package database_test import ( "context" "reflect" "strconv" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/database" ) // startedTestDB returns a started main database for model-level tests. func startedTestDB(t *testing.T) *gorm.DB { t.Helper() db, lc := setupTestDB(t) ctx := context.Background() require.NoError(t, lc.Start(ctx)) t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) }) return db.DB() } // storedRetention reads the retention_days column straight out of the // row, so the assertion is about what was persisted rather than about // whatever the in-memory struct happens to hold. func storedRetention(t *testing.T, db *gorm.DB, id string) int { t.Helper() var got int require.NoError( t, db.Model(&database.Webhook{}). Where("id = ?", id). Pluck("retention_days", &got).Error, ) return got } // newWebhookWithRetention creates a webhook through the ordinary Create // path, so the BeforeSave hook and the GORM column default both apply // exactly as they do in production. func newWebhookWithRetention( t *testing.T, db *gorm.DB, wh *database.Webhook, ) string { t.Helper() wh.UserID = uuid.New().String() wh.Name = testWebhookName require.NoError( t, db.Omit(clause.Associations).Create(wh).Error, ) return wh.ID } func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) { t.Parallel() db := startedTestDB(t) wh := &database.Webhook{RetentionDays: 0} id := newWebhookWithRetention(t, db, wh) assert.Equal( t, database.RetentionForeverDays, storedRetention(t, db, id), "a zero retention must be stored as the sentinel, "+ "not replaced by the column default", ) } func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) { t.Parallel() db := startedTestDB(t) wh := &database.Webhook{RetentionDays: -5} id := newWebhookWithRetention(t, db, wh) assert.Equal( t, database.RetentionForeverDays, storedRetention(t, db, id), ) } func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) { t.Parallel() db := startedTestDB(t) wh := &database.Webhook{RetentionDays: 7} id := newWebhookWithRetention(t, db, wh) assert.Equal(t, 7, storedRetention(t, db, id)) } // TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook // fires on update as well as insert, via the same Save call the edit // handler makes. func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) { t.Parallel() db := startedTestDB(t) wh := &database.Webhook{RetentionDays: 30} id := newWebhookWithRetention(t, db, wh) require.Equal(t, 30, storedRetention(t, db, id)) wh.RetentionDays = 0 require.NoError(t, db.Omit(clause.Associations).Save(wh).Error) assert.Equal( t, database.RetentionForeverDays, storedRetention(t, db, id), ) } // TestWebhookRetentionColumnDefaultMatchesConstant guards the one place // the default lives twice: a struct tag cannot reference a constant, so // this asserts the tag and DefaultRetentionDays agree. func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) { t.Parallel() field, ok := reflect.TypeFor[database.Webhook](). FieldByName("RetentionDays") require.True(t, ok, "Webhook.RetentionDays must exist") assert.Equal( t, "default:"+strconv.Itoa(database.DefaultRetentionDays), field.Tag.Get("gorm"), ) } // TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the // constant is exactly where the cutoff arithmetic stops working, which // is what makes it a derived bound rather than a round number someone // liked. One day more wraps the int64 nanosecond count negative, and a // negative span is precisely what turned a cutoff into a future // timestamp that matched — and deleted — every row. // // The multiplications are done through variables on purpose: as // constant expressions the overflowing one would not compile. func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) { t.Parallel() const hoursPerDay = 24 atCeiling := database.MaxFiniteRetentionDays overCeiling := database.MaxFiniteRetentionDays + 1 assert.Positive( t, time.Duration(atCeiling*hoursPerDay)*time.Hour, "the ceiling itself must still be representable", ) assert.Negative( t, time.Duration(overCeiling*hoursPerDay)*time.Hour, "one day past the ceiling must overflow", ) assert.Less( t, database.MaxFiniteRetentionDays, database.RetentionForeverDays, "the sentinel sits above the ceiling and is only safe "+ "because retain-forever webhooks skip the arithmetic", ) } func TestWebhookRetainsForeverAndLabel(t *testing.T) { t.Parallel() cases := []struct { name string days int forever bool label string }{ { "sentinel", database.RetentionForeverDays, true, testForeverLabel, }, { "above sentinel", database.RetentionForeverDays + 1, true, testForeverLabel, }, {"legacy zero", 0, true, testForeverLabel}, {"legacy negative", -1, true, testForeverLabel}, {"default", database.DefaultRetentionDays, false, "30 days"}, {"one day", 1, false, "1 day"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() wh := database.Webhook{RetentionDays: tc.days} assert.Equal(t, tc.forever, wh.RetainsForever()) assert.Equal(t, tc.label, wh.RetentionLabel()) }) } }