# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button) No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`. Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone. ## Automated SPA (Vitest + Vue Test Utils, `task test:web`) Suggested files, next to the existing view specs: `web/src/views/__tests__/SettingsMediaCleanup.spec.ts`, `web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts`, `web/src/views/__tests__/MediaPurgeEntry.spec.ts`. Reuse `mediaTestKit.ts` and the existing Settings/Media mount helpers. Setup: - Mock `useApi().request` per route and method: GET and PATCH `/v1/media/purge-settings`, GET `/v1/media/purge-preview`, POST `/v1/media/purge`. Assert the exact path, method and JSON body of every call. - Mock the session store: `role: 'owner'`, `role: 'admin'`, and for the non-admin cases a role cast to `UserRole` (for example `'editor' as never`) or undefined. - Fake timers for the 150 ms skeleton, the 2 s "Saved" and the 5 s toast. No test freezes the system date: every date comes from a fixture ISO string with a midday UTC time (for example `2026-06-14T09:00:00Z`), formatted by the SPA. - Offline: set `navigator.onLine` to false and dispatch `offline`, or set `isOffline` from `useApi`. - Reject with `ApiError(status, undefined, body)` and `NetworkError` as in the existing specs. - Fixtures reuse the board data: settings `last 14 Jun 2026 / 3 copies / 5 articles`, `next 14 Jul 2026`; preview 4 groups / 9 copies / 12 articles with floorboards.jpg (4, 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3); many: 8 / 15 / 24 with lime-plaster.jpg (2, 1) and more 3. Cases (one `it` per scenario or Examples row; `it.each` for outlines): Settings card 1. Default: one GET on open, title, description, label, helper, switch off, no "Every", no "Next purge", "Never run", divider, "Purge now" row, button "Purge duplicates", no Save button. 2. On: switch on, "Every" 30 with "days" suffix and helper, both lines; the Last purge pluralisation rows (3/5, 1/1, 0/0). 3. Toggle on: PATCH body is exactly `{ auto_purge_enabled: true }`; "Saving…" while pending; then "Saved" in a polite live region; gone after 2 s; the "Every" row and "Next purge" follow the response. 4. Toggle off: body exactly `{ auto_purge_enabled: false }`; schedule hidden, Last purge kept. 5. Days commit, table-driven (45 blur, 45 Enter, 1 Enter, 365 blur): exactly one PATCH with `{ auto_purge_interval_days: n }`. Enter then blur is one PATCH. Unchanged value sends nothing. 6. Validation, table-driven ("", 0, 366, 400, 1.5): no `request` call, field keeps the typed text, error message "Enter a whole number of days between 1 and 365." under the field, error border; a valid value afterwards clears it and sends. 7. Save failed (PATCH 500, then NetworkError, for the days field and for the toggle): value reverts, the "Couldn't save. Try again in a moment." copy shows in the right place, no "Saved"; a later success clears it. 8. Read-only: role not owner/admin leads to no call to `/v1/media/purge-settings`, placeholders (switch off, 30, "Never run"), disabled switch and input, the info alert "Only owners and admins can change this.", no divider, no "Purge now" row, no button. 9. 403 on PATCH: the control reverts, no toast, no redirect, `session.refresh` is called, and a role that is no longer owner/admin yields the Read-only card. 10. Loading: 149 ms no skeleton, 150 ms skeleton and `aria-busy="true"`, button disabled; resolve and assert both are gone. 11. Settings load failure (500, network): the inline alert, "Try again" reloads. 12. Offline: banner text "You're offline. Changing automatic purge and purging duplicates need a connection.", switch, input and button disabled, nothing sent. 13. 401 on any call: `redirectToLogin` path used, no local copy in the card. Dialog 14. Open: GET preview sent, title and description, focus on "Cancel", skeleton after 150 ms (not before), confirm disabled with the label "Purge duplicates". 15. Confirm: summary, rows (thumbnail src from `url`, filename, "Kept · used in N articles", "N copies merged"), link `href="/media?filter=duplicates"` with "Review in Duplicates", buttons, confirm label "Purge 4 groups". Singular rows (1/1/2, 1/1/1). 16. Many groups: 5 rows then "and 3 more groups"; "and 1 more group" for more 1. 17. Nothing: groups 0 gives "Nothing to purge" copy and a single "Close"; no confirm, summary or link. 18. Cancel, Escape and overlay close without POST and return focus to the opener. 19. Fingerprint: POST body is exactly `{ fingerprint: '' }`, sent once. 20. In progress (POST unresolved): label "Purging…" with the loader icon, confirm and Cancel disabled; Escape, overlay click and a close attempt do nothing. 21. Success: dialog closes, `purged` emitted with `{ merged, articles_updated }`, toast "Duplicates purged" / "9 copies merged. 12 articles updated." with a 5 s duration, a second GET `/v1/media/purge-settings`, "Last purge" and "Next purge" updated without remount, focus back on the opener; singular toast. 22. Error (500 and network): error notice, dialog open, summary unchanged, confirm retries with the same fingerprint, no toast, Last purge unchanged. 23. Partial (200, failed_groups 1, groups_total 4, groups_purged 3): the warning text with "3 of 4", a second GET preview, summary refreshed, settings refetched, buttons "Close" and "Retry", no toast, a Retry POST uses the new fingerprint; a refreshed empty preview gives "Nothing to purge". 24. Conflict (409 with the conflict text): warning, preview refetched, summary and confirm label refreshed, no toast; the next confirm sends the new fingerprint. Busy 409 text shows the server's text. 25. Preview failure: error notice and no purge possible until it loads. 26. Offline: the button is disabled and no dialog opens. Media 27. Button presence table: shown for owner and admin on the Duplicates tab with at least one group; hidden on All, on Unused, with no groups, and for a non-owner/admin role. 28. Offline: disabled, the existing banner shows, nothing opens. 29. DOM order of the toolbar: Sort, then "Purge duplicates", then the view toggle; the button carries the mobile classes for full width and a 44 px height (`h-11`) in the stacked toolbar. 30. Opening from Media: same dialog statuses as cases 14 to 25, driven through the same mocks. 31. After a successful purge: the toast, and the media list and counts are requested again (same assertion helper as the merge success case in `MediaDuplicates.spec.ts`); the last group leaving shows "No duplicates" and removes the button. Partial then "Close" reloads the list. 32. "Review in Duplicates" from Media closes the dialog and leaves the route unchanged. 33. "Merge into this one" regression: the existing merge cases still pass unchanged. Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the other Settings specs must tolerate a second card (any assertion about "no switch or input" is scoped to the Version card). `features/settings_page.feature` wording ("one card") is updated at its next amendment, not in this slice. ## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark) References: `docs/design/screens/settings.md` ("Media cleanup (third card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Back-office.dc.html` and the frames `AppFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable). Board sample data to reuse: - Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z"). - Success fixtures: last 5 Oct 2026, 9 copies, 12 articles. "Success" has automatic purge off (no Next purge); "Success, automatic purge on" has 30 days and next 4 Nov 2026. Both show the toast "Duplicates purged" / "9 copies merged. 12 articles updated." - Preview groups (ids 12, 7, 2, 4, 11): floorboards.jpg (used 4, merged 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3, SVG with alpha), lime-plaster.jpg (2, 1). Default preview: 4 groups, 9 copies, 12 articles with the first four. Many: 8 / 15 / 24 with all five and more 3. Partial: refreshed preview 1 group / 1 copy / 2 articles (IMG_4471.jpg), POST answers 200 `groups_total 4, groups_purged 3, failed_groups 1`. Conflict: POST 409, refreshed preview 3 groups / 7 copies / 10 articles (floorboards.jpg, kitchen-after.webp, logo-renew.svg). - Thumbnails: reuse the thumbnail fixtures the existing media states draw. - Frame values: page max width 720, 20 px between cards; card padding 20 (16 mobile), 16 gap; title 15/24 semibold; switch 36 x 20 with a 44 px hit area on mobile; days input 140 px wide (100 % on mobile), 40 px high (44 mobile), IBM Plex Mono 13 px (16 px on mobile), suffix "days" in mono; rows stack on mobile; "Purge duplicates" is 32 px high (44 mobile, full width); dialog 448 px wide, 140 px from the top on desktop, bottom sheet on mobile with the buttons column-reversed; toast 356 px wide at the bottom right (full width on mobile). Settings screen, group "Settings: Media cleanup": - [ ] Default: off, never run - [ ] On: 30 days, last run - [ ] Saving (PATCH hangs, days field focused) - [ ] Saved - [ ] Save failed - [ ] Validation error (days field holds "400") - [ ] Read-only (non-admin) (mocked role, placeholders, no GET) - [ ] Loading skeleton - [ ] Offline Settings screen, group "Purge duplicates: dialog": - [ ] Loading preview, [ ] Confirm, [ ] Confirm: many groups, [ ] In progress, [ ] Nothing to purge, [ ] Error, [ ] Partial failure, [ ] Conflict (409) Settings screen, group "Results and feedback": - [ ] Success, [ ] Success, automatic purge on (the setup holds the toast open so its 5 s timeout cannot end before the capture) Media screen, group "Media: Duplicates tab entry point": - [ ] Duplicates tab with the button, [ ] Dialog from Media, [ ] Member: button hidden (mocked role), [ ] Offline (button disabled, existing banner copy) Reuse the existing `mediaDuplicatesApi` fixtures for the Duplicates tab and add the purge-preview mock. Not covered by screenshots, check by hand: Tab order (switch, days field, "Purge duplicates"; in the dialog Cancel then confirm then the link); focus ring on the switch and field; Enter and Escape in the dialog; focus return to the opener after close; the polite announcement of "Saving…" then "Saved"; hover states; reduced motion on the skeleton and the loader icons; swipe-down is ignored while purging on a touch device. ## Manual verification (real API, `task dev`) - With an owner, turn automatic purge on, set 7 days, reload /settings: the values persist and "Next purge" matches `next_purge_at`. - Upload two identical images, use the copy in an article, press "Purge duplicates": the preview matches, the purge merges them, the toast shows, "Last purge" updates, the file of the copy is gone from `UPLOAD_DIR`. - Open the dialog, upload another duplicate from a second tab, confirm: the Conflict notice and the refreshed summary show. - Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings and /media?filter=duplicates on a hard refresh, since Vitest never exercises the embedded frontend.