check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never, so nothing that reads a target parses it. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string. Model: opus-5-5
376 lines
11 KiB
Go
376 lines
11 KiB
Go
package archive_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
|
)
|
|
|
|
// The archive rotations.
|
|
const (
|
|
rotationNone = "none"
|
|
rotationMonthly = "monthly"
|
|
rotationDaily = "daily"
|
|
rotationHourly = "hourly"
|
|
)
|
|
|
|
// 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 archives, as a delivery to a database target
|
|
// does, 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()
|
|
|
|
event := newEvent(`{"n":1}`)
|
|
event.CreatedAt = receivedAt
|
|
|
|
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
|
|
|
|
return event.ID
|
|
}
|
|
|
|
// TestWrite_RotatesAtEachPeriodBoundary writes, 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 TestWrite_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, 0, 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, 0, 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)))
|
|
})
|
|
}
|
|
|
|
// TestWrite_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 TestWrite_EventWithoutReceiveTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, 0, 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"))
|
|
}
|
|
|
|
// TestWrite_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 TestWrite_RotationChangeKeepsOldFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupArchiveTest(t)
|
|
tgt := env.seedDatabaseTarget(t, 0, rotationNone)
|
|
path := env.archivePath(tgt)
|
|
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
first := env.deliverReceivedAt(t, tgt, at)
|
|
|
|
tgt.ArchiveRotation = rotationDaily
|
|
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, time.Hour, 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, 0, rotationNone)
|
|
oldPath := env.archivePath(tgt)
|
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
unrotated := env.deliverReceivedAt(t, tgt, day)
|
|
|
|
tgt.ArchiveRotation = rotationDaily
|
|
first := env.deliverReceivedAt(t, tgt, day)
|
|
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
|
|
|
require.NoError(t, env.writers.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, 0, rotationDaily)
|
|
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.writers.Rename(tgt.ID, "Orders", "Long Term"),
|
|
archive.ErrNameTaken,
|
|
)
|
|
|
|
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
|
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
|
|
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
|
}
|
|
|
|
// TestStatArchive_EveryFile proves Stat 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 := archive.Stat(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{
|
|
"": "",
|
|
rotationNone: "",
|
|
rotationMonthly: "-2026-10",
|
|
rotationDaily: "-2026-10-01",
|
|
rotationHourly: "-2026-10-01-19",
|
|
}
|
|
|
|
for rotation, period := range cases {
|
|
target := &database.Target{
|
|
BaseModel: database.BaseModel{ID: "tgt-id"},
|
|
Name: "Archive",
|
|
ArchiveRotation: rotation,
|
|
}
|
|
|
|
assert.Equal(t,
|
|
filepath.Join(
|
|
dataDir, "archive-orders-archive-tgt-id"+period+".db",
|
|
),
|
|
archive.PathAt(dbMgr, webhook, target, at), rotation,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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, archive.ValidateRotation(ok), ok)
|
|
}
|
|
|
|
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
|
require.Error(t, archive.ValidateRotation(bad), bad)
|
|
}
|
|
}
|