feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API
This commit is contained in:
1 parent
161ddc0f7d
commit
e82d0ce254
25 files changed
+2459
-41
No files matched your search
@@ -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
@@ -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{}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user