853 lines
31 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|