package delivery_test import ( "database/sql" "fmt" "log/slog" "net/http" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) func archiveTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) } // openArchiveDBForRead opens an archive file read-only so a // test can inspect the rows the writer persisted. func openArchiveDBForRead( t *testing.T, path string, ) *gorm.DB { t.Helper() sqlDB, err := sql.Open( "sqlite", fmt.Sprintf("file:%s?mode=ro", path), ) require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) gdb, err := gorm.Open( sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, ) require.NoError(t, err) return gdb } // removeArchiveFiles simulates an operator moving the archive // away by deleting the SQLite file and its sidecar files. func removeArchiveFiles(t *testing.T, path string) { t.Helper() for _, suffix := range []string{ "", "-wal", "-shm", "-journal", } { err := os.Remove(path + suffix) if err != nil && !os.IsNotExist(err) { t.Fatalf("removing %s%s: %v", path, suffix, err) } } } // TestDeliverDatabase_ArchivesEvent verifies that delivering to // a database target marks the delivery delivered and archives // the full event into a separate per-webhook archive file. func TestDeliverDatabase_ArchivesEvent(t *testing.T) { t.Parallel() dataDir := t.TempDir() dbMgr := database.NewTestWebhookDBManager(dataDir) e := delivery.NewTestEngineWithDB( nil, dbMgr, archiveTestLogger(), &http.Client{Timeout: 5 * time.Second}, 1, ) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) d := seedDatabaseTargetDelivery(t, webhookDB, event, "") e.ExportDeliverDatabase(webhookDB, d) var updated database.Delivery require.NoError(t, webhookDB.First( &updated, "id = ?", d.ID, ).Error) assert.Equal(t, database.DeliveryStatusDelivered, updated.Status, "database target should mark the delivery delivered", ) archivePath := filepath.Join( dataDir, fmt.Sprintf("archive-%s.db", event.WebhookID), ) assert.FileExists(t, archivePath) rdb := openArchiveDBForRead(t, archivePath) var rows []delivery.ExportArchivedEvent require.NoError(t, rdb.Find(&rows).Error) require.Len(t, rows, 1) assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.Method, rows[0].Method) assert.JSONEq(t, `{"archived":true}`, rows[0].Body) } func TestArchiveWriter_WritesRow(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive-wh.db") w := delivery.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) row := delivery.ExportArchivedEvent{ EventID: "ev-1", WebhookID: "wh-1", EntrypointID: "ep-1", Method: "POST", Headers: `{"X":"Y"}`, Body: `{"hello":"world"}`, ContentType: "application/json", } require.NoError(t, w.Write(row, 0)) assert.FileExists(t, path) var got []delivery.ExportArchivedEvent require.NoError(t, w.DB().Find(&got).Error) require.Len(t, got, 1) assert.Equal(t, "ev-1", got[0].EventID) assert.Equal(t, "wh-1", got[0].WebhookID) assert.Equal(t, "ep-1", got[0].EntrypointID) assert.Equal(t, row.Method, got[0].Method) assert.Equal(t, row.ContentType, got[0].ContentType) assert.JSONEq(t, `{"hello":"world"}`, got[0].Body) assert.False(t, got[0].ArchivedAt.IsZero()) } func TestArchiveWriter_RecreatesAfterRemoval( t *testing.T, ) { t.Parallel() path := filepath.Join(t.TempDir(), "archive-wh.db") w := delivery.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "a"}, 0, )) assert.FileExists(t, path) // The operator moves the archive away while the handle is // still open. removeArchiveFiles(t, path) require.NoFileExists(t, path) // The next write recreates the file with a fresh schema and // only the new row. require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "b"}, 0, )) assert.FileExists(t, path) var got []delivery.ExportArchivedEvent require.NoError(t, w.DB().Find(&got).Error) require.Len(t, got, 1) assert.Equal(t, "b", got[0].EventID) } func TestArchiveWriter_ReopenDebounce(t *testing.T) { t.Parallel() // A generous debounce keeps the two rapid writes inside // the window even on a heavily loaded test machine. path := filepath.Join(t.TempDir(), "archive-wh.db") w := delivery.NewExportArchiveWriter( path, archiveTestLogger(), 2*time.Second, ) require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "a"}, 0, )) require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "b"}, 0, )) // Two writes inside the debounce window trigger only the // initial open — no extra close/reopen. assert.Equal(t, 1, w.Reopens()) time.Sleep(2100 * time.Millisecond) require.NoError(t, w.Write( delivery.ExportArchivedEvent{EventID: "c"}, 0, )) // A write after the window elapses closes and reopens once. assert.Equal(t, 2, w.Reopens()) } func TestArchiveWriter_ExpiryPrune(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive-wh.db") w := delivery.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) require.NoError(t, w.Open(0)) old := delivery.ExportArchivedEvent{ EventID: "old", ArchivedAt: time.Now().Add(-2 * time.Hour), } fresh := delivery.ExportArchivedEvent{ EventID: "fresh", ArchivedAt: time.Now(), } require.NoError(t, w.DB().Create(&old).Error) require.NoError(t, w.DB().Create(&fresh).Error) // Reopening with a one-hour expiry prunes the old row. require.NoError(t, w.Reopen(time.Hour)) var got []delivery.ExportArchivedEvent require.NoError(t, w.DB().Find(&got).Error) require.Len(t, got, 1) assert.Equal(t, "fresh", got[0].EventID) } func TestParseArchiveExpiry(t *testing.T) { t.Parallel() cases := []struct { name string in string want time.Duration }{ {"empty config", "", 0}, {"explicit never", `{"expiry":"never"}`, 0}, {"empty expiry", `{"expiry":""}`, 0}, {"duration", `{"expiry":"1h"}`, time.Hour}, {"zero duration", `{"expiry":"0s"}`, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got, err := delivery.ExportParseArchiveExpiry(tc.in) require.NoError(t, err) assert.Equal(t, tc.want, got) }) } _, err := delivery.ExportParseArchiveExpiry( `{"expiry":"nonsense"}`, ) require.Error(t, err) } // seedDatabaseTargetDelivery seeds a pending delivery for a // database target with the given config JSON and returns the // in-memory delivery the target handler is invoked with. func seedDatabaseTargetDelivery( t *testing.T, webhookDB *gorm.DB, event database.Event, config string, ) *database.Delivery { t.Helper() dlv := seedDelivery( t, webhookDB, event.ID, uuid.New().String(), database.DeliveryStatusPending, ) d := &database.Delivery{ EventID: event.ID, TargetID: dlv.TargetID, Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ Name: "test-db", Type: database.TargetTypeDatabase, Config: config, }, } d.ID = dlv.ID return d } // TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that // an archive error (here: an unparseable expiry in the target // config) fails the delivery loudly: the attempt is recorded as // failed with the error and the delivery is marked failed, not // delivered. func TestDeliverDatabase_ArchiveFailureFailsDelivery( t *testing.T, ) { t.Parallel() dataDir := t.TempDir() e := delivery.NewTestEngineWithDB( nil, database.NewTestWebhookDBManager(dataDir), archiveTestLogger(), &http.Client{Timeout: 5 * time.Second}, 1, ) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":false}`) d := seedDatabaseTargetDelivery( t, webhookDB, event, `{"expiry":"nonsense"}`, ) e.ExportDeliverDatabase(webhookDB, d) var updated database.Delivery require.NoError(t, webhookDB.First( &updated, "id = ?", d.ID, ).Error) assert.Equal(t, database.DeliveryStatusFailed, updated.Status, "archive failure must mark the delivery failed", ) var results []database.DeliveryResult require.NoError(t, webhookDB.Where( "delivery_id = ?", d.ID, ).Find(&results).Error) require.Len(t, results, 1) assert.False(t, results[0].Success, "the attempt must be recorded as failed", ) assert.Contains(t, results[0].Error, "nonsense", "the archive error must be recorded on the attempt", ) assert.NoFileExists(t, filepath.Join( dataDir, fmt.Sprintf("archive-%s.db", event.WebhookID), ), "no archive file should exist for a failed config", ) } func TestValidateArchiveExpiry(t *testing.T) { t.Parallel() valid := []string{"", "never", "1h", "720h", "30m"} for _, in := range valid { require.NoError(t, delivery.ValidateArchiveExpiry(in), "expiry %q should be accepted", in, ) } invalid := []string{"nonsense", "7d", "-5h", "0s", "0"} for _, in := range invalid { require.Error(t, delivery.ValidateArchiveExpiry(in), "expiry %q should be rejected", in, ) } }