feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API

This commit is contained in:
fsylvain committed 2026-10-05 18:24:03 +02:00
1 parent 161ddc0f7d
commit e82d0ce254
25 files changed
+2459 -41

No files matched your search

+4
View File
@@ -29,6 +29,7 @@ type mockImageRepo struct {
deleteCalls []uint32
createCalls int
lastContent []byte
onDelete func(id uint32)
}
func newMockImageRepo() *mockImageRepo {
@@ -85,6 +86,9 @@ func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
func (m *mockImageRepo) Delete(id uint32) error {
m.deleteCalls = append(m.deleteCalls, id)
if m.onDelete != nil {
m.onDelete(id)
}
if err := m.fail.trigger("Delete"); err != nil {
return err
}
+36 -22
View File
@@ -8,7 +8,9 @@ import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"strconv"
@@ -28,11 +30,14 @@ const (
)
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
users *mockUserRepo
token *jwtauth.JWTAuth
router http.Handler
articles *mockArticleRepo
images *mockImageRepo
users *mockUserRepo
purgeSettings *mockPurgeSettingsRepo
clock time.Time
logs *bytes.Buffer
token *jwtauth.JWTAuth
router http.Handler
}
func newMediaEnv(t *testing.T) *mediaEnv {
@@ -40,30 +45,39 @@ func newMediaEnv(t *testing.T) *mediaEnv {
validator.InitValidator()
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
articles: newMockArticleRepo(),
images: newMockImageRepo(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
purgeSettings: newMockPurgeSettingsRepo(),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
logs: &bytes.Buffer{},
}
env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true}
env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true}
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0)),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+821
View File
@@ -0,0 +1,821 @@
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 {
settings purge.Settings
fail failSwitch
getCalls int
saveCalls int
}
func newMockPurgeSettingsRepo() *mockPurgeSettingsRepo {
return &mockPurgeSettingsRepo{settings: purge.DefaultSettings()}
}
func (m *mockPurgeSettingsRepo) Get() (purge.Settings, error) {
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.saveCalls++
if err := m.fail.trigger("Save"); err != nil {
return err
}
m.settings = settings
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) + `,"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("Save")
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)
}
})
}
+129
View File
@@ -0,0 +1,129 @@
package test
import (
"RenewCMS/api/pkg/apperr"
domainPurge "RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"net/http"
"os/exec"
"reflect"
"strings"
"testing"
"time"
)
func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) {
for _, role := range []string{"OWNER", "Admin", "superuser"} {
for _, route := range []struct{ method, path string }{
{http.MethodGet, "/v1/media/purge-settings"},
{http.MethodPatch, "/v1/media/purge-settings"},
{http.MethodGet, "/v1/media/purge-preview"},
{http.MethodPost, "/v1/media/purge"},
} {
t.Run(role+" "+route.method+" "+route.path, func(t *testing.T) {
env := newPurgeEnv(t)
env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, Role: user.Role(role)}
rec := env.send(t, route.method, route.path, "", 20)
expectRaw(t, rec, 403, apperr.OwnerOrAdminRequiredErrorMessage+"\n")
if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 {
t.Errorf("expected no purge setting access")
}
})
}
}
}
func TestPurgeLayering(t *testing.T) {
list := func(t *testing.T, format string, pkg string) []string {
t.Helper()
output, err := exec.Command("go", "list", "-tags", "nofrontend", "-f", format, pkg).CombinedOutput()
if err != nil {
t.Fatalf("go list failed: %v: %s", err, output)
}
return strings.Fields(string(output))
}
t.Run("internal/domain/purge imports only the stdlib and other domain packages", func(t *testing.T) {
for _, imported := range list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/domain/purge") {
if strings.Contains(imported, ".") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
t.Errorf("unexpected import %s", imported)
}
}
})
t.Run("the use cases import no http, chi, gorm, persistence, mailer, models or api package", func(t *testing.T) {
for _, imported := range list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/infrastructure/useCases") {
for _, forbidden := range []string{"net/http", "go-chi", "gorm", "persistence", "mailer", "RenewCMS/api"} {
if strings.Contains(imported, forbidden) {
t.Errorf("useCases must not import %s", imported)
}
}
}
})
}
func TestPurgeSettingsRepositoryRoundTrip(t *testing.T) {
newRepo := func(t *testing.T) *persistence.PurgeSettingsRepository {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.PurgeSettings{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
return persistence.NewPurgeSettingsRepository(db)
}
t.Run("Nothing saved yields the defaults", func(t *testing.T) {
got, err := newRepo(t).Get()
if err != nil || !reflect.DeepEqual(got, domainPurge.DefaultSettings()) {
t.Errorf("expected defaults, got %+v, %v", got, err)
}
})
t.Run("Null and set timestamps survive a save", func(t *testing.T) {
repo := newRepo(t)
withNulls := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7}
if err := repo.Save(withNulls); err != nil {
t.Fatal(err)
}
got, err := repo.Get()
if err != nil || !reflect.DeepEqual(got, withNulls) {
t.Errorf("expected %+v, got %+v, %v", withNulls, got, err)
}
last := time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC)
enabled := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
full := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 365, LastPurgeAt: &last, LastPurgeMerged: 3, LastPurgeArticlesUpdated: 1, EnabledAt: &enabled}
if err := repo.Save(full); err != nil {
t.Fatal(err)
}
got, err = repo.Get()
if err != nil || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(last) || got.EnabledAt == nil || !got.EnabledAt.Equal(enabled) || got.LastPurgeMerged != 3 || got.LastPurgeArticlesUpdated != 1 || got.AutoPurgeIntervalDays != 365 {
t.Errorf("unexpected round trip %+v, %v", got, err)
}
})
t.Run("Repeated saves keep a single row", func(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.PurgeSettings{}); err != nil {
t.Fatal(err)
}
repo := persistence.NewPurgeSettingsRepository(db)
for days := 1; days <= 3; days++ {
if err := repo.Save(domainPurge.Settings{AutoPurgeIntervalDays: days}); err != nil {
t.Fatal(err)
}
}
var count int64
db.Model(&models.PurgeSettings{}).Count(&count)
if count != 1 {
t.Errorf("expected a single row, got %d", count)
}
})
}
+185
View File
@@ -0,0 +1,185 @@
package test
import (
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"log"
"reflect"
"sync"
"testing"
"time"
)
func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
for _, days := range []int{0, 366} {
repo := newMockPurgeSettingsRepo()
update := useCases.NewUpdatePurgeSettingsUseCase(repo, time.Now)
_, err := update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeIntervalDays: &days})
if !errors.Is(err, purge.ErrInvalidPurgeInterval) {
t.Errorf("interval %d: expected ErrInvalidPurgeInterval, got %v", days, err)
}
if repo.saveCalls != 0 {
t.Errorf("interval %d: expected Save not to be called", days)
}
}
}
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0))
}
t.Run("a non-matching fingerprint is a conflict and writes nothing", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
wrong := "wrong"
_, err := newRun(env).Run(&wrong)
if !errors.Is(err, purge.ErrPurgeConflict) || errors.Is(err, purge.ErrPurgeBusy) {
t.Errorf("expected only ErrPurgeConflict, got %v", err)
}
if env.purgeSettings.saveCalls != 0 || len(env.images.deleteCalls) != 0 {
t.Errorf("expected no write")
}
})
t.Run("a run while another holds the guard is busy and writes nothing", 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
})
}
run := newRun(env)
done := make(chan error, 1)
go func() {
_, err := run.Run(nil)
done <- err
}()
<-entered
savesBefore := env.purgeSettings.saveCalls
_, err := run.Run(nil)
if !errors.Is(err, purge.ErrPurgeBusy) || errors.Is(err, purge.ErrPurgeConflict) {
t.Errorf("expected only ErrPurgeBusy, got %v", err)
}
if env.purgeSettings.saveCalls != savesBefore {
t.Errorf("expected no write from the busy call")
}
close(release)
if err := <-done; err != nil {
t.Errorf("expected the first run to finish, got %v", err)
}
})
t.Run("a scheduler run without a fingerprint gets the result and the persistence", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
result, err := newRun(env).Run(nil)
if err != nil || result != (useCases.RunPurgeResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1}) {
t.Errorf("unexpected result %+v, %v", result, err)
}
if env.purgeSettings.settings.LastPurgeAt == nil || env.purgeSettings.settings.LastPurgeMerged != 1 {
t.Errorf("expected last_purge_* persisted")
}
})
}
func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
t.Run("groups are ordered by the kept image id whatever the order they were found in", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 6)
env.seedGroup(hashB, 2, 5)
env.seedGroup(hashC, 3, 4)
env.useImages(10, 6)
env.useImages(11, 5)
env.useImages(12, 3)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
var kept []uint32
for _, group := range preview.List {
kept = append(kept, group.KeepID)
}
if err != nil || !reflect.DeepEqual(kept, []uint32{3, 5, 6}) {
t.Errorf("expected kept ids [3 5 6], got %v, %v", kept, err)
}
})
t.Run("the fingerprint hashes the kept id and the comma separated merged ids of each group", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2, 3)
env.seedGroup(hashB, 4, 5)
env.useImages(10, 1, 2, 3)
env.useImages(11, 5)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
sum := sha256.Sum256([]byte("1:2,3;5:4;"))
if err != nil || preview.Fingerprint != hex.EncodeToString(sum[:]) {
t.Errorf("unexpected fingerprint %q, %v", preview.Fingerprint, err)
}
})
}
func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
t.Run("Existing merges report the same counts and now also the ids", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
result, err := merge.Merge(1, []uint32{2})
if err != nil || result.Merged != 1 || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{1, 2}) {
t.Errorf("unexpected result %+v, %v", result, err)
}
})
t.Run("A rolled-back merge returns no ids", func(t *testing.T) {
env := newPurgeEnv(t)
env.seedGroup(hashA, 1, 2)
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
result, err := merge.Merge(1, []uint32{2})
if err == nil || result.Merged != 0 || result.ArticlesUpdated != 0 || len(result.UpdatedArticleIDs) != 0 {
t.Errorf("unexpected result %+v, %v", result, err)
}
})
}
func TestPurgeSettingsNextPurgeAt(t *testing.T) {
t.Run("is null when off or when never enabled", func(t *testing.T) {
if purge.DefaultSettings().NextPurgeAt() != nil {
t.Errorf("expected nil")
}
if (purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 3}).NextPurgeAt() != nil {
t.Errorf("expected nil without enabled_at")
}
})
}