All checks were successful
check / check (push) Successful in 3m4s
RetentionDays carried gorm:"default:30", so GORM substituted 30 for a zero value while building the insert. A webhook could therefore never be configured to keep its events indefinitely: the reaper's retain-forever branch existed but was unreachable from the normal create and edit flows. Introduce database.RetentionForeverDays = 365 * 1000 as the sentinel for "retain forever" and a Webhook.BeforeSave hook that rewrites any non-positive RetentionDays to it. The rewrite has to live in the hook rather than at the call sites: GORM applies the column default while converting the model to insert values, which happens after BeforeSave, so anything later loses that race. Putting it on the model also means a future call site, such as the planned REST API, cannot bypass it. The reaper now skips a webhook when Webhook.RetainsForever reports true, which recognises the sentinel and keeps honouring the old <= 0 values for rows written before it existed. Without this the sentinel, being positive, would have produced a cutoff a thousand years in the past and a DELETE matching nothing on every sweep. Form handling is shared by create and edit through parseRetentionDays so the two cannot drift: an empty field keeps the previous behaviour (default on create, unchanged on edit), 0 is honoured, and an unparseable or negative value is a 400 that re-renders the form rather than a silently substituted default. The retention inputs drop max="365". That cap was not cosmetic: the edit form pre-fills the stored value, so a retain-forever webhook rendered 365000 into an input capped at 365 and browser validation would have blocked saving any edit to it. min becomes 0 with a hint explaining what 0 does, and the list and detail views render a RetentionLabel of "forever" instead of a raw day count. The 30-day default is consolidated into database.DefaultRetentionDays, referenced from the handler and from the create form's pre-filled value, with a test asserting it agrees with the struct tag that cannot reference it.
185 lines
4.1 KiB
Go
185 lines
4.1 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"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"),
|
|
)
|
|
}
|
|
|
|
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())
|
|
})
|
|
}
|
|
}
|