check / check (push) Successful in 10m29s
The four testing.go files in config, database, middleware and session are gone. ClearEnvForTest moves to internal/config/configtest; the webhook database manager helpers move to internal/database/databasetest and the middleware's NewForTest to internal/middleware/middlewaretest, both now built through the production constructors. Tests that wrapped an open main database use database.Open. The session helpers move into the session package's export_test.go; the middleware tests build their session through session.New and age its timestamps instead of using a fake clock. The session, middleware and webhook database manager now take the *slog.Logger they log through, so tests in other packages can give them their own. Model: opus-5-5
403 lines
9.2 KiB
Go
403 lines
9.2 KiB
Go
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.Get()},
|
|
)
|
|
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)
|
|
}
|
|
|
|
// TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents pins the
|
|
// overflow that made a large finite retention destroy everything.
|
|
//
|
|
// The cutoff is a time.Duration, an int64 nanosecond count. A day
|
|
// count above MaxFiniteRetentionDays multiplied out unclamped wraps
|
|
// negative, so subtracting it moves the cutoff into the far future,
|
|
// where "created_at < cutoff" matches every row: an event created a
|
|
// moment ago, and its delivery and delivery result, were all deleted
|
|
// on the first sweep. 200000 is inside that band and below the
|
|
// retain-forever sentinel, so it is treated as a finite policy and
|
|
// really does reach the arithmetic.
|
|
//
|
|
// The row is planted at the column level because such a value can no
|
|
// longer be submitted through the form; the point of the test is that
|
|
// a row from an older version, or a future call site, still cannot
|
|
// trigger the wipe.
|
|
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := setupRetentionTest(t)
|
|
|
|
const overflowingRetentionDays = 200000
|
|
|
|
require.Greater(
|
|
t,
|
|
overflowingRetentionDays,
|
|
database.MaxFiniteRetentionDays,
|
|
"the test value must exceed what the cutoff can represent",
|
|
)
|
|
require.Less(
|
|
t,
|
|
overflowingRetentionDays,
|
|
database.RetentionForeverDays,
|
|
"the test value must not be treated as retain-forever",
|
|
)
|
|
|
|
webhookID := createWebhook(
|
|
t, env.mainDB.DB(), overflowingRetentionDays,
|
|
)
|
|
|
|
db, err := env.mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := seedEventChain(t, db, webhookID, time.Now())
|
|
|
|
env.reaper.ExportSweep(context.Background())
|
|
|
|
assertChainPresent(t, db, fresh)
|
|
}
|
|
|
|
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)
|
|
}
|