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, Save and SaveLastRun, 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 Then the response status is And the body is 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 with a valid cookie of a verified 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": } Then the response status is 200 and "auto_purge_interval_days" is 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 Then the response status is 400 And the body is 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": "", "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 Then the preview fingerprint is 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": , "articles_updated": , "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 SaveLastRun 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