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 += `` } 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) } }) }