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

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

No files matched your search

+432
View File
@@ -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
+78
View File
@@ -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).