Files
RenewCMS/test/media_purge_http_test.go
T

853 lines
31 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/user"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"time"
)
const (
purgeOwnerId uint32 = 10
purgeAdminId uint32 = 11
purgeEditorId uint32 = 12
purgeUnverifiedId uint32 = 13
fingerprintLength = 64
internalError = "Internal server error\n"
)
type mockPurgeSettingsRepo struct {
mu sync.Mutex
settings purge.Settings
fail failSwitch
getCalls int
saveCalls int
beforeRunWrite func()
}
func newMockPurgeSettingsRepo() *mockPurgeSettingsRepo {
return &mockPurgeSettingsRepo{settings: purge.DefaultSettings()}
}
func (m *mockPurgeSettingsRepo) snapshot() purge.Settings {
m.mu.Lock()
defer m.mu.Unlock()
return m.settings
}
func (m *mockPurgeSettingsRepo) Get() (purge.Settings, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.getCalls++
if err := m.fail.trigger("Get"); err != nil {
return purge.Settings{}, err
}
return m.settings, nil
}
func (m *mockPurgeSettingsRepo) Save(settings purge.Settings) error {
m.mu.Lock()
defer m.mu.Unlock()
m.saveCalls++
if err := m.fail.trigger("Save"); err != nil {
return err
}
m.settings = settings
return nil
}
func (m *mockPurgeSettingsRepo) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
m.mu.Lock()
m.saveCalls++
hook := m.beforeRunWrite
m.mu.Unlock()
if hook != nil {
hook()
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.fail.trigger("SaveLastRun"); err != nil {
return err
}
m.settings.LastPurgeAt = &at
m.settings.LastPurgeMerged = merged
m.settings.LastPurgeArticlesUpdated = articlesUpdated
return nil
}
var _ purge.Repository = &mockPurgeSettingsRepo{}
func purgeTime(value string) *time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return &parsed
}
func newPurgeEnv(t *testing.T) *mediaEnv {
t.Helper()
env := newMediaEnv(t)
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner}
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")}
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner}
return env
}
func (e *mediaEnv) seedGroup(hash string, ids ...uint32) {
for _, id := range ids {
e.seedImage(id, "image-"+strconv.Itoa(int(id))+".jpg", 100, "image/jpeg", 10, 10, hash, 1)
}
}
func (e *mediaEnv) useImages(articleId uint32, ids ...uint32) {
body := ""
for _, id := range ids {
body += `<img src="` + e.urlOf(id) + `">`
}
e.seedArticle(articleId, "Article "+strconv.Itoa(int(articleId)), true, body)
}
func (e *mediaEnv) purgeSend(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.send(t, method, path, body, purgeOwnerId)
}
func (e *mediaEnv) preview(t *testing.T) map[string]any {
t.Helper()
rec := e.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
return decodeObject(t, rec.Body.String())
}
func (e *mediaEnv) run(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return e.purgeSend(t, http.MethodPost, "/v1/media/purge", body)
}
func expectRaw(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || rec.Body.String() != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
}
}
func TestPurgeRoutesRefuseCallersThatAreNotAllowed(t *testing.T) {
examples := []struct {
method string
path string
userId uint32
description string
status int
message string
}{
{http.MethodGet, "/v1/media/purge-settings", 0, "no cookie", 401, ""},
{http.MethodPatch, "/v1/media/purge-settings", 0, "no cookie", 401, ""},
{http.MethodGet, "/v1/media/purge-preview", 0, "no cookie", 401, ""},
{http.MethodPost, "/v1/media/purge", 0, "no cookie", 401, ""},
{http.MethodGet, "/v1/media/purge-settings", mediaStaleUserId, "a stale token_version cookie", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeUnverifiedId, "cookie of an unverified owner", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPatch, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/purge-preview", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/purge", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
}
for _, example := range examples {
t.Run(example.method+" "+example.path+" "+example.description, func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
bodiesBefore := env.bodies()
imagesBefore := env.imageIds()
rec := env.send(t, example.method, example.path, "", example.userId)
if example.message == "" {
if rec.Code != example.status {
t.Fatalf("expected status %d, got %d", example.status, rec.Code)
}
} else {
expectRaw(t, rec, example.status, example.message+"\n")
}
if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected no purge setting access")
}
if !reflect.DeepEqual(env.bodies(), bodiesBefore) || !reflect.DeepEqual(env.imageIds(), imagesBefore) || len(env.images.deleteCalls) != 0 {
t.Errorf("expected no image or article to change")
}
})
}
}
func TestPurgeRoleGuardLetsOwnersAndAdminsThrough(t *testing.T) {
for _, role := range []struct {
name string
userId uint32
}{{"owner", purgeOwnerId}, {"admin", purgeAdminId}} {
for _, path := range []string{"/v1/media/purge-settings", "/v1/media/purge-preview"} {
t.Run(role.name+" GET "+path, func(t *testing.T) {
env := newPurgeEnv(t)
rec := env.send(t, http.MethodGet, path, "", role.userId)
if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
t.Errorf("expected the guard to pass, got %d", rec.Code)
}
})
}
}
}
const defaultSettingsBody = `{"auto_purge_enabled":false,"auto_purge_interval_days":30,"last_purge_at":null,"last_purge_merged":0,"last_purge_articles_updated":0,"enabled_at":null,"next_purge_at":null}`
func TestPurgeSettings(t *testing.T) {
get := func(t *testing.T, env *mediaEnv) map[string]any {
t.Helper()
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
return decodeObject(t, rec.Body.String())
}
t.Run("Nothing was ever saved, so the defaults are served", func(t *testing.T) {
env := newPurgeEnv(t)
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
expectRaw(t, rec, 200, defaultSettingsBody)
})
t.Run("Turning automatic purge on stamps enabled_at and computes next_purge_at", func(t *testing.T) {
env := newPurgeEnv(t)
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := decodeObject(t, rec.Body.String())
if body["auto_purge_enabled"] != true || numberOf(body["auto_purge_interval_days"]) != 30 || body["enabled_at"] != "2026-10-05T09:00:00Z" || body["last_purge_at"] != nil || body["next_purge_at"] != "2026-11-04T09:00:00Z" {
t.Errorf("unexpected body %s", rec.Body.String())
}
if !reflect.DeepEqual(get(t, env), body) {
t.Errorf("expected a following GET to return the same body")
}
})
t.Run("next_purge_at counts from the last purge when it is later than enabled_at", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 10, EnabledAt: purgeTime("2026-10-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-10-04T12:00:00Z")}
if got := get(t, env)["next_purge_at"]; got != "2026-10-14T12:00:00Z" {
t.Errorf("unexpected next_purge_at %v", got)
}
})
t.Run("next_purge_at counts from enabled_at when it is later than the last purge", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 10, EnabledAt: purgeTime("2026-10-05T09:00:00Z"), LastPurgeAt: purgeTime("2026-09-01T00:00:00Z")}
if got := get(t, env)["next_purge_at"]; got != "2026-10-15T09:00:00Z" {
t.Errorf("unexpected next_purge_at %v", got)
}
})
t.Run("Turning automatic purge off and on again never yields a next_purge_at in the past", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: false, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-08-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-09-01T00:00:00Z")}
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`).Body.String())
if body["enabled_at"] != "2026-10-05T09:00:00Z" || body["last_purge_at"] != "2026-09-01T00:00:00Z" || body["next_purge_at"] != "2026-11-04T09:00:00Z" {
t.Errorf("unexpected body %v", body)
}
})
t.Run("Changing only the interval keeps enabled_at", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": 7}`)
body := decodeObject(t, rec.Body.String())
if rec.Code != 200 || numberOf(body["auto_purge_interval_days"]) != 7 || body["enabled_at"] != "2026-10-01T00:00:00Z" || body["next_purge_at"] != "2026-10-08T00:00:00Z" {
t.Errorf("unexpected response %d %v", rec.Code, body)
}
})
t.Run("Patching on while already on does not restamp enabled_at", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`).Body.String())
if body["enabled_at"] != "2026-10-01T00:00:00Z" {
t.Errorf("unexpected enabled_at %v", body["enabled_at"])
}
})
t.Run("Turning automatic purge off nulls next_purge_at and keeps the history", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-10-04T12:00:00Z"), LastPurgeMerged: 3, LastPurgeArticlesUpdated: 5}
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": false}`).Body.String())
if body["auto_purge_enabled"] != false || body["next_purge_at"] != nil || body["last_purge_at"] != "2026-10-04T12:00:00Z" || numberOf(body["last_purge_merged"]) != 3 || numberOf(body["last_purge_articles_updated"]) != 5 {
t.Errorf("unexpected body %v", body)
}
})
for _, days := range []int{1, 30, 365} {
t.Run("The interval accepts "+strconv.Itoa(days), func(t *testing.T) {
env := newPurgeEnv(t)
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": `+strconv.Itoa(days)+`}`)
if rec.Code != 200 || int(numberOf(decodeObject(t, rec.Body.String())["auto_purge_interval_days"])) != days {
t.Errorf("unexpected response %d %s", rec.Code, rec.Body.String())
}
})
}
invalid := []struct {
body string
message string
}{
{`{"auto_purge_interval_days": 0}`, apperr.PurgeIntervalErrorMessage},
{`{"auto_purge_interval_days": 366}`, apperr.PurgeIntervalErrorMessage},
{`{"auto_purge_interval_days": -1}`, apperr.PurgeIntervalErrorMessage},
{`{"auto_purge_interval_days": 1.5}`, apperr.BodyErrorMessage},
{`{"auto_purge_interval_days": "7"}`, apperr.BodyErrorMessage},
{`{"auto_purge_enabled": "yes"}`, apperr.BodyErrorMessage},
{`not json`, apperr.BodyErrorMessage},
}
for _, example := range invalid {
t.Run("An invalid patch is 400 and nothing changes "+example.body, func(t *testing.T) {
env := newPurgeEnv(t)
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", example.body)
expectRaw(t, rec, 400, example.message+"\n")
if env.purgeSettings.saveCalls != 0 || !reflect.DeepEqual(env.purgeSettings.settings, purge.DefaultSettings()) {
t.Errorf("expected the stored settings to be unchanged")
}
})
}
t.Run("An empty object changes nothing", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
before := get(t, env)
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{}`)
if rec.Code != 200 || !reflect.DeepEqual(decodeObject(t, rec.Body.String()), before) {
t.Errorf("unexpected response %d %s", rec.Code, rec.Body.String())
}
})
t.Run("A repository failure is a generic 500", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.fail.failNext("Get")
expectRaw(t, env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", ""), 500, internalError)
})
t.Run("A failing Save on PATCH is a generic 500", func(t *testing.T) {
env := newPurgeEnv(t)
env.purgeSettings.fail.failNext("Save")
expectRaw(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`), 500, internalError)
})
t.Run("The purge routes are not taken by the /{id} routes", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1)
namesBefore := env.images.images[1].Filename
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": 7}`)
previewRec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
if rec.Code != 200 || env.purgeSettings.settings.AutoPurgeIntervalDays != 7 || env.images.images[1].Filename != namesBefore {
t.Errorf("expected the settings to be updated without renaming an image")
}
if previewRec.Code != 200 || !strings.Contains(previewRec.Body.String(), `"fingerprint"`) {
t.Errorf("expected the preview, got %d %s", previewRec.Code, previewRec.Body.String())
}
})
}
func TestPurgePreview(t *testing.T) {
t.Run("A group with a used copy is planned, merging every other copy into the most used", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedImage(1, "hero.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
env.seedImage(2, "hero-copy.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
env.seedImage(3, "hero-2.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
env.useImages(10, 1)
env.useImages(11, 2)
env.useImages(12, 2)
env.useImages(13, 3)
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
body := decodeObject(t, rec.Body.String())
list := body["list"].([]any)
first := list[0].(map[string]any)
if rec.Code != 200 || numberOf(body["groups"]) != 1 || numberOf(body["copies"]) != 2 || numberOf(body["articles"]) != 2 || numberOf(body["more"]) != 0 || len(list) != 1 {
t.Fatalf("unexpected preview %s", rec.Body.String())
}
if numberOf(first["keep_id"]) != 2 || first["filename"] != "hero-copy.jpg" || first["url"] != env.urlOf(2) || numberOf(first["used_in"]) != 2 || numberOf(first["merged"]) != 2 {
t.Errorf("unexpected list entry %v", first)
}
if fingerprint, _ := body["fingerprint"].(string); len(fingerprint) != fingerprintLength {
t.Errorf("unexpected fingerprint %v", body["fingerprint"])
}
})
t.Run("Ties on usage keep the oldest copy, then the lowest id", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashB, 4, 5)
env.seedGroup(hashC, 6, 7)
same := mockImageEpoch
for _, id := range []uint32{4, 5} {
img := env.images.images[id]
img.CreatedAt = same
env.images.images[id] = img
}
older := env.images.images[7]
older.CreatedAt = mockImageEpoch.Add(-time.Hour)
env.images.images[7] = older
env.useImages(20, 4)
env.useImages(21, 5)
env.useImages(22, 6)
env.useImages(23, 7)
list := env.preview(t)["list"].([]any)
if numberOf(list[0].(map[string]any)["keep_id"]) != 4 || numberOf(list[1].(map[string]any)["keep_id"]) != 7 {
t.Errorf("unexpected list %v", list)
}
})
t.Run("A single used copy with unused siblings keeps the used one", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashD, 8, 9, 10)
env.useImages(30, 10)
body := env.preview(t)
first := body["list"].([]any)[0].(map[string]any)
if numberOf(first["keep_id"]) != 10 || numberOf(first["used_in"]) != 1 || numberOf(first["merged"]) != 2 || numberOf(body["articles"]) != 0 {
t.Errorf("unexpected preview %v", body)
}
})
t.Run("A group whose copies are all unused is skipped", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 11, 12)
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
body := decodeObject(t, rec.Body.String())
if numberOf(body["groups"]) != 0 || numberOf(body["copies"]) != 0 || numberOf(body["articles"]) != 0 || numberOf(body["more"]) != 0 || !strings.Contains(rec.Body.String(), `"list":[]`) {
t.Errorf("unexpected preview %s", rec.Body.String())
}
})
t.Run("A library with no duplicates", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1)
env.seedGroup(hashB, 2)
env.seedGroup(hashC, 3)
emptyFingerprint := decodeObject(t, newPurgeEnv(t).purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String())["fingerprint"]
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
body := decodeObject(t, rec.Body.String())
if numberOf(body["groups"]) != 0 || !strings.Contains(rec.Body.String(), `"list":[]`) || body["fingerprint"] != emptyFingerprint {
t.Errorf("unexpected preview %s", rec.Body.String())
}
})
t.Run("Images without a hash are never grouped", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup("", 13, 14)
env.useImages(40, 13, 14)
if got := numberOf(env.preview(t)["groups"]); got != 0 {
t.Errorf("expected no group, got %d", got)
}
})
t.Run("An article that uses copies of two groups counts once", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.seedGroup(hashB, 3, 4)
env.useImages(50, 1)
env.useImages(51, 3)
env.useImages(52, 2, 4)
body := env.preview(t)
if numberOf(body["groups"]) != 2 || numberOf(body["copies"]) != 2 || numberOf(body["articles"]) != 1 {
t.Errorf("unexpected preview %v", body)
}
})
t.Run("An article that uses both the kept copy and a copy is rewritten", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(60, 1, 2)
env.useImages(61, 1)
if got := numberOf(env.preview(t)["articles"]); got != 1 {
t.Errorf("expected 1 article, got %d", got)
}
})
t.Run("The list shows at most 5 groups and reports the rest", func(t *testing.T) {
env := newPurgeEnv(t)
for group := uint32(0); group < 7; group++ {
hash := sha256Hex([]byte{byte(group)})
first, second := group*2+1, group*2+2
env.seedGroup(hash, first, second)
env.useImages(100+first, first)
}
body := env.preview(t)
list := body["list"].([]any)
if numberOf(body["groups"]) != 7 || len(list) != 5 || numberOf(body["more"]) != 2 {
t.Fatalf("unexpected preview %v", body)
}
for position, entry := range list {
if numberOf(entry.(map[string]any)["keep_id"]) != uint32(position*2+1) {
t.Errorf("expected ascending keep_id, got %v", list)
}
}
})
t.Run("The preview changes nothing", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1, 2)
bodiesBefore := env.bodies()
first := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String()
second := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String()
if first != second || len(env.images.deleteCalls) != 0 || !reflect.DeepEqual(env.bodies(), bodiesBefore) || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected the preview to change nothing")
}
})
t.Run("The fingerprint reacts to the plan and to nothing else", func(t *testing.T) {
examples := []struct {
change string
apply func(env *mediaEnv)
identical bool
}{
{"nothing changes", func(env *mediaEnv) {}, true},
{"a new unused copy is uploaded to the group", func(env *mediaEnv) { env.seedGroup(hashA, 9) }, false},
{"a copy is deleted from the group", func(env *mediaEnv) { delete(env.images.images, 3) }, false},
{"article usage changes so that another copy becomes kept", func(env *mediaEnv) { env.useImages(12, 3); env.useImages(13, 3); env.useImages(14, 3) }, false},
{"an unrelated image with a unique sha256 is uploaded", func(env *mediaEnv) { env.seedGroup(hashD, 20) }, true},
{"an article body changes without changing which is kept", func(env *mediaEnv) { env.useImages(10, 2, 2) }, true},
{"a group with no used copy gains an unused copy", func(env *mediaEnv) { env.seedGroup(hashB, 30, 31); env.seedGroup(hashB, 32) }, true},
}
for _, example := range examples {
t.Run(example.change, func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 2, 3)
env.useImages(10, 2)
env.useImages(11, 2)
before := env.preview(t)["fingerprint"]
example.apply(env)
if (env.preview(t)["fingerprint"] == before) != example.identical {
t.Errorf("unexpected fingerprint comparison, identical expected %v", example.identical)
}
})
}
})
}
func TestPurgeRun(t *testing.T) {
counters := func(groups, purged, merged, articles, failed int) string {
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"sites_updated":0,"authors_updated":0,"failed_groups":` + strconv.Itoa(failed) + `}`
}
seedThreeGroups := func(env *mediaEnv) {
env.seedGroup(hashA, 1, 2, 3)
env.seedGroup(hashB, 4, 5)
env.seedGroup(hashC, 6, 7)
env.useImages(10, 1)
env.useImages(11, 2)
env.useImages(12, 2, 4)
}
t.Run("A run merges every planned group and persists the result", func(t *testing.T) {
env := newPurgeEnv(t)
seedThreeGroups(env)
enabledAt := purgeTime("2026-10-01T00:00:00Z")
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: enabledAt}
rec := env.run(t, `{}`)
expectRaw(t, rec, 200, counters(2, 2, 3, 1, 0))
expectIds(t, env.imageIds(), []uint32{2, 4, 6, 7})
if !strings.Contains(env.bodies()[10], env.images.images[2].Path) {
t.Errorf("expected article 10 to use image 2")
}
stored := env.purgeSettings.settings
if !stored.LastPurgeAt.Equal(env.clock) || stored.LastPurgeMerged != 3 || stored.LastPurgeArticlesUpdated != 1 || !stored.AutoPurgeEnabled || stored.AutoPurgeIntervalDays != 7 || !stored.EnabledAt.Equal(*enabledAt) {
t.Errorf("unexpected stored settings %+v", stored)
}
})
t.Run("An article rewritten by two groups is counted once in articles_updated", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.seedGroup(hashB, 3, 4)
env.useImages(10, 1)
env.useImages(11, 3)
env.useImages(12, 2, 4)
expectRaw(t, env.run(t, `{}`), 200, counters(2, 2, 2, 1, 0))
})
t.Run("A single used copy with unused siblings is merged without rewriting any article", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashD, 8, 9, 10)
env.useImages(30, 10)
bodiesBefore := env.bodies()
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 2, 0, 0))
expectIds(t, env.imageIds(), []uint32{10})
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
t.Errorf("expected no article body to change")
}
})
t.Run("A matching fingerprint is accepted", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
fingerprint := env.preview(t)["fingerprint"].(string)
expectRaw(t, env.run(t, `{"fingerprint": "`+fingerprint+`"}`), 200, counters(1, 1, 1, 0, 0))
})
t.Run("A stale fingerprint answers 409 and changes nothing", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
fingerprint := env.preview(t)["fingerprint"].(string)
env.seedGroup(hashA, 9)
bodiesBefore := env.bodies()
imagesBefore := env.imageIds()
rec := env.run(t, `{"fingerprint": "`+fingerprint+`"}`)
expectRaw(t, rec, 409, apperr.PurgeConflictErrorMessage+"\n")
if !reflect.DeepEqual(env.bodies(), bodiesBefore) || !reflect.DeepEqual(env.imageIds(), imagesBefore) || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected nothing to change")
}
})
t.Run("A fingerprint that matches no plan at all is a conflict, not a validation error", func(t *testing.T) {
env := newPurgeEnv(t)
expectRaw(t, env.run(t, `{"fingerprint": "not-a-real-fingerprint"}`), 409, apperr.PurgeConflictErrorMessage+"\n")
if env.purgeSettings.saveCalls != 0 {
t.Errorf("expected nothing saved")
}
})
t.Run("A run without a fingerprint always runs the current plan", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
expectRaw(t, env.run(t, ""), 200, counters(1, 1, 1, 0, 0))
})
t.Run("A library with nothing to purge answers zeros and is still recorded", func(t *testing.T) {
env := newPurgeEnv(t)
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
stored := env.purgeSettings.settings
if stored.LastPurgeAt == nil || !stored.LastPurgeAt.Equal(env.clock) || stored.LastPurgeMerged != 0 || stored.LastPurgeArticlesUpdated != 0 {
t.Errorf("unexpected stored settings %+v", stored)
}
})
t.Run("The fingerprint of the empty plan on an empty plan runs as a no-op", func(t *testing.T) {
env := newPurgeEnv(t)
fingerprint := env.preview(t)["fingerprint"].(string)
expectRaw(t, env.run(t, `{"fingerprint": "`+fingerprint+`"}`), 200, counters(0, 0, 0, 0, 0))
})
t.Run("A group that fails while rewriting bodies is rolled back and contributes no ids", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.seedGroup(hashB, 3, 4)
env.seedGroup(hashC, 5, 6)
env.useImages(20, 1, 2)
env.useImages(21, 1)
env.useImages(22, 3, 4)
env.useImages(23, 3, 4)
env.useImages(24, 3)
env.useImages(25, 5)
env.useImages(26, 5, 6)
env.articles.failUpdateBodyFor = map[uint32]error{23: errRepositoryFailure}
originalB := []string{env.bodies()[22], env.bodies()[23]}
rec := env.run(t, `{}`)
expectRaw(t, rec, 200, counters(3, 2, 2, 2, 1))
if env.bodies()[22] != originalB[0] || env.bodies()[23] != originalB[1] {
t.Errorf("expected group B bodies restored")
}
expectIds(t, env.imageIds(), []uint32{1, 3, 4, 5})
stored := env.purgeSettings.settings
if stored.LastPurgeMerged != 2 || stored.LastPurgeArticlesUpdated != 2 {
t.Errorf("unexpected stored settings %+v", stored)
}
})
t.Run("A group that fails after rewriting bodies is reported as failed and contributes nothing", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.seedGroup(hashB, 3, 4)
env.useImages(20, 1, 2)
env.useImages(21, 1)
env.useImages(22, 3, 4)
env.useImages(23, 3)
env.images.failDeleteFor = map[uint32]error{2: errRepositoryFailure}
expectRaw(t, env.run(t, `{}`), 200, counters(2, 1, 1, 1, 1))
if strings.Contains(env.bodies()[20], env.urlOf(2)) {
t.Errorf("expected article 20 to stay switched to the kept copy")
}
if got := numberOf(env.preview(t)["groups"]); got != 1 {
t.Errorf("expected group A to be planned again, got %d groups", got)
}
env.images.failDeleteFor = nil
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 0, 0))
})
t.Run("Every group failing is still a 200 with the counters", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.seedGroup(hashB, 3, 4)
env.useImages(20, 1, 2)
env.useImages(21, 1)
env.useImages(22, 3, 4)
env.useImages(23, 3)
env.articles.failUpdateBodyFor = map[uint32]error{20: errRepositoryFailure, 22: errRepositoryFailure}
expectRaw(t, env.run(t, `{}`), 200, counters(2, 0, 0, 0, 2))
if env.purgeSettings.settings.LastPurgeAt == nil {
t.Errorf("expected last_purge_at to be set")
}
})
t.Run("A second run while one is in progress is rejected as busy, not queued", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
env.images.onDelete = func(uint32) {
once.Do(func() {
close(entered)
<-release
})
}
first := make(chan *httptest.ResponseRecorder, 1)
go func() { first <- env.run(t, `{}`) }()
<-entered
savesBefore := env.purgeSettings.saveCalls
withoutFingerprint := env.run(t, `{}`)
withFingerprint := env.run(t, `{"fingerprint": "whatever"}`)
expectRaw(t, withoutFingerprint, 409, apperr.PurgeBusyErrorMessage+"\n")
expectRaw(t, withFingerprint, 409, apperr.PurgeBusyErrorMessage+"\n")
if env.purgeSettings.saveCalls != savesBefore {
t.Errorf("expected the rejected runs to save nothing")
}
close(release)
expectRaw(t, <-first, 200, counters(1, 1, 1, 0, 0))
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
})
t.Run("A malformed body is 400", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
expectRaw(t, env.run(t, "not json"), 400, apperr.BodyErrorMessage+"\n")
if len(env.images.deleteCalls) != 0 || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected nothing to change")
}
})
t.Run("A repository failure while planning is a generic 500 and nothing changes", func(t *testing.T) {
env := newPurgeEnv(t)
env.images.fail.failNext("ListDuplicates")
expectRaw(t, env.run(t, `{}`), 500, internalError)
if env.purgeSettings.saveCalls != 0 {
t.Errorf("expected no last_purge_* saved")
}
})
t.Run("A failing save of the run result is a logged 500 and the merges stay", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
env.purgeSettings.fail.failNext("SaveLastRun")
expectRaw(t, env.run(t, `{}`), 500, internalError)
expectIds(t, env.imageIds(), []uint32{1})
if !strings.Contains(env.logs.String(), errRepositoryFailure.Error()) {
t.Errorf("expected the error in the injected logger, got %q", env.logs.String())
}
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
})
t.Run("The preview and the run use the same plan", func(t *testing.T) {
env := newPurgeEnv(t)
seedThreeGroups(env)
preview := env.preview(t)
rec := env.run(t, `{"fingerprint": "`+preview["fingerprint"].(string)+`"}`)
body := decodeObject(t, rec.Body.String())
if rec.Code != 200 || body["groups_total"] != preview["groups"] || body["merged"] != preview["copies"] || body["articles_updated"] != preview["articles"] {
t.Errorf("preview %v and run %v disagree", preview, body)
}
})
}