package delivery_test import ( "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // The archive rotations, and the configs of a daily target and of one // whose rotation is not one of the four. const ( rotationNone = "none" rotationMonthly = "monthly" rotationDaily = "daily" rotationHourly = "hourly" dailyConfig = `{"rotation":"daily"}` weeklyConfig = `{"rotation":"weekly"}` ) // The periods the tests archive into most: two days, and an hour of // the first. const ( dayPeriod = "2026-03-04" nextDayPeriod = "2026-03-05" hourPeriod = "2026-03-04-05" ) // periodPath returns the archive file for a period of the target whose // file without a period is path. func periodPath(path, period string) string { return strings.TrimSuffix(path, ".db") + "-" + period + ".db" } // deliverReceivedAt delivers to a database target an event whose // receive time is receivedAt, and returns the event's id. The receive // time is what decides a rotated archive's file, so setting it is how // these tests move the clock across a period boundary. func (env *archiveEnv) deliverReceivedAt( t *testing.T, tgt *database.Target, receivedAt time.Time, ) string { t.Helper() webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"n":1}`) event.CreatedAt = receivedAt env.eng.ExportDeliverDatabase( webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), ) return event.ID } // TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each // rotation, an event received in the last second of a period and one // received in the first second of the next, and checks each lands in // the file named for its own period, in UTC. Rotation none keeps both // in the one file. func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) { t.Parallel() berlin := time.FixedZone("CEST", 2*60*60) cases := []struct { name string rotation string before, after time.Time // periods are the periods of before and after. periods [2]string }{ { rotationMonthly, rotationMonthly, time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC), time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), [2]string{"2026-01", "2026-02"}, }, { rotationDaily, rotationDaily, time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC), time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC), [2]string{dayPeriod, nextDayPeriod}, }, { // The same instants, received in a zone two hours ahead // of UTC, where they fall on 5 March: the period is UTC's. "daily in another zone", rotationDaily, time.Date(2026, 3, 5, 1, 59, 59, 0, berlin), time.Date(2026, 3, 5, 2, 0, 0, 0, berlin), [2]string{dayPeriod, nextDayPeriod}, }, { rotationHourly, rotationHourly, time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC), time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC), [2]string{hourPeriod, "2026-03-04-06"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`) path := env.archivePath(tgt) first := env.deliverReceivedAt(t, tgt, tc.before) second := env.deliverReceivedAt(t, tgt, tc.after) assert.Equal(t, []string{first}, archivedEventIDs(t, periodPath(path, tc.periods[0]))) assert.Equal(t, []string{second}, archivedEventIDs(t, periodPath(path, tc.periods[1]))) assert.NoFileExists(t, path, "a rotated target never writes the file without a period") }) } t.Run(rotationNone, func(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`) first := env.deliverReceivedAt(t, tgt, cases[0].before) second := env.deliverReceivedAt(t, tgt, cases[0].after) assert.ElementsMatch(t, []string{first, second}, archivedEventIDs(t, env.archivePath(tgt))) }) } // TestDeliverDatabase_EventWithoutReceiveTime proves an event whose // receive time is not known goes to the file for the time it is // archived, rather than to one for the year 1. func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`) path := env.archivePath(tgt) before := time.Now().UTC().Format("2006-01") id := env.deliverReceivedAt(t, tgt, time.Time{}) file := periodPath(path, time.Now().UTC().Format("2006-01")) _, err := os.Stat(file) if err != nil { // The month turned during the delivery. file = periodPath(path, before) } assert.Equal(t, []string{id}, archivedEventIDs(t, file)) assert.NoFileExists(t, periodPath(path, "0001-01")) } // TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's // rotation from none to daily between two events, and checks the // second goes to the daily file while the first stays where it was. func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, "") path := env.archivePath(tgt) at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) first := env.deliverReceivedAt(t, tgt, at) tgt.Config = dailyConfig second := env.deliverReceivedAt(t, tgt, at) assert.Equal(t, []string{first}, archivedEventIDs(t, path)) assert.Equal(t, []string{second}, archivedEventIDs(t, periodPath(path, dayPeriod))) } // TestArchiveSweep_PrunesEveryFile gives a daily target three files: // the file without a period, left from before it rotated, and two // daily files. Each holds a row older than the expiry, and one daily // file also a newer row. The sweep prunes the old row from every file, // deletes the daily file it leaves empty, and keeps the file without a // period although it is empty too. func TestArchiveSweep_PrunesEveryFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget( t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`, ) path := env.archivePath(tgt) emptied := periodPath(path, dayPeriod) kept := periodPath(path, nextDayPeriod) now := time.Now() old := now.Add(-48 * time.Hour) seedArchiveFile(t, path, tgt.WebhookID, old) seedArchiveFile(t, emptied, tgt.WebhookID, old) seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute)) env.sweeper.ExportSweep(t.Context()) assert.Empty(t, archivedEventIDs(t, path)) assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept)) for _, suffix := range archiveFileSuffixes() { assert.NoFileExists(t, emptied+suffix) } } // TestRename_MovesEveryFile renames a daily target that also has a // file without a period, and checks every file moves to the new name // with its period, rows and all, and that a later write uses the new // name. func TestRename_MovesEveryFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, "") oldPath := env.archivePath(tgt) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) unrotated := env.deliverReceivedAt(t, tgt, day) tgt.Config = dailyConfig first := env.deliverReceivedAt(t, tgt, day) second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term")) newPath := filepath.Join( env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", ) for _, old := range []string{ oldPath, periodPath(oldPath, dayPeriod), periodPath(oldPath, nextDayPeriod), } { assert.NoFileExists(t, old) } assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath)) assert.Equal(t, []string{first}, archivedEventIDs(t, periodPath(newPath, dayPeriod))) assert.Equal(t, []string{second}, archivedEventIDs(t, periodPath(newPath, nextDayPeriod))) third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour)) assert.Equal(t, []string{third}, archivedEventIDs(t, periodPath(newPath, "2026-03-06"))) } // TestRename_NeverReplacesARotatedFile plants a file at the new name // of a target's daily file, and proves the rename is refused and moves // none of the target's files. func TestRename_NeverReplacesARotatedFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) tgt := env.seedDatabaseTarget(t, dailyConfig) oldPath := env.archivePath(tgt) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) env.deliverReceivedAt(t, tgt, day) env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) newPath := filepath.Join( env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", ) planted := periodPath(newPath, nextDayPeriod) require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) require.ErrorIs( t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), delivery.ErrArchiveNameTaken, ) assert.FileExists(t, periodPath(oldPath, dayPeriod)) assert.FileExists(t, periodPath(oldPath, nextDayPeriod)) assert.NoFileExists(t, periodPath(newPath, dayPeriod)) } // TestStatArchive_EveryFile proves StatArchive counts and adds up every // one of a target's files, takes the latest write of any of them, and // leaves out files whose names only look like the target's. func TestStatArchive_EveryFile(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "archive-wh.db") files := []string{ path, periodPath(path, "2026-03"), periodPath(path, hourPeriod), } written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) var size int64 for i, file := range files { require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600)) size += int64(100 * (i + 1)) at := written.Add(-time.Duration(i) * time.Hour) require.NoError(t, os.Chtimes(file, at, at)) } for _, other := range []string{ "archive-wh-2026-13.db", "archive-wh-2026-3.db", "archive-wh-other.db", "archive-wh-2026-03.json", "archive-whx.db", } { require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) } got, err := delivery.StatArchive(path) require.NoError(t, err) assert.Equal(t, len(files), got.Files) assert.Equal(t, size, got.Size) assert.True(t, written.Equal(got.Written), got.Written) } // TestArchivePathAt names the file each rotation writes an event to. func TestArchivePathAt(t *testing.T) { t.Parallel() dataDir := t.TempDir() dbMgr := database.NewTestWebhookDBManager(dataDir) webhook := &database.Webhook{ BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders", } at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC) cases := map[string]string{ "": "", `{"rotation":"` + rotationNone + `"}`: "", `{"rotation":"` + rotationMonthly + `"}`: "-2026-10", dailyConfig: "-2026-10-01", `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19", } for config, period := range cases { target := &database.Target{ BaseModel: database.BaseModel{ID: "tgt-id"}, Name: "Archive", Config: config, } got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at) require.NoError(t, err, config) assert.Equal(t, filepath.Join( dataDir, "archive-orders-archive-tgt-id"+period+".db", ), got, config, ) } _, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{ Config: weeklyConfig, }, at) require.Error(t, err) } // TestValidateArchiveRotation accepts the four rotations, and empty, // and refuses anything else. func TestValidateArchiveRotation(t *testing.T) { t.Parallel() for _, ok := range []string{ "", rotationNone, rotationMonthly, rotationDaily, rotationHourly, } { require.NoError(t, delivery.ValidateArchiveRotation(ok), ok) } for _, bad := range []string{"weekly", "Daily", "hourly "} { require.Error(t, delivery.ValidateArchiveRotation(bad), bad) } }