package database_test import ( "context" "net/http" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx/fxtest" "gorm.io/gorm" "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) // retentionTestEnv bundles the pieces a retention test drives. type retentionTestEnv struct { reaper *database.RetentionReaper mainDB *database.Database mgr *database.WebhookDBManager } func setupRetentionTest(t *testing.T) *retentionTestEnv { t.Helper() lc := fxtest.NewLifecycle(t) g := &globals.Globals{ Appname: testAppname, Version: testVersion, } l, err := logger.New(lc, logger.LoggerParams{Globals: g}) require.NoError(t, err) cfg := &config.Config{ DataDir: t.TempDir(), Environment: "dev", } mainDB, err := database.New(lc, database.DatabaseParams{ Config: cfg, Logger: l, }) require.NoError(t, err) mgr, err := database.NewWebhookDBManager( lc, database.WebhookDBManagerParams{Config: cfg, Logger: l}, ) require.NoError(t, err) ctx := context.Background() require.NoError(t, lc.Start(ctx)) t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) }) return &retentionTestEnv{ reaper: database.NewTestRetentionReaper(mainDB, mgr), mainDB: mainDB, mgr: mgr, } } // createWebhook inserts a webhook row into the main database with the // given retention policy and returns its ID. func createWebhook( t *testing.T, db *gorm.DB, retentionDays int, ) string { t.Helper() wh := &database.Webhook{ UserID: uuid.New().String(), Name: testWebhookName, RetentionDays: retentionDays, } require.NoError( t, db.Omit(clause.Associations).Create(wh).Error, ) // Webhook.BeforeSave rewrites a non-positive RetentionDays to the // retain-forever sentinel, and the column's GORM default would // otherwise substitute 30. Force the requested value with a // column-level update so tests can plant legacy rows that predate // the sentinel and still carry a literal 0 or negative value. require.NoError( t, db.Model(wh). Update("retention_days", retentionDays).Error, ) return wh.ID } // createWebhookNormally inserts a webhook through the ordinary Create // path, with no column-level forcing, so Webhook.BeforeSave applies // exactly as it does in production. Passing 0 therefore yields a row // holding the RetentionForeverDays sentinel. func createWebhookNormally( t *testing.T, db *gorm.DB, retentionDays int, ) string { t.Helper() wh := &database.Webhook{ UserID: uuid.New().String(), Name: testWebhookName, RetentionDays: retentionDays, } require.NoError( t, db.Omit(clause.Associations).Create(wh).Error, ) return wh.ID } // eventChain is the set of row IDs seeded for a single event. type eventChain struct { eventID string deliveryID string resultID string } // seedEventChain creates an event with one delivery and one delivery // result, all stamped with createdAt, and returns their IDs. func seedEventChain( t *testing.T, db *gorm.DB, webhookID string, createdAt time.Time, ) eventChain { t.Helper() event := &database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), Method: http.MethodPost, Body: `{"seed": true}`, ContentType: testContentType, } event.CreatedAt = createdAt require.NoError(t, db.Create(event).Error) delivery := &database.Delivery{ EventID: event.ID, TargetID: uuid.New().String(), Status: database.DeliveryStatusDelivered, } delivery.CreatedAt = createdAt require.NoError(t, db.Create(delivery).Error) result := &database.DeliveryResult{ DeliveryID: delivery.ID, AttemptNum: 1, Success: true, StatusCode: 200, Duration: 10, } result.CreatedAt = createdAt require.NoError(t, db.Create(result).Error) return eventChain{ eventID: event.ID, deliveryID: delivery.ID, resultID: result.ID, } } // countByID returns how many rows of model match the given id, // counting even hard-deletable rows via Unscoped. func countByID( t *testing.T, db *gorm.DB, model any, id string, ) int64 { t.Helper() var n int64 require.NoError( t, db.Unscoped().Model(model). Where("id = ?", id).Count(&n).Error, ) return n } func assertChainGone( t *testing.T, db *gorm.DB, chain eventChain, ) { t.Helper() assert.Zero( t, countByID(t, db, &database.Event{}, chain.eventID), "expired event should be removed", ) assert.Zero( t, countByID(t, db, &database.Delivery{}, chain.deliveryID), "expired delivery should be removed", ) assert.Zero( t, countByID( t, db, &database.DeliveryResult{}, chain.resultID, ), "expired delivery result should be removed", ) } func assertChainPresent( t *testing.T, db *gorm.DB, chain eventChain, ) { t.Helper() assert.Equal( t, int64(1), countByID(t, db, &database.Event{}, chain.eventID), "recent event should be retained", ) assert.Equal( t, int64(1), countByID(t, db, &database.Delivery{}, chain.deliveryID), "recent delivery should be retained", ) assert.Equal( t, int64(1), countByID( t, db, &database.DeliveryResult{}, chain.resultID, ), "recent delivery result should be retained", ) } func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) { t.Parallel() env := setupRetentionTest(t) const retentionDays = 30 webhookID := createWebhook( t, env.mainDB.DB(), retentionDays, ) db, err := env.mgr.GetDB(webhookID) require.NoError(t, err) now := time.Now() old := seedEventChain( t, db, webhookID, now.Add(-40*24*time.Hour), ) recent := seedEventChain( t, db, webhookID, now.Add(-1*24*time.Hour), ) env.reaper.ExportSweep(context.Background()) assertChainGone(t, db, old) assertChainPresent(t, db, recent) } // TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the // end-to-end retain-forever path: a webhook created the normal way with // a requested retention of 0 lands on the RetentionForeverDays // sentinel, and the reaper leaves its ancient events alone while still // reaping a finite-retention webhook in the very same sweep. func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep( t *testing.T, ) { t.Parallel() env := setupRetentionTest(t) foreverID := createWebhookNormally(t, env.mainDB.DB(), 0) var stored database.Webhook require.NoError( t, env.mainDB.DB().Where("id = ?", foreverID). First(&stored).Error, ) require.Equal( t, database.RetentionForeverDays, stored.RetentionDays, "a requested retention of 0 must persist as the sentinel", ) finiteID := createWebhookNormally(t, env.mainDB.DB(), 30) foreverDB, err := env.mgr.GetDB(foreverID) require.NoError(t, err) finiteDB, err := env.mgr.GetDB(finiteID) require.NoError(t, err) ancient := time.Now().Add(-365 * 24 * time.Hour) kept := seedEventChain(t, foreverDB, foreverID, ancient) doomed := seedEventChain(t, finiteDB, finiteID, ancient) env.reaper.ExportSweep(context.Background()) assertChainPresent(t, foreverDB, kept) assertChainGone(t, finiteDB, doomed) } func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) { t.Parallel() env := setupRetentionTest(t) // A legacy row written before the sentinel existed still carries a // literal 0; the <= 0 guard must keep honouring it. webhookID := createWebhook(t, env.mainDB.DB(), 0) db, err := env.mgr.GetDB(webhookID) require.NoError(t, err) ancient := seedEventChain( t, db, webhookID, time.Now().Add(-365*24*time.Hour), ) env.reaper.ExportSweep(context.Background()) assertChainPresent(t, db, ancient) }