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
@@ -6,6 +6,7 @@ import (
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
@@ -62,6 +63,10 @@ type UseCases struct {
|
||||
DeleteMediaUseCase *useCases.DeleteMediaUseCase
|
||||
MergeMediaUseCase *useCases.MergeMediaUseCase
|
||||
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
|
||||
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
|
||||
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
|
||||
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
|
||||
RunPurgeUseCase *useCases.RunPurgeUseCase
|
||||
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||
@@ -80,6 +85,7 @@ type Repositories struct {
|
||||
ImageRepo domainImage.Repository
|
||||
InviteRepo domainInvite.Repository
|
||||
APIKeyRepo domainAPIKey.Repository
|
||||
PurgeSettingsRepo domainPurge.Repository
|
||||
MailRepo domainMail.Repository
|
||||
ReleaseFeed domainUpdate.ReleaseFeed
|
||||
UpdateStore domainUpdate.StatusStore
|
||||
@@ -116,7 +122,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
||||
panic("Unable to open the database: " + err.Error())
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -147,6 +153,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||
@@ -166,6 +173,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
|
||||
|
||||
return &UseCases{
|
||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||
@@ -194,8 +202,12 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default()),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
|
||||
|
||||
@@ -19,12 +19,16 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
DeleteUseCase *useCases.DeleteMediaUseCase
|
||||
MergeUseCase *useCases.MergeMediaUseCase
|
||||
UsageUseCase *useCases.GetMediaUsageUseCase
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
DeleteUseCase *useCases.DeleteMediaUseCase
|
||||
MergeUseCase *useCases.MergeMediaUseCase
|
||||
UsageUseCase *useCases.GetMediaUsageUseCase
|
||||
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
|
||||
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
|
||||
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
|
||||
RunPurgeUseCase *useCases.RunPurgeUseCase
|
||||
}
|
||||
|
||||
type RenameRequest struct {
|
||||
@@ -74,14 +78,18 @@ type mergeResponse struct {
|
||||
Merged int `json:"merged"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler {
|
||||
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
PurgeSettingsUseCase: purgeSettings,
|
||||
UpdatePurgeSettingsUseCase: updatePurgeSettings,
|
||||
PreviewPurgeUseCase: previewPurge,
|
||||
RunPurgeUseCase: runPurge,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeSettingsRequest struct {
|
||||
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
|
||||
}
|
||||
|
||||
type PurgeRunRequest struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type purgeSettingsResponse struct {
|
||||
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
|
||||
LastPurgeAt *time.Time `json:"last_purge_at"`
|
||||
LastPurgeMerged int `json:"last_purge_merged"`
|
||||
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
NextPurgeAt *time.Time `json:"next_purge_at"`
|
||||
}
|
||||
|
||||
type purgePreviewGroupResponse struct {
|
||||
KeepID uint32 `json:"keep_id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
UsedIn int `json:"used_in"`
|
||||
Merged int `json:"merged"`
|
||||
}
|
||||
|
||||
type purgePreviewResponse struct {
|
||||
Groups int `json:"groups"`
|
||||
Copies int `json:"copies"`
|
||||
Articles int `json:"articles"`
|
||||
List []purgePreviewGroupResponse `json:"list"`
|
||||
More int `json:"more"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type purgeRunResponse struct {
|
||||
GroupsTotal int `json:"groups_total"`
|
||||
GroupsPurged int `json:"groups_purged"`
|
||||
Merged int `json:"merged"`
|
||||
ArticlesUpdated int `json:"articles_updated"`
|
||||
FailedGroups int `json:"failed_groups"`
|
||||
}
|
||||
|
||||
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
|
||||
return purgeSettingsResponse{
|
||||
AutoPurgeEnabled: settings.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: settings.LastPurgeAt,
|
||||
LastPurgeMerged: settings.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
|
||||
EnabledAt: settings.EnabledAt,
|
||||
NextPurgeAt: settings.NextPurgeAt(),
|
||||
}
|
||||
}
|
||||
|
||||
func writePurgeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, purge.ErrInvalidPurgeInterval):
|
||||
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, purge.ErrPurgeConflict):
|
||||
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, purge.ErrPurgeBusy):
|
||||
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
writeInternalServerError(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
settings, err := h.PurgeSettingsUseCase.Get()
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var request PurgeSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
|
||||
AutoPurgeEnabled: request.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
|
||||
})
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
|
||||
preview, err := h.PreviewPurgeUseCase.Preview()
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
|
||||
for _, group := range preview.List {
|
||||
list = append(list, purgePreviewGroupResponse{
|
||||
KeepID: group.KeepID,
|
||||
Filename: group.Filename,
|
||||
URL: group.URL,
|
||||
UsedIn: group.UsedIn,
|
||||
Merged: group.Merged,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, purgePreviewResponse{
|
||||
Groups: preview.Groups,
|
||||
Copies: preview.Copies,
|
||||
Articles: preview.Articles,
|
||||
List: list,
|
||||
More: preview.More,
|
||||
Fingerprint: preview.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request PurgeRunRequest
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.RunPurgeUseCase.Run(request.Fingerprint)
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, purgeRunResponse{
|
||||
GroupsTotal: result.GroupsTotal,
|
||||
GroupsPurged: result.GroupsPurged,
|
||||
Merged: result.Merged,
|
||||
ArticlesUpdated: result.ArticlesUpdated,
|
||||
FailedGroups: result.FailedGroups,
|
||||
})
|
||||
}
|
||||
@@ -95,3 +95,21 @@ func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Hand
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,7 @@ const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html
|
||||
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
const PurgeBusyErrorMessage = "A purge is already running."
|
||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||
+8
-1
@@ -208,13 +208,20 @@ func NewAPIKeyRouter(container *UseCases) http.Handler {
|
||||
func NewMediaRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.Post("/purge", mediaHandler.RunPurge)
|
||||
})
|
||||
r.Get("/", mediaHandler.List)
|
||||
r.Post("/", mediaHandler.Upload)
|
||||
r.Post("/merge", mediaHandler.Merge)
|
||||
|
||||
@@ -30,6 +30,21 @@ Nothing under `internal/` imports `api/`.
|
||||
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
|
||||
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
|
||||
|
||||
### Media purge
|
||||
|
||||
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
|
||||
|
||||
The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `RunPurge` depend only on domain interfaces, an injected clock and an injected `*log.Logger`, so a scheduler can call them. `PreviewPurge` and `RunPurge` share one plan (`purgePlan.go`): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest `created_at`, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. `RunPurge` takes a non-blocking in-process lock (`ErrPurgeBusy` for a concurrent call), refuses a non-matching fingerprint with `ErrPurgeConflict`, merges group by group with `MergeMedia` (a failing group is counted and skipped), unions the `UpdatedArticleIDs` of the fully merged groups, and records `last_purge_*` after every run.
|
||||
|
||||
`middleware.RequireOwnerOrAdmin` lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the `/{id}` routes:
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /v1/media/purge-settings` | Settings with `next_purge_at`. |
|
||||
| `PATCH /v1/media/purge-settings` | Partial update of `auto_purge_enabled` and `auto_purge_interval_days`. |
|
||||
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
|
||||
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
|
||||
|
||||
### Image metadata backfill
|
||||
|
||||
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
Feature: Owners and admins can merge duplicate images in use, with persisted purge settings
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings
|
||||
So that articles end up pointing at the most used copy of an image and the other copies are removed
|
||||
|
||||
Background:
|
||||
Given internal/domain/purge defines the entity Settings with AutoPurgeEnabled (default false),
|
||||
AutoPurgeIntervalDays (default 30, valid 1 to 365), LastPurgeAt, LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated and EnabledAt (unset or 0 by default), plus a Repository
|
||||
interface with Get and Save, and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict
|
||||
(the library changed since the fingerprint) and ErrPurgeBusy (a run is already in progress),
|
||||
all asserted with errors.Is
|
||||
And Repository.Get returns the defaults when nothing was ever saved (a single-row table)
|
||||
And the use cases GetPurgeSettings, UpdatePurgeSettings, PreviewPurge and RunPurge depend only
|
||||
on domain interfaces, take no HTTP types and are reusable by a scheduler
|
||||
And the use cases receive an injectable clock and an injected *log.Logger
|
||||
And an image's "used in" is the count of articles, online or draft, whose body contains the
|
||||
image's stored name, read from article bodies each time, as in ListMedia
|
||||
And a duplicate group is the set of images sharing one non-empty sha256, as from
|
||||
image.Repository.ListDuplicates
|
||||
And the purge plan is: a group is planned only if at least one copy is used in an article, the
|
||||
kept copy is the most used one (ties: oldest created_at, then lowest id), and every other
|
||||
copy of the group, used or unused, is merged into it. A group with no used copy is skipped
|
||||
And the fingerprint is a hex SHA-256 of the planned groups ordered by kept id, with their kept
|
||||
id and sorted merged ids; an empty plan has a fixed fingerprint
|
||||
And useCases.MergeMediaResult keeps ArticlesUpdated and Merged, and gains UpdatedArticleIDs,
|
||||
the ids of the articles actually rewritten (empty when the merge was rolled back or failed)
|
||||
And RunPurge merges each group with the MergeMedia semantics and unions UpdatedArticleIDs of the
|
||||
fully merged groups; it never recomputes the articles from the plan
|
||||
And next_purge_at is max(last_purge_at, enabled_at) + auto_purge_interval_days when automatic
|
||||
purge is on and null when it is off; with no last_purge_at it is enabled_at + interval
|
||||
And /v1/media/purge-settings, /v1/media/purge-preview and /v1/media/purge are mounted in
|
||||
api/router.go inside the NewMediaRouter chain (Verifier, Authenticator, RequireCurrentToken,
|
||||
RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and
|
||||
registered before the /{id} routes
|
||||
And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value,
|
||||
including an empty role, answers 403 with apperr.OwnerOrAdminRequiredErrorMessage
|
||||
("Only owners and admins can do this.") as plain text from http.Error
|
||||
And a stale fingerprint answers 409 with apperr.PurgeConflictErrorMessage ("The library changed,
|
||||
so nothing was purged. Check the new summary and try again.") and a busy run answers 409 with
|
||||
apperr.PurgeBusyErrorMessage ("A purge is already running.")
|
||||
And the purge settings range error is apperr.PurgeIntervalErrorMessage ("Enter a whole number of
|
||||
days between 1 and 365.")
|
||||
And request and response bodies are JSON with snake_case fields; errors are plain text
|
||||
And the request body of PATCH is validated by validate tags and validator.Validate.Struct
|
||||
|
||||
Scenario Outline: Every purge route refuses callers that are not allowed
|
||||
When the client sends <method> <path> <credentials>
|
||||
Then the response status is <status>
|
||||
And the body is <body>
|
||||
And no purge setting, image or article is read or changed
|
||||
|
||||
Examples:
|
||||
| method | path | credentials | status | body |
|
||||
| GET | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| PATCH | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| GET | /v1/media/purge-preview | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| POST | /v1/media/purge | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| GET | /v1/media/purge-settings | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of an unverified owner | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins both pass the role guard
|
||||
When the client sends <method> <path> with a valid cookie of a verified <role>
|
||||
Then the response status is not 401 and not 403
|
||||
|
||||
Examples:
|
||||
| role | method | path |
|
||||
| owner | GET | /v1/media/purge-settings |
|
||||
| admin | GET | /v1/media/purge-settings |
|
||||
| owner | GET | /v1/media/purge-preview |
|
||||
| admin | GET | /v1/media/purge-preview |
|
||||
|
||||
Rule: Purge settings
|
||||
|
||||
Scenario: Nothing was ever saved, so the defaults are served
|
||||
When the client sends GET /v1/media/purge-settings with a valid cookie of the owner
|
||||
Then the response status is 200
|
||||
And the body is {"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}
|
||||
|
||||
Scenario: Turning automatic purge on stamps enabled_at and computes next_purge_at
|
||||
Given the clock reads 2026-10-05T09:00:00Z and the settings are the defaults
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then the response status is 200
|
||||
And "auto_purge_enabled" is true, "auto_purge_interval_days" is 30
|
||||
And "enabled_at" is "2026-10-05T09:00:00Z" and "last_purge_at" is null
|
||||
And "next_purge_at" is "2026-11-04T09:00:00Z"
|
||||
And a following GET /v1/media/purge-settings returns the same body
|
||||
|
||||
Scenario: next_purge_at counts from the last purge when it is later than enabled_at
|
||||
Given automatic purge is on with interval 10, enabled_at 2026-10-01T00:00:00Z and last_purge_at 2026-10-04T12:00:00Z
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then "next_purge_at" is "2026-10-14T12:00:00Z"
|
||||
|
||||
Scenario: next_purge_at counts from enabled_at when it is later than the last purge
|
||||
Given automatic purge is on with interval 10, enabled_at 2026-10-05T09:00:00Z and last_purge_at 2026-09-01T00:00:00Z
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then "next_purge_at" is "2026-10-15T09:00:00Z"
|
||||
|
||||
Scenario: Turning automatic purge off and on again never yields a next_purge_at in the past
|
||||
Given automatic purge was on, last_purge_at is 2026-09-01T00:00:00Z, and it was turned off
|
||||
And the clock reads 2026-10-05T09:00:00Z and the interval is 30
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then "enabled_at" is "2026-10-05T09:00:00Z"
|
||||
And "last_purge_at" is still "2026-09-01T00:00:00Z"
|
||||
And "next_purge_at" is "2026-11-04T09:00:00Z"
|
||||
|
||||
Scenario: Changing only the interval keeps enabled_at
|
||||
Given automatic purge is on since 2026-10-01T00:00:00Z with interval 30, no purge yet, and the clock reads 2026-10-05T09:00:00Z
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
|
||||
Then the response status is 200
|
||||
And "auto_purge_interval_days" is 7 and "enabled_at" is still "2026-10-01T00:00:00Z"
|
||||
And "next_purge_at" is "2026-10-08T00:00:00Z"
|
||||
|
||||
Scenario: Patching "on" while already on does not restamp enabled_at
|
||||
Given automatic purge is on since 2026-10-01T00:00:00Z and the clock reads 2026-10-05T09:00:00Z
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then "enabled_at" is still "2026-10-01T00:00:00Z"
|
||||
|
||||
Scenario: Turning automatic purge off nulls next_purge_at and keeps the history
|
||||
Given automatic purge is on with last_purge_at 2026-10-04T12:00:00Z, last_purge_merged 3 and last_purge_articles_updated 5
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": false}
|
||||
Then "auto_purge_enabled" is false and "next_purge_at" is null
|
||||
And "last_purge_at", "last_purge_merged" (3) and "last_purge_articles_updated" (5) are unchanged
|
||||
|
||||
Scenario Outline: The interval accepts whole numbers from 1 to 365
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": <days>}
|
||||
Then the response status is 200 and "auto_purge_interval_days" is <days>
|
||||
|
||||
Examples:
|
||||
| days |
|
||||
| 1 |
|
||||
| 30 |
|
||||
| 365 |
|
||||
|
||||
Scenario Outline: An invalid patch is 400 and nothing changes
|
||||
When the client sends PATCH /v1/media/purge-settings with <body>
|
||||
Then the response status is 400
|
||||
And the body is <message>
|
||||
And the stored settings are unchanged
|
||||
|
||||
Examples:
|
||||
| body | message |
|
||||
| {"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 |
|
||||
|
||||
Scenario: An empty object changes nothing
|
||||
When the client sends PATCH /v1/media/purge-settings with {}
|
||||
Then the response status is 200 and the body equals the current settings
|
||||
|
||||
Scenario: The use case rejects an out-of-range interval with the domain sentinel
|
||||
When UpdatePurgeSettings receives interval 0, or 366
|
||||
Then errors.Is(err, purge.ErrInvalidPurgeInterval) is true
|
||||
And Repository.Save is not called
|
||||
|
||||
Scenario: A repository failure is a generic 500
|
||||
Given the purge settings repository's next Get fails
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: A failing Save on PATCH is a generic 500
|
||||
Given the purge settings repository's next Save fails
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: The purge routes are not taken by the /{id} routes
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
|
||||
Then the settings are updated and no image is renamed
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the preview use case is invoked
|
||||
|
||||
Rule: Purge plan and preview
|
||||
|
||||
Background:
|
||||
Given the clock reads 2026-10-05T09:00:00Z
|
||||
And images are created in id order, oldest first
|
||||
And articles reference images by their stored name
|
||||
|
||||
Scenario: A group with a used copy is planned, merging every other copy into the most used
|
||||
Given images 1, 2 and 3 share sha256 A with filenames "hero.jpg", "hero-copy.jpg", "hero-2.jpg"
|
||||
And article 10 uses image 1, articles 11 and 12 use image 2, article 13 uses image 3
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the response status is 200
|
||||
And "groups" is 1, "copies" is 2 and "articles" is 2
|
||||
And "list" is [{"keep_id": 2, "filename": "hero-copy.jpg", "url": "<stored url of image 2>",
|
||||
"used_in": 2, "merged": 2}]
|
||||
And "more" is 0 and "fingerprint" is a 64-character hex string
|
||||
|
||||
Scenario: Ties on usage keep the oldest copy, then the lowest id
|
||||
Given images 4 and 5 share sha256 B, created at the same instant, each used in one article
|
||||
And images 6 and 7 share sha256 C, image 7 is older than image 6, each used in one article
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the "keep_id" of the group B is 4 and the "keep_id" of the group C is 7
|
||||
|
||||
Scenario: A single used copy with unused siblings keeps the used one
|
||||
Given images 8, 9 and 10 share sha256 D and only image 10 is used, in one article
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the group has keep_id 10, used_in 1, merged 2
|
||||
And "articles" is 0, since no article references a merged-away copy
|
||||
|
||||
Scenario: A group whose copies are all unused is skipped
|
||||
Given images 11 and 12 share sha256 E and no article references either
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 0, "copies" is 0, "articles" is 0, "list" is [] and "more" is 0
|
||||
|
||||
Scenario: A library with no duplicates
|
||||
Given the library holds three images with distinct sha256 values
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the response status is 200
|
||||
And "groups" is 0 and "list" is an empty array in the raw JSON, not null
|
||||
And "fingerprint" equals the fingerprint of any other empty plan
|
||||
|
||||
Scenario: Images without a hash are never grouped
|
||||
Given images 13 and 14 both have an empty sha256 and both are used
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 0
|
||||
|
||||
Scenario: An article that uses copies of two groups counts once
|
||||
Given groups A (keep 1, copy 2) and B (keep 3, copy 4) and one article that uses images 2 and 4
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 2, "copies" is 2 and "articles" is 1
|
||||
|
||||
Scenario: An article that uses both the kept copy and a copy is rewritten
|
||||
Given an article uses images 1 and 2 of one group where image 1 is kept
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "articles" is 1
|
||||
|
||||
Scenario: The list shows at most 5 groups and reports the rest
|
||||
Given 7 planned groups
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 7, "list" has 5 entries in ascending keep_id order and "more" is 2
|
||||
|
||||
Scenario: The preview changes nothing
|
||||
Given a planned group
|
||||
When the client sends GET /v1/media/purge-preview twice
|
||||
Then both bodies are equal
|
||||
And no image is deleted, no article body changes and no purge setting is saved
|
||||
|
||||
Scenario Outline: The fingerprint reacts to the plan and to nothing else
|
||||
Given a planned group with kept image 2 and merged image 3
|
||||
When <change>
|
||||
Then the preview fingerprint is <result>
|
||||
|
||||
Examples:
|
||||
| change | result |
|
||||
| nothing changes | identical |
|
||||
| a new unused copy is uploaded to the group | different |
|
||||
| a copy is deleted from the group | different |
|
||||
| article usage changes so that another copy becomes kept | different |
|
||||
| an unrelated image with a unique sha256 is uploaded | identical |
|
||||
| an article body changes without changing which is kept | identical |
|
||||
| a group with no used copy gains an unused copy | identical |
|
||||
|
||||
Rule: Running a purge
|
||||
|
||||
Background:
|
||||
Given the clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: A run merges every planned group and persists the result
|
||||
Given group A: images 1 (used in article 10), 2 (used in articles 11 and 12) and 3 (unused)
|
||||
And group B: images 4 (used in article 12) and 5 (unused)
|
||||
And group C: images 6 and 7, both unused
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 2, "groups_purged": 2, "merged": 3, "articles_updated": 1, "failed_groups": 0}
|
||||
And images 1 and 3 are gone from the image repository, image 2 remains
|
||||
And image 5 is gone, image 4 remains, and images 6 and 7 both remain
|
||||
And article 10's body now uses image 2's stored name
|
||||
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 3 and last_purge_articles_updated 1
|
||||
And only article 10 is rewritten, since article 12 uses only kept copies
|
||||
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are untouched
|
||||
|
||||
Scenario: An article rewritten by two groups is counted once in articles_updated
|
||||
Given an article uses a copy of group A and a copy of group B
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then "articles_updated" counts that article once, from the union of the groups' UpdatedArticleIDs
|
||||
|
||||
Scenario: A single used copy with unused siblings is merged without rewriting any article
|
||||
Given images 8, 9 and 10 share a hash and only image 10 is used
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the body is {"groups_total": 1, "groups_purged": 1, "merged": 2, "articles_updated": 0, "failed_groups": 0}
|
||||
And images 8 and 9 are gone, image 10 remains and no article body changed
|
||||
|
||||
Scenario: A matching fingerprint is accepted
|
||||
Given the preview returned fingerprint F
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
|
||||
Then the response status is 200 and the purge ran
|
||||
|
||||
Scenario: A stale fingerprint answers 409 and changes nothing
|
||||
Given the preview returned fingerprint F
|
||||
And an unused copy is then uploaded to a planned group
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
|
||||
Then the response status is 409
|
||||
And the body is apperr.PurgeConflictErrorMessage
|
||||
And no image is deleted, no article body changes and the stored last_purge_* values are unchanged
|
||||
|
||||
Scenario: A fingerprint that matches no plan at all is a conflict, not a validation error
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "not-a-real-fingerprint"}
|
||||
Then the response status is 409 and the body is apperr.PurgeConflictErrorMessage
|
||||
And nothing changed
|
||||
|
||||
Scenario: A run without a fingerprint always runs the current plan
|
||||
Given the library changed since any preview
|
||||
When the client sends POST /v1/media/purge with an empty body
|
||||
Then the response status is 200 and the current plan ran
|
||||
|
||||
Scenario: A library with nothing to purge answers zeros and is still recorded
|
||||
Given the library holds no planned group
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 0, "groups_purged": 0, "merged": 0, "articles_updated": 0, "failed_groups": 0}
|
||||
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 0 and last_purge_articles_updated 0
|
||||
And a scheduler reading next_purge_at sees it move forward from that run
|
||||
|
||||
Scenario: The fingerprint of the empty plan on an empty plan runs as a no-op
|
||||
Given the library holds no planned group
|
||||
When the client sends POST /v1/media/purge with the empty plan's fingerprint
|
||||
Then the response status is 200 and the body has groups_total 0
|
||||
|
||||
Scenario: A group that fails while rewriting bodies is rolled back and contributes no ids
|
||||
Given groups A, B and C are planned, in that order
|
||||
And the article repository's UpdateBody fails for the second article that group B must rewrite
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 3, "groups_purged": 2, "merged": <copies of A and C>, "articles_updated": <distinct articles of A and C>, "failed_groups": 1}
|
||||
And group B's article bodies are back to their original text and all of group B's images remain
|
||||
And group B's UpdatedArticleIDs is empty, so no article of group B is in "articles_updated"
|
||||
And the stored last_purge_merged and last_purge_articles_updated equal the response totals
|
||||
|
||||
Scenario: A group that fails after rewriting bodies is reported as failed and contributes nothing
|
||||
Given the image repository's Delete fails for a merged image of group A, and group B is planned
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1
|
||||
And group A contributes 0 to "merged" and its article ids are not added to "articles_updated", even though its article bodies stay switched to the kept copy
|
||||
And "articles_updated" and "merged" count only the fully merged group B
|
||||
And a following GET /v1/media/purge-preview plans group A again, and the next run converges because the plan is recomputed from current data
|
||||
|
||||
Scenario: Every group failing is still a 200 with the counters
|
||||
Given every UpdateBody fails
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And "groups_purged" is 0 and "failed_groups" equals "groups_total"
|
||||
And the stored last_purge_at is set
|
||||
|
||||
Scenario: A second run while one is in progress is rejected as busy, not queued
|
||||
Given a purge run is in progress and blocked inside the image repository
|
||||
When a second POST /v1/media/purge arrives, with or without a fingerprint
|
||||
Then it answers 409 with apperr.PurgeBusyErrorMessage at once
|
||||
And it changed nothing and saved no last_purge_* values
|
||||
When the first run is released
|
||||
Then it finishes normally with its own counters
|
||||
And a later POST /v1/media/purge is accepted
|
||||
|
||||
Scenario: The use case distinguishes a busy run from a stale fingerprint
|
||||
When RunPurge receives a non-matching fingerprint
|
||||
Then errors.Is(err, purge.ErrPurgeConflict) is true and errors.Is(err, purge.ErrPurgeBusy) is false
|
||||
When RunPurge is called while another run holds the guard
|
||||
Then errors.Is(err, purge.ErrPurgeBusy) is true and errors.Is(err, purge.ErrPurgeConflict) is false
|
||||
And in both cases no repository write happens
|
||||
|
||||
Scenario: A run via the use case with no fingerprint needs no HTTP types
|
||||
When a scheduler later calls RunPurge with no fingerprint
|
||||
Then it gets the result struct and the same last_purge_* persistence applies
|
||||
|
||||
Scenario: A malformed body is 400
|
||||
When the client sends POST /v1/media/purge with "not json"
|
||||
Then the response status is 400 and the body is apperr.BodyErrorMessage
|
||||
And nothing changed
|
||||
|
||||
Scenario: A repository failure while planning is a generic 500 and nothing changes
|
||||
Given the image repository's ListDuplicates fails
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And no last_purge_* values are saved
|
||||
|
||||
Scenario: A failing save of the run result is a logged 500 and the merges stay
|
||||
Given the purge settings repository's Save fails after the merges succeeded
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And the merges that already happened stay done
|
||||
And the error is written to the injected *log.Logger
|
||||
And the next POST /v1/media/purge converges, because the plan is recomputed from current data
|
||||
|
||||
Scenario: The preview and the run use the same plan
|
||||
When the client sends GET /v1/media/purge-preview and then POST /v1/media/purge with the returned fingerprint
|
||||
Then the run's "groups_total" equals the preview's "groups"
|
||||
And the run's "merged" equals the preview's "copies" and its "articles_updated" equals the preview's "articles"
|
||||
|
||||
Rule: MergeMedia keeps its contract while exposing the rewritten ids
|
||||
|
||||
Scenario: Existing merges report the same counts and now also the ids
|
||||
When MergeMedia.Merge succeeds for a group that rewrites articles 1 and 2
|
||||
Then Merged and ArticlesUpdated are unchanged for existing callers
|
||||
And UpdatedArticleIDs is [1, 2]
|
||||
|
||||
Scenario: A rolled-back merge returns no ids
|
||||
When MergeMedia.Merge fails while rewriting a body
|
||||
Then it returns the error and an empty result
|
||||
|
||||
@manual
|
||||
Rule: Persistence and wiring (verified by hand, see the qa.md)
|
||||
|
||||
Scenario: The purge settings table is a single row created by AutoMigrate
|
||||
Given a database with no purge settings table
|
||||
When the server boots
|
||||
Then the table exists, GET /v1/media/purge-settings returns the defaults and no row is required beforehand
|
||||
And repeated PATCH calls keep exactly one row
|
||||
|
||||
Scenario: Settings survive a restart
|
||||
Given automatic purge was turned on with interval 7
|
||||
When the server restarts
|
||||
Then GET /v1/media/purge-settings returns the same values
|
||||
|
||||
Scenario: A real purge removes the merged files from UPLOAD_DIR
|
||||
Given duplicates uploaded through the real server
|
||||
When an owner runs POST /v1/media/purge
|
||||
Then the merged copies' files are gone from UPLOAD_DIR and the kept file remains
|
||||
@@ -0,0 +1,78 @@
|
||||
# QA procedure: media-purge
|
||||
|
||||
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Fakes:
|
||||
- `mockPurgeSettingsRepo` (domain `purge.Repository`): holds one `Settings`, `Get` returns the defaults when nothing was saved, `Save` records the call count, with armed failure switches `failGet` and `failSave`.
|
||||
- Reuse or extend `mockImageRepo` (`test/image_test.go`): `ListDuplicates()` (images with a non-empty sha256 shared by at least two rows, ordered `sha256, id`), `Delete` with `failDeleteFor`, `Get`, `FindByHash`, `Counts`. Add a hook that lets a test block inside `ListDuplicates` or `Delete` on a channel, to hold a run in progress.
|
||||
- Reuse or extend `mockArticleRepo` (`test/article_test.go`): `FindByFilters`, `FindByBodyContaining`, `UpdateBody` with a per-id failure switch.
|
||||
- The user fake: a verified owner (id 1), a verified admin (id 2), a verified user with `Role("editor")` (id 3), a verified user with an empty role (id 5), and an unverified owner (id 4).
|
||||
3. Wire the real use cases over the fakes: the four purge use cases (constructors chosen by the coder), `NewGetUserUseCase(userRepo)`, plus the existing media use cases. Inject a fixed clock (2026-10-05T09:00:00Z) and a `*log.Logger` writing to a `bytes.Buffer`. Add the four purge handler methods to the media handler, built with `media.NewHandler(...)`.
|
||||
4. Build `jwtauth.New("HS256", []byte("test-secret"), nil)` and mount on a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`:
|
||||
- `jwtauth.Verifier` at the top, then `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`.
|
||||
- The purge routes inside `r.Group` with `middleware.RequireOwnerOrAdmin(getUser)`, registered BEFORE `Patch("/{id}")` and `Delete("/{id}")`.
|
||||
- Also register `Get("/")`, `Post("/merge")`, `Patch("/{id}")` and `Delete("/{id}")` so route precedence is exercised.
|
||||
- Mount under `/v1/media`.
|
||||
5. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), "token_version": ...})` as in other HTTP tests. Build a stale-token cookie with a lower `token_version`.
|
||||
6. Seeding helper `seedGroup(images ...)` assigning ids in order, strictly increasing `CreatedAt` unless the case says otherwise, and `articleUsing(id, images ...)` building bodies that contain each image's stored name (`image.StoredName(path)`).
|
||||
7. Compare JSON bodies by decoding, except where the case says "raw body" (null versus `[]`, null versus absent). Error bodies are plain text: compare to `message + "\n"`.
|
||||
|
||||
## Auth and role checks (table-driven)
|
||||
8. For each row of the "refuse callers" outline (4 routes x no cookie, stale, unverified, role "editor", empty role), assert the status and body. Assert the settings, image and article fakes saw no call (only the user lookup of the middleware).
|
||||
9. Owner and admin cookies: GET `/v1/media/purge-settings` and GET `/v1/media/purge-preview` return a status other than 401 and 403.
|
||||
10. Allowlist guard: add a case proving a role other than `owner`/`admin` (for example "OWNER" in upper case, or "superuser") is refused with 403, so the middleware is not a blocklist.
|
||||
|
||||
## Settings
|
||||
11. Defaults: empty repo, GET returns the exact default body. Assert the raw JSON has `"last_purge_at":null`, `"enabled_at":null` and `"next_purge_at":null` (not omitted).
|
||||
12. PATCH on: assert `enabled_at` equals the fixed clock, `next_purge_at` = enabled_at + 30 days, the GET after returns the same body, and `Save` was called once.
|
||||
13. next_purge_at basis (max rule): last_purge_at later than enabled_at counts from last_purge_at; enabled_at later than last_purge_at counts from enabled_at; no last_purge_at counts from enabled_at.
|
||||
14. Re-enable step: seed on with `last_purge_at` 2026-09-01, PATCH `false`, then PATCH `true` at the fixed clock with interval 30. Assert `enabled_at` is the clock, `last_purge_at` is still 2026-09-01, and `next_purge_at` is 2026-11-04T09:00:00Z, strictly after the clock.
|
||||
15. Interval-only PATCH keeps `enabled_at`. PATCH `true` while already on keeps `enabled_at`. PATCH `false` nulls `next_purge_at` and keeps last_purge_*.
|
||||
16. Interval table (1, 30, 365 accepted). Invalid table (0, 366, -1 give `apperr.PurgeIntervalErrorMessage`; 1.5, "7", "yes", not json give `apperr.BodyErrorMessage`). Assert the repo state and the `Save` count are unchanged after each 400.
|
||||
17. `{}` PATCH returns 200 equal to the current settings.
|
||||
18. Use-case-level: `UpdatePurgeSettings` with interval 0 and 366 returns an error with `errors.Is(err, purge.ErrInvalidPurgeInterval)` and no `Save`. At the handler, a value of 0 must be rejected by the validate tag and never reach the use case.
|
||||
19. Failure rows: `failGet` gives 500 "Internal server error\n"; `failSave` on PATCH gives 500.
|
||||
20. Route precedence: PATCH `/v1/media/purge-settings` reaches the settings handler and does not touch `Rename`.
|
||||
|
||||
## Preview
|
||||
21. Build each Background scenario with the seed helpers and assert the decoded `groups`, `copies`, `articles`, `list` and `more`:
|
||||
- used group (keep 2 with used_in 2; copies 2; articles 2);
|
||||
- tie-breaks: same CreatedAt gives the lowest id, different CreatedAt gives the oldest (assert both orders by swapping ids);
|
||||
- a single used copy with unused siblings (articles 0);
|
||||
- an all-unused group;
|
||||
- no duplicates (raw `"list":[]`);
|
||||
- empty-hash images ignored;
|
||||
- an article touching two groups counts once;
|
||||
- an article using kept plus a copy counts once;
|
||||
- 7 groups give 5 list entries in ascending keep_id order and `more` 2. Each entry has `keep_id`, `filename`, `url`, `used_in`, `merged`.
|
||||
22. Preview purity: call twice, assert equal bodies, assert zero `Delete`, `UpdateBody` and `Save` calls.
|
||||
23. Fingerprint table: compute a baseline, apply each change from the outline, and assert equal or different. Assert it is a 64-char lowercase hex string and that two empty plans share it.
|
||||
|
||||
## Run
|
||||
24. Full run scenario: assert the response body, the remaining images, the rewritten article bodies (check bytes), and `Save` called once with `last_purge_at` = clock, `last_purge_merged` = 3 and `last_purge_articles_updated` = 1, and that `auto_purge_*` and `enabled_at` are untouched. Article 12 uses only kept copies, so only article 10 is rewritten.
|
||||
25. `articles_updated` source: it must come from the union of `MergeMediaResult.UpdatedArticleIDs` returned by the group merges, never from the plan. Prove it with the rolled-back case in step 29: the plan contains B's articles, the response does not.
|
||||
26. Used-sibling run with zero article rewrites (`articles_updated` 0, `UpdateBody` never called).
|
||||
27. Fingerprint: match gives 200. A stale fingerprint gives 409 with `apperr.PurgeConflictErrorMessage + "\n"`, no `Delete`, no `UpdateBody`, no `Save`. A garbage string gives 409 with the same body. No fingerprint, `{}` and an empty body all run.
|
||||
28. Empty plan: 200 zeros, `Save` with `last_purge_at` = clock and zero counters, and a run with the empty plan's fingerprint is also 200.
|
||||
29. Partial failure, rolled back: arm `UpdateBody` to fail for the second article of group B (plan order A, B, C). Assert the body counters, B's bodies restored byte for byte, all B images present, A and C merged, B's articles absent from `articles_updated`, and `Save` counters equal the response.
|
||||
30. Partial failure, delete: arm `failDeleteFor` for one of group A's merged images and plan B too. Assert `failed_groups` 1, `groups_purged` 1, A contributes 0 to `merged` and none of its ids to `articles_updated` (only B counts), A's article bodies stay switched to the kept copy, and a following preview plans A again. Run the purge again with the failure disarmed and assert it converges (A's leftover copy is merged away). Then arm every `UpdateBody` and assert `groups_purged` 0, `failed_groups` = `groups_total` and `last_purge_at` still saved.
|
||||
31. Concurrency: start run 1 in a goroutine, blocked in the image fake (wait on a "started" channel). Send run 2 (once with a fingerprint, once without). Assert both answer 409 with `apperr.PurgeBusyErrorMessage + "\n"` immediately (use a timeout) and no `Save`/`Delete` calls from them. Release run 1, assert its 200 body and that a later run is accepted. Run this test with `-race`.
|
||||
32. Sentinels at the use-case level, asserted separately: a stale fingerprint gives `errors.Is(err, purge.ErrPurgeConflict)` true and `errors.Is(err, purge.ErrPurgeBusy)` false; a busy guard gives `ErrPurgeBusy` true and `ErrPurgeConflict` false. Call RunPurge without any HTTP package to prove it is reusable (that test file section imports no `net/http`).
|
||||
33. Malformed body gives 400 `apperr.BodyErrorMessage`. `ListDuplicates` failure gives 500 and no `Save`.
|
||||
34. Persist failure: arm `failSave` on the run-result save after merges succeed. Assert 500 "Internal server error\n", the merged images are still gone, the logger buffer contains the error text, then disarm and run again and assert 200 with no remaining groups (convergence from current data).
|
||||
35. Preview versus run parity: the run's `groups_total`, `merged` and `articles_updated` equal the preview's `groups`, `copies` and `articles` on the same seed.
|
||||
36. MergeMedia contract: extend the existing merge tests only by adding assertions. `Merged` and `ArticlesUpdated` are unchanged, `UpdatedArticleIDs` lists the rewritten ids in ascending order, and a rolled-back merge returns the error and an empty result. Existing callers and tests must compile and pass unchanged.
|
||||
|
||||
## Layering guards
|
||||
37. `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package. The new use cases import no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...` package. `internal/domain/purge` imports only the stdlib and other `internal/domain/*` packages. Use a `go list -deps` or grep-style test as in media_library.qa.md.
|
||||
|
||||
## Manual verification (not exercised by `task test`)
|
||||
- AutoMigrate: boot against a scratch SQLite file (`task dev`) and check the purge settings table exists, GET returns the defaults, repeated PATCH keeps one row, and values survive a restart. The mapper round-trips `last_purge_at`/`enabled_at` including null.
|
||||
- Real purge against real uploads: upload duplicates through `POST /v1/media/`, reference them from articles, run the preview then the purge. The merged files disappear from `UPLOAD_DIR`, the kept file loads through `/static/uploadedImages/`, and article bodies are switched.
|
||||
- Concurrent real requests with two shells (`curl` with the `jwt` cookie): the second gets 409 "A purge is already running." while the first runs.
|
||||
- Role check with a real admin account (invite then accept). Use a hand-edited user row with an empty or unknown role to check the 403.
|
||||
- The persist-failure log line appears in the server output (check by making the database read-only during a purge).
|
||||
- Docs: `docs/architecture.md` lists the `purge` domain package, the four use cases, the settings repository, the new routes and `RequireOwnerOrAdmin`. Read it and compare. No SMTP and no env var are involved.
|
||||
- Out of scope: the scheduler (part 2) and the SPA (part 3).
|
||||
@@ -0,0 +1,48 @@
|
||||
package purge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidPurgeInterval = errors.New("invalid purge interval")
|
||||
var ErrPurgeConflict = errors.New("the library changed since the fingerprint")
|
||||
var ErrPurgeBusy = errors.New("a purge is already running")
|
||||
|
||||
const (
|
||||
DefaultIntervalDays = 30
|
||||
MinIntervalDays = 1
|
||||
MaxIntervalDays = 365
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
|
||||
LastPurgeAt *time.Time `json:"last_purge_at"`
|
||||
LastPurgeMerged int `json:"last_purge_merged"`
|
||||
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{AutoPurgeIntervalDays: DefaultIntervalDays}
|
||||
}
|
||||
|
||||
func ValidateIntervalDays(days int) error {
|
||||
if days < MinIntervalDays || days > MaxIntervalDays {
|
||||
return ErrInvalidPurgeInterval
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Settings) NextPurgeAt() *time.Time {
|
||||
if !s.AutoPurgeEnabled || s.EnabledAt == nil {
|
||||
return nil
|
||||
}
|
||||
base := *s.EnabledAt
|
||||
if s.LastPurgeAt != nil && s.LastPurgeAt.After(base) {
|
||||
base = *s.LastPurgeAt
|
||||
}
|
||||
next := base.AddDate(0, 0, s.AutoPurgeIntervalDays)
|
||||
return &next
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package purge
|
||||
|
||||
type Repository interface {
|
||||
Get() (Settings, error)
|
||||
Save(settings Settings) error
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func PurgeSettingsToDomain(m models.PurgeSettings) purge.Settings {
|
||||
return purge.Settings{
|
||||
AutoPurgeEnabled: m.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: m.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: m.LastPurgeAt,
|
||||
LastPurgeMerged: m.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: m.LastPurgeArticlesUpdated,
|
||||
EnabledAt: m.EnabledAt,
|
||||
}
|
||||
}
|
||||
|
||||
func PurgeSettingsToModel(s purge.Settings) models.PurgeSettings {
|
||||
return models.PurgeSettings{
|
||||
AutoPurgeEnabled: s.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: s.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: s.LastPurgeAt,
|
||||
LastPurgeMerged: s.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: s.LastPurgeArticlesUpdated,
|
||||
EnabledAt: s.EnabledAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PurgeSettings struct {
|
||||
ID uint32 `gorm:"primaryKey"`
|
||||
AutoPurgeEnabled bool `gorm:"not null"`
|
||||
AutoPurgeIntervalDays int `gorm:"not null"`
|
||||
LastPurgeAt *time.Time
|
||||
LastPurgeMerged int `gorm:"not null"`
|
||||
LastPurgeArticlesUpdated int `gorm:"not null"`
|
||||
EnabledAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const purgeSettingsRowID uint32 = 1
|
||||
|
||||
type PurgeSettingsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domainPurge.Repository = &PurgeSettingsRepository{}
|
||||
|
||||
func NewPurgeSettingsRepository(db *gorm.DB) *PurgeSettingsRepository {
|
||||
return &PurgeSettingsRepository{db}
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Get() (domainPurge.Settings, error) {
|
||||
var model entity.PurgeSettings
|
||||
err := p.db.First(&model, purgeSettingsRowID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domainPurge.DefaultSettings(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return domainPurge.Settings{}, err
|
||||
}
|
||||
return mappers.PurgeSettingsToDomain(model), nil
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Save(settings domainPurge.Settings) error {
|
||||
model := mappers.PurgeSettingsToModel(settings)
|
||||
model.ID = purgeSettingsRowID
|
||||
return p.db.Save(&model).Error
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/purge"
|
||||
|
||||
type GetPurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
}
|
||||
|
||||
func NewGetPurgeSettingsUseCase(repository purge.Repository) *GetPurgeSettingsUseCase {
|
||||
return &GetPurgeSettingsUseCase{repository}
|
||||
}
|
||||
|
||||
func (g *GetPurgeSettingsUseCase) Get() (purge.Settings, error) {
|
||||
return g.repository.Get()
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type MergeMediaResult struct {
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
type MergeMediaUseCase struct {
|
||||
@@ -57,7 +58,15 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
}
|
||||
}
|
||||
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
}
|
||||
|
||||
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
|
||||
ids := make([]uint32, 0, len(rewrites))
|
||||
for _, plan := range rewrites {
|
||||
ids = append(ids, plan.articleId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
)
|
||||
|
||||
const purgePreviewListLimit = 5
|
||||
|
||||
type PurgePreviewGroup struct {
|
||||
KeepID uint32
|
||||
Filename string
|
||||
URL string
|
||||
UsedIn int
|
||||
Merged int
|
||||
}
|
||||
|
||||
type PurgePreview struct {
|
||||
Groups int
|
||||
Copies int
|
||||
Articles int
|
||||
List []PurgePreviewGroup
|
||||
More int
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository, articleRepository}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return PurgePreview{}, err
|
||||
}
|
||||
|
||||
shown := min(len(plan.groups), purgePreviewListLimit)
|
||||
list := make([]PurgePreviewGroup, 0, shown)
|
||||
for _, group := range plan.groups[:shown] {
|
||||
list = append(list, PurgePreviewGroup{
|
||||
KeepID: group.kept.ID,
|
||||
Filename: group.kept.Filename,
|
||||
URL: group.kept.Path,
|
||||
UsedIn: group.keptUsage,
|
||||
Merged: len(group.mergedIds),
|
||||
})
|
||||
}
|
||||
|
||||
return PurgePreview{
|
||||
Groups: len(plan.groups),
|
||||
Copies: plan.copies,
|
||||
Articles: plan.articles,
|
||||
List: list,
|
||||
More: len(plan.groups) - shown,
|
||||
Fingerprint: plan.fingerprint,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunPurgeResult struct {
|
||||
GroupsTotal int
|
||||
GroupsPurged int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
FailedGroups int
|
||||
}
|
||||
|
||||
type RunPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
merge *MergeMediaUseCase
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository, articleRepository},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (RunPurgeResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return RunPurgeResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
defer u.guard.Unlock()
|
||||
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return RunPurgeResult{}, err
|
||||
}
|
||||
if fingerprint != nil && *fingerprint != plan.fingerprint {
|
||||
return RunPurgeResult{}, purge.ErrPurgeConflict
|
||||
}
|
||||
|
||||
result := u.mergeGroups(plan)
|
||||
if err := u.recordRun(result); err != nil {
|
||||
u.logger.Printf("purge: could not record the run result: %v", err)
|
||||
return RunPurgeResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) RunPurgeResult {
|
||||
result := RunPurgeResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.Merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
if err != nil {
|
||||
result.FailedGroups++
|
||||
continue
|
||||
}
|
||||
result.GroupsPurged++
|
||||
result.Merged += merged.Merged
|
||||
for _, id := range merged.UpdatedArticleIDs {
|
||||
updated[id] = true
|
||||
}
|
||||
}
|
||||
result.ArticlesUpdated = len(updated)
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) recordRun(result RunPurgeResult) error {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := u.now().UTC()
|
||||
settings.LastPurgeAt = &at
|
||||
settings.LastPurgeMerged = result.Merged
|
||||
settings.LastPurgeArticlesUpdated = result.ArticlesUpdated
|
||||
return u.repository.Save(settings)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdatePurgeSettingsCommand struct {
|
||||
AutoPurgeEnabled *bool
|
||||
AutoPurgeIntervalDays *int
|
||||
}
|
||||
|
||||
type UpdatePurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewUpdatePurgeSettingsUseCase(repository purge.Repository, now func() time.Time) *UpdatePurgeSettingsUseCase {
|
||||
return &UpdatePurgeSettingsUseCase{repository, now}
|
||||
}
|
||||
|
||||
func (u *UpdatePurgeSettingsUseCase) Update(command UpdatePurgeSettingsCommand) (purge.Settings, error) {
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
if err := purge.ValidateIntervalDays(*command.AutoPurgeIntervalDays); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
|
||||
if command.AutoPurgeEnabled != nil {
|
||||
if *command.AutoPurgeEnabled && !settings.AutoPurgeEnabled {
|
||||
enabledAt := u.now().UTC()
|
||||
settings.EnabledAt = &enabledAt
|
||||
}
|
||||
settings.AutoPurgeEnabled = *command.AutoPurgeEnabled
|
||||
}
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
settings.AutoPurgeIntervalDays = *command.AutoPurgeIntervalDays
|
||||
}
|
||||
|
||||
if err := u.repository.Save(settings); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type purgeGroup struct {
|
||||
kept image.Image
|
||||
keptUsage int
|
||||
mergedIds []uint32
|
||||
}
|
||||
|
||||
type purgePlan struct {
|
||||
groups []purgeGroup
|
||||
copies int
|
||||
articles int
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
duplicates, err := p.imageRepository.ListDuplicates()
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
articles, err := p.articleRepository.FindByFilters(article.Filters{})
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
groups := []purgeGroup{}
|
||||
copies := 0
|
||||
touched := map[uint32]bool{}
|
||||
for _, members := range groupByHash(duplicates) {
|
||||
group, planned := planGroup(members, articles)
|
||||
if !planned {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, group)
|
||||
copies += len(group.mergedIds)
|
||||
for _, id := range articlesUsingCopies(group, members, articles) {
|
||||
touched[id] = true
|
||||
}
|
||||
}
|
||||
slices.SortFunc(groups, func(a, b purgeGroup) int { return int(a.kept.ID) - int(b.kept.ID) })
|
||||
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups)}, nil
|
||||
}
|
||||
|
||||
func groupByHash(images []image.Image) [][]image.Image {
|
||||
byHash := map[string][]image.Image{}
|
||||
hashes := []string{}
|
||||
for _, img := range images {
|
||||
if img.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := byHash[img.SHA256]; !seen {
|
||||
hashes = append(hashes, img.SHA256)
|
||||
}
|
||||
byHash[img.SHA256] = append(byHash[img.SHA256], img)
|
||||
}
|
||||
groups := make([][]image.Image, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
groups = append(groups, byHash[hash])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func usageOf(img image.Image, articles []article.Article) int {
|
||||
storedName := image.StoredName(img.Path)
|
||||
used := 0
|
||||
for _, a := range articles {
|
||||
if strings.Contains(a.Body, storedName) {
|
||||
used++
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
|
||||
usage := make(map[uint32]int, len(members))
|
||||
anyUsed := false
|
||||
for _, member := range members {
|
||||
usage[member.ID] = usageOf(member, articles)
|
||||
anyUsed = anyUsed || usage[member.ID] > 0
|
||||
}
|
||||
if !anyUsed {
|
||||
return purgeGroup{}, false
|
||||
}
|
||||
|
||||
kept := members[0]
|
||||
for _, member := range members[1:] {
|
||||
if isBetterToKeep(member, kept, usage) {
|
||||
kept = member
|
||||
}
|
||||
}
|
||||
|
||||
mergedIds := []uint32{}
|
||||
for _, member := range members {
|
||||
if member.ID != kept.ID {
|
||||
mergedIds = append(mergedIds, member.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(mergedIds)
|
||||
return purgeGroup{kept: kept, keptUsage: usage[kept.ID], mergedIds: mergedIds}, true
|
||||
}
|
||||
|
||||
func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
|
||||
if usage[candidate.ID] != usage[current.ID] {
|
||||
return usage[candidate.ID] > usage[current.ID]
|
||||
}
|
||||
if !candidate.CreatedAt.Equal(current.CreatedAt) {
|
||||
return candidate.CreatedAt.Before(current.CreatedAt)
|
||||
}
|
||||
return candidate.ID < current.ID
|
||||
}
|
||||
|
||||
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
|
||||
mergedNames := []string{}
|
||||
for _, member := range members {
|
||||
if member.ID != group.kept.ID {
|
||||
mergedNames = append(mergedNames, image.StoredName(member.Path))
|
||||
}
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, a := range articles {
|
||||
for _, name := range mergedNames {
|
||||
if strings.Contains(a.Body, name) {
|
||||
ids = append(ids, a.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func fingerprintOf(groups []purgeGroup) string {
|
||||
var builder strings.Builder
|
||||
for _, group := range groups {
|
||||
builder.WriteString(strconv.FormatUint(uint64(group.kept.ID), 10))
|
||||
builder.WriteString(":")
|
||||
for position, id := range group.mergedIds {
|
||||
if position > 0 {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString(strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
builder.WriteString(";")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -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