Files
webhooker/internal/handlers/archive_rotation_test.go
T
sneak ab7eec4b3e
check / check (push) Waiting to run
Move the archive files into their own package (closes #541)
The SQLite archive files the database target writes are now managed by
internal/archive: file names, rotation, the writer of each target,
pruning, renames, the gzipped JSON export, the idle sweep, and the
rename lock, which was Handlers.renameMu. The database target stays in
internal/delivery and writes through archive.Writers; the engine still
closes the writers when it stops, so the stop order is unchanged. The
handlers take the rename lock from archive.Archives.RenameLock, and
rename and evict through archive.Archives.

Exported names drop their Archive prefix in the new package. The tests
moved with the code; the two that check a delivery's recorded attempt
stay in delivery.

Model: opus-5-5
2026-10-15 18:42:29 +00:00

230 lines
6.8 KiB
Go

package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"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"
)
// rotationNone is the archive rotation that keeps one file.
const rotationNone = "none"
// rotationShown returns the archive rotations the webhook page's
// target list shows.
func rotationShown(
t *testing.T, env *sourceTestEnv, webhookID string,
) []string {
t.Helper()
return matched(
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID),
)
}
// TestArchiveRotationChoices adds a database target with each archive
// rotation the forms offer, and checks that it is stored as chosen,
// shown in the target list, and that the target edit form starts on
// it. It then edits the target to hourly, keeping its expiry.
func TestArchiveRotationChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
t.Run(rotation, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", "720h")
form.Set("rotation", rotation)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"`+rotation+`"}`,
targets[0].Config,
)
assert.Equal(t,
[]string{rotation}, rotationShown(t, env, webhook.ID))
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
form = url.Values{}
form.Set("name", "archive")
form.Set("expiry", "720h")
form.Set("rotation", "hourly")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(t,
`{"expiry":"720h","rotation":"hourly"}`,
storedTarget(t, env, targets[0].ID).Config,
)
})
}
}
// TestArchiveRotationEditStartsOnNone checks the edit form of a
// database target with no rotation stored starts on none.
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
page := targetEditPage(t, env, webhook.ID, target.ID)
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
}
// TestArchiveRotationRefused proves a rotation that is not one of the
// four is refused on the add target form and the target edit form, and
// that nothing is stored.
func TestArchiveRotationRefused(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("rotation", "weekly")
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
form.Del("type")
w = submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
assert.JSONEq(t,
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
)
}
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
// page's archive rotation is stored on the archive target it creates.
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
form := url.Values{}
form.Set("name", "rotated")
form.Set("archive", "on")
form.Set("archive_expiry", "720h")
form.Set("archive_rotation", "daily")
w := submitCreateForm(env, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
var webhook database.Webhook
require.NoError(t, env.db.DB().
Where("name = ?", "rotated").First(&webhook).Error)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(t,
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
)
}
// TestArchiveFileView_Rotated describes a daily target's archive files
// at two times. On a day that has a file, the view names that file;
// on the next, before any event, it names the file the next event
// will go to, not created yet. Both times the size is of every file
// together and the last write the latest of them.
func TestArchiveFileView_Rotated(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"rotation":"daily"}`,
)
path := archive.Path(env.dbMgr, &webhook, target)
stem := strings.TrimSuffix(path, ".db")
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
for i, day := range []string{"2026-10-01", "2026-10-02"} {
file := stem + "-" + day + ".db"
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
view := env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
assert.Empty(t, view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
view = env.handlers.ArchiveFileViewForTest(
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
)
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
assert.Equal(t, "not created yet", view.Note)
assert.Equal(t, 2, view.Files)
assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
}
// renderedPage returns the webhook page.
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
t.Helper()
w := httptest.NewRecorder()
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
t, "/hook/"+webhookID, env.cookies,
map[string]string{sourceIDParam: webhookID},
))
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}