feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)
This commit is contained in:
1 parent
1a55a39425
commit
2303b12377
26 files changed
+3856
-83
No files matched your search
@@ -0,0 +1,385 @@
|
||||
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to turn on automatic duplicate purging and to purge duplicates by hand
|
||||
So that identical images are merged without leaving Settings or the Media library
|
||||
|
||||
Background:
|
||||
Given the API of media_purge.feature and media_purge_scheduler.feature is live
|
||||
And every call goes through useApi; 401 is global (/login?redirect=<path>&reason=expired);
|
||||
a 403 on a verified account has no local copy and redirects nowhere
|
||||
And the calls are:
|
||||
| call | body or response |
|
||||
| GET /v1/media/purge-settings | {auto_purge_enabled, auto_purge_interval_days, last_purge_at, last_purge_merged, last_purge_articles_updated, enabled_at, next_purge_at} |
|
||||
| PATCH /v1/media/purge-settings | {"auto_purge_enabled": bool} or {"auto_purge_interval_days": int}, answers the settings above |
|
||||
| GET /v1/media/purge-preview | {groups, copies, articles, list: [{keep_id, filename, url, used_in, merged}], more, fingerprint} |
|
||||
| POST /v1/media/purge | {"fingerprint": "<from the preview>"}, answers {groups_total, groups_purged, merged, articles_updated, failed_groups} |
|
||||
And errors are plain text; 409 carries "The library changed, so nothing was purged. Check the new
|
||||
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
|
||||
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
|
||||
other role is a mocked value in tests
|
||||
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
|
||||
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
|
||||
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
|
||||
drawn in docs/design/source/SettingsFrame.dc.html
|
||||
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
|
||||
UModal, USkeleton, useToast) and design tokens only
|
||||
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
|
||||
open, preview, status (loading, ready, purging, nothing, error, partial, conflict); it emits
|
||||
purged with the counts; the parent shows the toast
|
||||
And plural rules: "1 group" / "N groups", "1 copy" / "N copies", "1 article" / "N articles"
|
||||
And skeleton placeholders show only after 150 ms of loading
|
||||
And a Partial result is a 200 whose failed_groups is above 0 (the board's 207 is only a label)
|
||||
And dates are shown in the browser's timezone, from last_purge_at and next_purge_at in fixtures
|
||||
And no state here is clock-sensitive
|
||||
|
||||
Rule: The Media cleanup card on /settings
|
||||
|
||||
Scenario: Default: off, never run
|
||||
Given GET /v1/media/purge-settings resolves 200 with auto_purge_enabled false,
|
||||
auto_purge_interval_days 30, last_purge_at null, next_purge_at null
|
||||
And the user is the owner
|
||||
When the user opens /settings
|
||||
Then the card titled "Media cleanup" reads "Merge identical images so your library stays tidy.
|
||||
Articles that use a copy are switched to the most used one."
|
||||
And "Automatic purge" with the helper "Runs in the background. Off by default." and a switch that is off
|
||||
And no "Every" row and no "Next purge" line
|
||||
And the line "Never run" is shown
|
||||
And a divider, then "Purge now" with the helper "Merge every group of duplicates that has an image
|
||||
in use, right away." and an outline button "Purge duplicates" with the merge icon
|
||||
And the card has no Save button
|
||||
|
||||
Scenario: On: 30 days, last run
|
||||
Given GET resolves auto_purge_enabled true, auto_purge_interval_days 30,
|
||||
last_purge_at "2026-06-14T09:00:00Z", last_purge_merged 3, last_purge_articles_updated 5,
|
||||
next_purge_at "2026-07-14T09:00:00Z"
|
||||
When the user opens /settings
|
||||
Then the switch is on
|
||||
And "Every" with the helper "Between 1 and 365 days." and a number input showing 30 with the suffix "days"
|
||||
And the lines "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" and "Next purge: 14 Jul 2026"
|
||||
|
||||
Scenario Outline: The Last purge line is pluralised
|
||||
Given last_purge_at is "2026-06-14T09:00:00Z", last_purge_merged <merged> and last_purge_articles_updated <articles>
|
||||
When the card renders
|
||||
Then the line reads "<line>"
|
||||
|
||||
Examples:
|
||||
| merged | articles | line |
|
||||
| 3 | 5 | Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated |
|
||||
| 1 | 1 | Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated |
|
||||
| 0 | 0 | Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated |
|
||||
|
||||
Scenario: Turning the switch on saves at once
|
||||
Given the Default state
|
||||
When the user turns the switch on
|
||||
Then PATCH /v1/media/purge-settings is sent with exactly {"auto_purge_enabled": true}
|
||||
And while it is pending "Saving…" with loader-circle sits in the card header and the controls stay usable
|
||||
When it resolves 200 with auto_purge_interval_days 30 and next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Saving…" is replaced by "Saved" with a check, in the success colour, in the card header
|
||||
And the "Every" row shows 30 and "Next purge: 4 Nov 2026" is shown
|
||||
And "Saved" is announced politely and disappears after 2 s
|
||||
|
||||
Scenario: Turning the switch off saves at once and hides the schedule
|
||||
Given the On state
|
||||
When the user turns the switch off
|
||||
Then PATCH is sent with exactly {"auto_purge_enabled": false}
|
||||
And on 200 with next_purge_at null the "Every" row and the "Next purge" line are hidden
|
||||
And the "Last purge" line is unchanged
|
||||
|
||||
Scenario Outline: The days field saves on blur or Enter, once
|
||||
Given the On state and the days field holds 30
|
||||
When the user types "<typed>" and <commit>
|
||||
Then PATCH is sent exactly once with {"auto_purge_interval_days": <sent>}
|
||||
And on 200 "Saved" shows in the header and "Next purge" follows the response's next_purge_at
|
||||
|
||||
Examples:
|
||||
| typed | commit | sent |
|
||||
| 45 | leaves the field | 45 |
|
||||
| 45 | presses Enter | 45 |
|
||||
| 1 | presses Enter | 1 |
|
||||
| 365 | leaves the field | 365 |
|
||||
|
||||
Scenario: Enter then the resulting blur is one request
|
||||
Given the On state
|
||||
When the user types 45, presses Enter and the field then loses focus
|
||||
Then exactly one PATCH is sent
|
||||
|
||||
Scenario: An unchanged value sends nothing
|
||||
Given the On state and the days field holds 30
|
||||
When the user focuses the field and leaves it without changing it
|
||||
Then no PATCH is sent and no "Saving…" shows
|
||||
|
||||
Scenario Outline: Validation error: nothing is sent
|
||||
Given the On state
|
||||
When the user types "<typed>" and leaves the field or presses Enter
|
||||
Then no PATCH is sent
|
||||
And the field keeps "<typed>" and gets the error border
|
||||
And under the field, with circle-alert, the message "Enter a whole number of days between 1 and 365." shows
|
||||
And no "Saving…" and no "Saved" show
|
||||
|
||||
Examples:
|
||||
| typed |
|
||||
| |
|
||||
| 0 |
|
||||
| 366 |
|
||||
| 400 |
|
||||
| 1.5 |
|
||||
|
||||
Scenario: A validation error clears when the value becomes valid
|
||||
Given the Validation error state with "400"
|
||||
When the user types 60 and presses Enter
|
||||
Then the message disappears and PATCH is sent with {"auto_purge_interval_days": 60}
|
||||
|
||||
Scenario Outline: Save failed: the control reverts
|
||||
Given the On state with 30 days
|
||||
When the user <action> and PATCH answers <failure>
|
||||
Then the control returns to its previous value (<previous>)
|
||||
And "Couldn't save. Try again in a moment." shows under the field (under the "Automatic purge" row for the switch)
|
||||
And no "Saved" shows
|
||||
|
||||
Examples:
|
||||
| action | failure | previous |
|
||||
| types 45 and presses Enter | 500 | 30 |
|
||||
| types 45 and presses Enter | a network error | 30 |
|
||||
| turns the switch off | 500 | on |
|
||||
|
||||
Scenario: The save-failed message goes away on the next successful save
|
||||
Given the Save failed state
|
||||
When the user types 45 and presses Enter and PATCH answers 200
|
||||
Then the failure message is gone and "Saved" shows
|
||||
|
||||
Scenario: Role changed (403 on PATCH)
|
||||
Given the On state for an owner who has been changed to a role that is not owner or admin
|
||||
When the user turns the switch off and PATCH answers 403
|
||||
Then the switch returns to on, with no message, no toast and no redirect
|
||||
And the session is refreshed and the card switches to the Read-only state
|
||||
|
||||
Scenario: Read-only (non-admin)
|
||||
Given the session role is not owner or admin
|
||||
When the user opens /settings
|
||||
Then GET /v1/media/purge-settings is not sent
|
||||
And the switch is off, the days input shows 30 and the line "Never run" shows, as placeholders
|
||||
And the switch and the days input are disabled
|
||||
And an info alert reads "Only owners and admins can change this."
|
||||
And the "Purge now" divider, row and button are not rendered
|
||||
And no PATCH can be sent from the card
|
||||
|
||||
Scenario: Loading skeleton after 150 ms
|
||||
Given GET /v1/media/purge-settings has not resolved
|
||||
When the user opens /settings
|
||||
Then during the first 150 ms the card shows no skeleton
|
||||
And after 150 ms a skeleton replaces the card body (title bar, two text bars, switch row, schedule bar, divider, button row), the card is aria-busy="true" and the button is disabled
|
||||
When it resolves
|
||||
Then the Default or On state replaces it and aria-busy is gone
|
||||
|
||||
Scenario Outline: The settings could not be loaded
|
||||
Given GET /v1/media/purge-settings <failure>
|
||||
When the user opens /settings
|
||||
Then the card shows an error alert "Couldn't reach RenewCMS. Check your connection and try again." with "Try again" (rotate-cw)
|
||||
And the Version and Updates cards are unaffected
|
||||
When the user activates "Try again"
|
||||
Then the loading state shows and GET is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| fails (network) |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
When the user opens /settings
|
||||
Then a banner reads "You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
And the switch, the days input and "Purge duplicates" are disabled (40 % opacity)
|
||||
And nothing is sent when they are activated
|
||||
|
||||
Scenario: 401 is global
|
||||
Given any call of the card answers 401
|
||||
Then the user goes to /login?redirect=/settings&reason=expired and the card shows no copy of its own
|
||||
|
||||
Rule: PurgeDuplicatesDialog opened from "Purge duplicates" in the card
|
||||
|
||||
Background:
|
||||
Given an owner on /settings and the card in the On state
|
||||
|
||||
Scenario: Opening shows the preview loading
|
||||
When the user activates "Purge duplicates"
|
||||
Then GET /v1/media/purge-preview is sent
|
||||
And a dialog opens (UModal, sm:max-w-md, a bottom sheet on mobile) titled "Purge duplicates?" with the description
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
And focus is on the first button, "Cancel"
|
||||
And after 150 ms skeleton rows replace the summary and list, and the confirm button, labelled
|
||||
"Purge duplicates", is disabled
|
||||
|
||||
Scenario: Confirm: summary, up to 5 groups and the link
|
||||
Given the preview is groups 4, copies 9, articles 12, a list of 4 groups, more 0
|
||||
When it resolves
|
||||
Then the summary reads "4 groups · 9 copies will be deleted · 12 articles will be updated"
|
||||
And a bordered list shows a row per group: a 40 x 30 thumbnail of its url, the filename, "Kept · used in N articles"
|
||||
and, right-aligned, "N copies merged"
|
||||
And no "and N more" line
|
||||
And the link "Review in Duplicates" (arrow-right) points to /media?filter=duplicates
|
||||
And the buttons are "Cancel" and the danger confirm "Purge 4 groups"
|
||||
|
||||
Scenario Outline: The summary and rows are pluralised
|
||||
Given the preview is groups <g>, copies <c>, articles <a> with one group kept in <u> article(s) and <m> merged
|
||||
Then the summary reads "<summary>"
|
||||
And the confirm reads "<confirm>"
|
||||
And the row reads "Kept · used in <ku>" and "<km>"
|
||||
|
||||
Examples:
|
||||
| g | c | a | u | m | summary | confirm | ku | km |
|
||||
| 4 | 9 | 12 | 4 | 2 | 4 groups · 9 copies will be deleted · 12 articles will be updated | Purge 4 groups | 4 articles | 2 copies merged |
|
||||
| 1 | 1 | 2 | 2 | 1 | 1 group · 1 copy will be deleted · 2 articles will be updated | Purge 1 group | 2 articles | 1 copy merged |
|
||||
| 1 | 1 | 1 | 1 | 1 | 1 group · 1 copy will be deleted · 1 article will be updated | Purge 1 group | 1 article | 1 copy merged |
|
||||
|
||||
Scenario: Confirm: many groups
|
||||
Given the preview is groups 8, copies 15, articles 24, a list of 5 groups and more 3
|
||||
When it resolves
|
||||
Then exactly 5 rows show, followed by "and 3 more groups"
|
||||
And the confirm reads "Purge 8 groups"
|
||||
|
||||
Scenario: "and 1 more group" is singular
|
||||
Given the preview has more 1
|
||||
Then the line reads "and 1 more group"
|
||||
|
||||
Scenario: Nothing to purge
|
||||
Given the preview is groups 0 with an empty list
|
||||
When it resolves
|
||||
Then the dialog shows an info icon, "Nothing to purge" and "No duplicate is used in an article, so there is nothing to merge."
|
||||
And the only button is "Close"; there is no confirm, no summary and no link
|
||||
And "Close" closes the dialog and sends nothing else
|
||||
|
||||
Scenario: The preview could not be loaded
|
||||
Given GET /v1/media/purge-preview answers 500 or fails
|
||||
Then the dialog shows the error notice "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
And the confirm is disabled and offers no purge until the preview loads again
|
||||
|
||||
Scenario: Cancelling changes nothing
|
||||
Given the Confirm state
|
||||
When the user activates "Cancel", presses Escape or clicks the overlay
|
||||
Then the dialog closes, no POST is sent and focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: The fingerprint goes from the preview to the purge
|
||||
Given the preview carries fingerprint "ab12"
|
||||
When the user activates "Purge 4 groups"
|
||||
Then POST /v1/media/purge is sent once with exactly {"fingerprint": "ab12"}
|
||||
|
||||
Scenario: In progress: the dialog cannot be dismissed
|
||||
Given POST /v1/media/purge has not resolved
|
||||
Then the confirm reads "Purging…" with loader-circle and is disabled
|
||||
And "Cancel" is disabled
|
||||
And Escape, a click on the overlay and a swipe-down do nothing
|
||||
|
||||
Scenario: Success: the dialog closes, a toast shows and Last purge updates in place
|
||||
Given the preview was groups 4, copies 9, articles 12
|
||||
When POST answers 200 with {groups_total 4, groups_purged 4, merged 9, articles_updated 12, failed_groups 0}
|
||||
Then the dialog closes, the dialog emits purged with merged 9 and articles_updated 12
|
||||
And a toast with circle-check shows for 5 s reading "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
And GET /v1/media/purge-settings is sent again
|
||||
And with last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 9, last_purge_articles_updated 12 the line reads
|
||||
"Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated", without a page reload
|
||||
And focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: Success with automatic purge on moves Next purge forward
|
||||
Given automatic purge is on with interval 30
|
||||
When a purge succeeds and the refreshed settings carry next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Next purge: 4 Nov 2026" shows, and with automatic purge off no "Next purge" line shows
|
||||
|
||||
Scenario: The toast is pluralised
|
||||
When POST answers 200 with merged 1, articles_updated 1 and failed_groups 0
|
||||
Then the toast reads "Duplicates purged" / "1 copy merged. 1 article updated."
|
||||
|
||||
Scenario Outline: Error: the dialog stays open and confirm retries
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers <failure>
|
||||
Then an error notice (soft alert, circle-x) reads "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." above the summary
|
||||
And the dialog stays open with the summary and list unchanged, and "Cancel" and "Purge 4 groups" enabled
|
||||
When the user activates "Purge 4 groups" again
|
||||
Then POST is sent again with {"fingerprint": "ab12"}
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Partial: some groups failed
|
||||
Given the preview was 4 groups
|
||||
When POST answers 200 with {groups_total 4, groups_purged 3, merged 8, articles_updated 10, failed_groups 1}
|
||||
Then a warning notice (soft alert, triangle-alert) reads "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish."
|
||||
And GET /v1/media/purge-preview is sent again and the summary and list refresh to the remaining groups
|
||||
And GET /v1/media/purge-settings is sent again so "Last purge" refreshes
|
||||
And the buttons are "Close" and "Retry" (rotate-cw, danger)
|
||||
And no toast shows and the dialog does not close
|
||||
When the user activates "Retry"
|
||||
Then POST is sent with the refreshed fingerprint
|
||||
|
||||
Scenario: Partial that leaves nothing to purge
|
||||
Given a Partial result and the refreshed preview has groups 0
|
||||
Then the dialog shows "Nothing to purge" with "Close"
|
||||
|
||||
Scenario: Conflict (409): the library changed
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers 409 with "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
Then a warning notice reads "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
And GET /v1/media/purge-preview is sent again and the summary refreshes (for example 3 groups · 7 copies · 10 articles)
|
||||
And the buttons are "Cancel" and "Purge 3 groups"
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
When the user activates "Purge 3 groups"
|
||||
Then POST is sent with the new fingerprint
|
||||
|
||||
Scenario: Conflict (409): a purge is already running
|
||||
When POST answers 409 with "A purge is already running."
|
||||
Then a warning notice shows the server's text, the preview refreshes and the dialog stays open
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
Then "Purge duplicates" in the card is disabled and the dialog cannot be opened
|
||||
|
||||
Rule: The Duplicates toolbar button on /media
|
||||
|
||||
Scenario: The button shows on the Duplicates tab for an owner or admin
|
||||
Given the user is owner or admin and /media?filter=duplicates holds at least one duplicate group
|
||||
Then the secondary (outline) button "Purge duplicates" with the merge icon shows after the sort menu
|
||||
and before the view toggle in the toolbar, as MediaFrame.dc.html draws it
|
||||
And on a stacked mobile toolbar it is the last row, full width and 44 px high, under the sort and view row
|
||||
|
||||
Scenario Outline: The button is hidden
|
||||
Given <condition>
|
||||
Then the toolbar has no "Purge duplicates" button
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| the filter is All |
|
||||
| the filter is Unused |
|
||||
| the Duplicates tab has no groups ("No duplicates" shows) |
|
||||
| the session role is not owner or admin |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false on the Duplicates tab
|
||||
Then the button is disabled, the existing offline banner of Media shows and nothing opens
|
||||
|
||||
Scenario: The button opens the same dialog
|
||||
When the user activates "Purge duplicates" on the Duplicates tab
|
||||
Then GET /v1/media/purge-preview is sent and PurgeDuplicatesDialog opens in the loading state
|
||||
And every status, notice, button and request of the previous Rule behaves identically
|
||||
|
||||
Scenario: After a purge from Media the list reloads like after a merge
|
||||
Given the Duplicates tab lists 4 groups
|
||||
When a purge succeeds (200, failed_groups 0)
|
||||
Then the toast "Duplicates purged" / "9 copies merged. 12 articles updated." shows
|
||||
And the list and the tab counts are requested again, as after "Merge into this one"
|
||||
And when no group is left "No duplicates" shows and the button is gone
|
||||
|
||||
Scenario: Partial from Media reloads the list when the dialog closes
|
||||
When the purge answers 200 with failed_groups 1 and the user activates "Close"
|
||||
Then the list and the tab counts are requested again
|
||||
|
||||
Scenario: "Review in Duplicates" from Media only closes the dialog
|
||||
Given the dialog is open on /media?filter=duplicates
|
||||
When the user follows "Review in Duplicates"
|
||||
Then the dialog closes and the route is unchanged
|
||||
|
||||
Scenario: "Merge into this one" is unchanged
|
||||
Given the Duplicates tab lists a group
|
||||
Then each group still shows "Merge into this one" and it still sends POST /v1/media/merge with {keep_id, merge_ids}
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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: '<preview 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 (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.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.
|
||||
Reference in new issue
Block a user