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: "test-webhook", RetentionDays: retentionDays, } require.NoError( t, db.Omit(clause.Associations).Create(wh).Error, ) // The RetentionDays column carries a GORM default of 30, so a // zero (or negative) value passed to Create is replaced by that // default. Force the requested value explicitly so the // retain-forever (<= 0) path can be exercised. require.NoError( t, db.Model(wh). Update("retention_days", retentionDays).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) } func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) { t.Parallel() env := setupRetentionTest(t) // RetentionDays of zero means retain forever. 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) }