check / check (push) Waiting to run
The test helpers lived in ordinary `testing.go` files inside the config, database, middleware and session packages, so they were built into the binary and the shared `test-support` lint rule could not see them. The four files are gone: the session's helpers move into its own `_test.go` file, and the rest into `configtest`, `databasetest` and `middlewaretest`, which the `depguard` deny list now names, so a non-test file importing them fails lint. The test-support packages build through the production constructors. Judgement call: the session, the middleware and the webhook database manager now take the plain logger they log through, which the application wiring provides. Judgement call: two idle-expiry tests move the stored timestamps back instead of advancing a fake clock. Model: opus-5-5
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// The archive rotations, and the configs of a daily target and of one
|
|
// whose rotation is not one of the four.
|
|
const (
|
|
rotationNone = "none"
|
|
rotationMonthly = "monthly"
|
|
rotationDaily = "daily"
|
|
rotationHourly = "hourly"
|
|
|
|
dailyConfig = `{"rotation":"daily"}`
|
|
weeklyConfig = `{"rotation":"weekly"}`
|
|
)
|
|
|
|
// The periods the tests archive into most: two days, and an hour of
|
|
// the first.
|
|
const (
|
|
dayPeriod = "2026-03-04"
|
|
nextDayPeriod = "2026-03-05"
|
|
hourPeriod = "2026-03-04-05"
|
|
)
|
|
|
|
// periodPath returns the archive file for a period of the target whose
|
|
// file without a period is path.
|
|
func periodPath(path, period string) string {
|
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
|
}
|
|
|
|
// deliverReceivedAt delivers to a database target an event whose
|
|
// receive time is receivedAt, and returns the event's id. The receive
|
|
// time is what decides a rotated archive's file, so setting it is how
|
|
// these tests move the clock across a period boundary.
|
|
func (env *archiveEnv) deliverReceivedAt(
|
|
t *testing.T, tgt *database.Target, receivedAt time.Time,
|
|
) string {
|
|
t.Helper()
|
|
|
|
webhookDB := testWebhookDB(t)
|
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
|
event.CreatedAt = receivedAt
|
|
|
|
env.eng.ExportDeliverDatabase(
|
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
|
)
|
|
|
|
return event.ID
|
|
}
|
|
|
|
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
|
|
// rotation, an event received in the last second of a period and one
|
|
// received in the first second of the next, and checks each lands in
|
|
// the file named for its own period, in UTC. Rotation none keeps both
|
|
// in the one file.
|
|
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
berlin := time.FixedZone("CEST", 2*60*60)
|
|
|
|
cases := []struct {
|
|
name string
|
|
rotation string
|
|
before, after time.Time
|
|
// periods are the periods of before and after.
|
|
periods [2]string
|
|
}{
|
|
{
|
|
rotationMonthly, rotationMonthly,
|
|
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
|
|
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
|
[2]string{"2026-01", "2026-02"},
|
|
},
|
|
{
|
|
rotationDaily, rotationDaily,
|
|
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
|
|
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
|
|
[2]string{dayPeriod, nextDayPeriod},
|
|
},
|
|
{
|
|
// The same instants, received in a zone two hours ahead
|
|
// of UTC, where they fall on 5 March: the period is UTC's.
|
|
"daily in another zone", rotationDaily,
|
|
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
|
|
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
|
|
[2]string{dayPeriod, nextDayPeriod},
|
|
},
|
|
{
|
|
rotationHourly, rotationHourly,
|
|
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
|
|
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
|
|
[2]string{hourPeriod, "2026-03-04-06"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
|
|
path := env.archivePath(tgt)
|
|
|
|
first := env.deliverReceivedAt(t, tgt, tc.before)
|
|
second := env.deliverReceivedAt(t, tgt, tc.after)
|
|
|
|
assert.Equal(t, []string{first},
|
|
archivedEventIDs(t, periodPath(path, tc.periods[0])))
|
|
assert.Equal(t, []string{second},
|
|
archivedEventIDs(t, periodPath(path, tc.periods[1])))
|
|
assert.NoFileExists(t, path,
|
|
"a rotated target never writes the file without a period")
|
|
})
|
|
}
|
|
|
|
t.Run(rotationNone, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
|
|
|
|
first := env.deliverReceivedAt(t, tgt, cases[0].before)
|
|
second := env.deliverReceivedAt(t, tgt, cases[0].after)
|
|
|
|
assert.ElementsMatch(t, []string{first, second},
|
|
archivedEventIDs(t, env.archivePath(tgt)))
|
|
})
|
|
}
|
|
|
|
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
|
|
// receive time is not known goes to the file for the time it is
|
|
// archived, rather than to one for the year 1.
|
|
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
|
|
path := env.archivePath(tgt)
|
|
|
|
before := time.Now().UTC().Format("2006-01")
|
|
id := env.deliverReceivedAt(t, tgt, time.Time{})
|
|
|
|
file := periodPath(path, time.Now().UTC().Format("2006-01"))
|
|
|
|
_, err := os.Stat(file)
|
|
if err != nil {
|
|
// The month turned during the delivery.
|
|
file = periodPath(path, before)
|
|
}
|
|
|
|
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
|
|
assert.NoFileExists(t, periodPath(path, "0001-01"))
|
|
}
|
|
|
|
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
|
|
// rotation from none to daily between two events, and checks the
|
|
// second goes to the daily file while the first stays where it was.
|
|
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, "")
|
|
path := env.archivePath(tgt)
|
|
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
first := env.deliverReceivedAt(t, tgt, at)
|
|
|
|
tgt.Config = dailyConfig
|
|
second := env.deliverReceivedAt(t, tgt, at)
|
|
|
|
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
|
|
assert.Equal(t, []string{second},
|
|
archivedEventIDs(t, periodPath(path, dayPeriod)))
|
|
}
|
|
|
|
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
|
|
// the file without a period, left from before it rotated, and two
|
|
// daily files. Each holds a row older than the expiry, and one daily
|
|
// file also a newer row. The sweep prunes the old row from every file,
|
|
// deletes the daily file it leaves empty, and keeps the file without a
|
|
// period although it is empty too.
|
|
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(
|
|
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
|
|
)
|
|
path := env.archivePath(tgt)
|
|
emptied := periodPath(path, dayPeriod)
|
|
kept := periodPath(path, nextDayPeriod)
|
|
|
|
now := time.Now()
|
|
old := now.Add(-48 * time.Hour)
|
|
|
|
seedArchiveFile(t, path, tgt.WebhookID, old)
|
|
seedArchiveFile(t, emptied, tgt.WebhookID, old)
|
|
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
|
|
|
|
env.sweeper.ExportSweep(t.Context())
|
|
|
|
assert.Empty(t, archivedEventIDs(t, path))
|
|
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
|
|
|
|
for _, suffix := range archiveFileSuffixes() {
|
|
assert.NoFileExists(t, emptied+suffix)
|
|
}
|
|
}
|
|
|
|
// TestRename_MovesEveryFile renames a daily target that also has a
|
|
// file without a period, and checks every file moves to the new name
|
|
// with its period, rows and all, and that a later write uses the new
|
|
// name.
|
|
func TestRename_MovesEveryFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, "")
|
|
oldPath := env.archivePath(tgt)
|
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
unrotated := env.deliverReceivedAt(t, tgt, day)
|
|
|
|
tgt.Config = dailyConfig
|
|
first := env.deliverReceivedAt(t, tgt, day)
|
|
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
|
|
|
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
|
|
|
|
newPath := filepath.Join(
|
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
)
|
|
|
|
for _, old := range []string{
|
|
oldPath,
|
|
periodPath(oldPath, dayPeriod),
|
|
periodPath(oldPath, nextDayPeriod),
|
|
} {
|
|
assert.NoFileExists(t, old)
|
|
}
|
|
|
|
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
|
|
assert.Equal(t, []string{first},
|
|
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
|
|
assert.Equal(t, []string{second},
|
|
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
|
|
|
|
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
|
|
assert.Equal(t, []string{third},
|
|
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
|
|
}
|
|
|
|
// TestRename_NeverReplacesARotatedFile plants a file at the new name
|
|
// of a target's daily file, and proves the rename is refused and moves
|
|
// none of the target's files.
|
|
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, dailyConfig)
|
|
oldPath := env.archivePath(tgt)
|
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
env.deliverReceivedAt(t, tgt, day)
|
|
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
|
|
|
newPath := filepath.Join(
|
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
|
)
|
|
planted := periodPath(newPath, nextDayPeriod)
|
|
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
|
|
|
|
require.ErrorIs(
|
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
|
delivery.ErrArchiveNameTaken,
|
|
)
|
|
|
|
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
|
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
|
|
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
|
}
|
|
|
|
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
|
|
// one of a target's files, takes the latest write of any of them, and
|
|
// leaves out files whose names only look like the target's.
|
|
func TestStatArchive_EveryFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "archive-wh.db")
|
|
files := []string{
|
|
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
|
|
}
|
|
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
|
|
|
|
var size int64
|
|
|
|
for i, file := range files {
|
|
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
|
|
|
|
size += int64(100 * (i + 1))
|
|
at := written.Add(-time.Duration(i) * time.Hour)
|
|
require.NoError(t, os.Chtimes(file, at, at))
|
|
}
|
|
|
|
for _, other := range []string{
|
|
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
|
|
"archive-wh-other.db", "archive-wh-2026-03.json",
|
|
"archive-whx.db",
|
|
} {
|
|
require.NoError(t,
|
|
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
|
|
}
|
|
|
|
got, err := delivery.StatArchive(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, len(files), got.Files)
|
|
assert.Equal(t, size, got.Size)
|
|
assert.True(t, written.Equal(got.Written), got.Written)
|
|
}
|
|
|
|
// TestArchivePathAt names the file each rotation writes an event to.
|
|
func TestArchivePathAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := t.TempDir()
|
|
dbMgr := databasetest.NewWebhookDBManager(t, dataDir)
|
|
webhook := &database.Webhook{
|
|
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
|
|
}
|
|
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
|
|
|
|
cases := map[string]string{
|
|
"": "",
|
|
`{"rotation":"` + rotationNone + `"}`: "",
|
|
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
|
|
dailyConfig: "-2026-10-01",
|
|
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
|
|
}
|
|
|
|
for config, period := range cases {
|
|
target := &database.Target{
|
|
BaseModel: database.BaseModel{ID: "tgt-id"},
|
|
Name: "Archive",
|
|
Config: config,
|
|
}
|
|
|
|
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
|
|
require.NoError(t, err, config)
|
|
assert.Equal(t,
|
|
filepath.Join(
|
|
dataDir, "archive-orders-archive-tgt-id"+period+".db",
|
|
),
|
|
got, config,
|
|
)
|
|
}
|
|
|
|
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
|
|
Config: weeklyConfig,
|
|
}, at)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestValidateArchiveRotation accepts the four rotations, and empty,
|
|
// and refuses anything else.
|
|
func TestValidateArchiveRotation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, ok := range []string{
|
|
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
|
} {
|
|
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
|
|
}
|
|
|
|
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
|
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
|
|
}
|
|
}
|