feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
1 parent 1a55a39425
commit 2303b12377
26 files changed
+3856 -83

No files matched your search

+385
View File
@@ -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}
+102
View File
@@ -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.
+4 -1
View File
@@ -23,6 +23,7 @@ const props = withDefaults(
stickyFooter?: boolean
fullBleed?: boolean
compactPadding?: boolean
bleedOfflineBanner?: boolean
renderWhileChecking?: boolean
offlineMessage?: string
offlineIcon?: string
@@ -36,6 +37,7 @@ const props = withDefaults(
offlineIcon: undefined,
fullBleed: false,
compactPadding: false,
bleedOfflineBanner: false,
renderWhileChecking: false,
},
)
@@ -301,7 +303,8 @@ async function onLogout() {
<OfflineBanner
:message="offlineMessage"
:icon="offlineIcon"
:bleed="compactPadding"
:bleed="compactPadding || bleedOfflineBanner"
:roomy="!compactPadding && bleedOfflineBanner"
/>
<AppBoot v-if="showBoot" />
+388
View File
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
import type { PurgeResult } from '@/composables/useMediaPurge'
import {
DEFAULT_INTERVAL_DAYS,
REVIEW_DUPLICATES_PATH,
lastPurgeLine,
nextPurgeLine,
parseIntervalDays,
purgedToast,
} from '@/lib/mediaPurge'
import { useSessionStore } from '@/stores/session'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
const READ_ONLY_COPY = 'Only owners and admins can change this.'
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
const session = useSessionStore()
const toast = useLocalizedToast()
const router = useRouter()
const online = useOnline()
const isAdmin = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const readOnly = computed(() => !isAdmin.value)
const offline = computed(() => !online.value)
const cleanup = useMediaCleanupSettings(() => {
session.refresh()
})
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
const enabled = ref(false)
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
const daysInvalid = ref(false)
const daysFocused = ref(false)
const purgeOpen = ref(false)
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
const controlsDisabled = computed(() => readOnly.value || offline.value)
const switchChecked = computed(() => !readOnly.value && enabled.value)
const showEvery = computed(() => readOnly.value || enabled.value)
const lastLine = computed(() =>
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
)
const nextIso = computed(() =>
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
)
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
const fieldError = computed(() => daysError.value || daysSaveError.value)
watch(settings, (next) => {
if (!next) {
return
}
enabled.value = next.auto_purge_enabled
committedDays.value = next.auto_purge_interval_days
if (!daysInvalid.value && !daysFocused.value) {
daysText.value = String(next.auto_purge_interval_days)
}
})
watch(
isAdmin,
(admin) => {
if (admin) {
cleanup.load()
}
},
{ immediate: true },
)
async function toggle(value: boolean) {
if (controlsDisabled.value) {
return
}
const previous = enabled.value
enabled.value = value
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
if (!saved) {
enabled.value = previous
}
}
async function commitDays() {
if (controlsDisabled.value) {
return
}
const days = parseIntervalDays(daysText.value)
if (days === null) {
daysInvalid.value = true
return
}
daysInvalid.value = false
if (days === committedDays.value) {
daysText.value = String(days)
return
}
const previous = committedDays.value
committedDays.value = days
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
if (!saved) {
committedDays.value = previous
daysText.value = String(previous)
}
}
function onDaysBlur() {
daysFocused.value = false
commitDays()
}
function openPurge() {
if (!offline.value) {
purgeOpen.value = true
}
}
async function closePurge() {
purgeOpen.value = false
await nextTick()
purgeButton.value?.$el?.focus()
}
function onPurged(result: PurgeResult) {
toast.add(purgedToast(result))
cleanup.refresh()
}
function reviewDuplicates() {
purgeOpen.value = false
router.push(REVIEW_DUPLICATES_PATH)
}
</script>
<template>
<section
aria-labelledby="media-cleanup-h"
data-testid="media-cleanup-card"
:aria-busy="skeletonVisible ? 'true' : undefined"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
>
<template v-if="skeletonVisible">
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1.5">
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
</div>
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
</div>
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
<USeparator />
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1.5">
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
</div>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-merge"
disabled
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-4' }"
>
{{ t('Purge duplicates') }}
</UButton>
</div>
</template>
<template v-else>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
{{ t('Media cleanup') }}
</h2>
<p class="text-pretty text-muted">
{{
t(
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
)
}}
</p>
</div>
<span
aria-live="polite"
data-testid="cleanup-save-status"
class="flex h-6 shrink-0 items-center"
>
<span
v-if="saveStatus === 'saving'"
class="inline-flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
{{ t('Saving…') }}
</span>
<span
v-else-if="saveStatus === 'saved'"
class="inline-flex items-center gap-1.5 text-xs text-success"
>
<UIcon name="i-lucide-check" class="size-3.5" />
{{ t('Saved') }}
</span>
</span>
</div>
<UAlert
v-if="loadFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="cleanup.load"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="showBody">
<UAlert
v-if="readOnly"
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:description="t(READ_ONLY_COPY)"
/>
<div
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span id="auto-purge-label" class="font-medium text-highlighted">{{
t('Automatic purge')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Runs in the background. Off by default.')
}}</span>
<span
v-if="switchSaveError"
role="alert"
data-testid="cleanup-switch-error"
class="flex items-start gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
{{ switchSaveError }}
</span>
</div>
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
<USwitch
:model-value="switchChecked"
:disabled="controlsDisabled"
aria-labelledby="auto-purge-label"
:ui="{ base: 'data-[disabled]:opacity-40' }"
@update:model-value="toggle"
/>
</div>
</div>
<div
v-if="showEvery"
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<label for="auto-purge-days" class="font-medium text-highlighted">{{
t('Every')
}}</label>
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
</div>
<div class="flex flex-col gap-1.5 md:items-end">
<UInput
id="auto-purge-days"
v-model="daysText"
type="text"
inputmode="numeric"
:disabled="controlsDisabled"
:highlight="fieldError !== ''"
:color="fieldError ? 'error' : 'neutral'"
:aria-invalid="fieldError ? 'true' : undefined"
class="w-full md:w-35"
:ui="{
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
trailing: 'pe-3',
}"
@focus="daysFocused = true"
@blur="onDaysBlur"
@keydown.enter="commitDays"
>
<template #trailing>
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
</template>
</UInput>
<span
v-if="fieldError"
role="alert"
data-testid="cleanup-days-error"
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
>
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
{{ fieldError }}
</span>
</div>
</div>
<div class="flex flex-col gap-1 text-toned">
<span data-testid="cleanup-last">{{ lastLine }}</span>
<span v-if="nextIso" data-testid="cleanup-next">{{
nextPurgeLine(nextIso)
}}</span>
</div>
<template v-if="!readOnly">
<USeparator />
<div
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
<span class="text-xs/5 text-muted">{{
t(
'Merge every group of duplicates that has an image in use, right away.',
)
}}</span>
</div>
<div class="flex md:shrink-0">
<UButton
ref="purgeButton"
color="neutral"
variant="outline"
icon="i-lucide-merge"
:disabled="offline"
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-4' }"
@click="openPurge"
>
{{ t('Purge duplicates') }}
</UButton>
</div>
</div>
</template>
</template>
</template>
<PurgeDuplicatesDialog
v-if="purgeOpen"
@close="closePurge"
@purged="onPurged"
@partial="cleanup.refresh"
@review="reviewDuplicates"
/>
</section>
</template>
+27 -5
View File
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
},
]
const props = defineProps<{
controls: MediaControls
counts: MediaCounts | null
}>()
const props = withDefaults(
defineProps<{
controls: MediaControls
counts: MediaCounts | null
showPurge?: boolean
purgeDisabled?: boolean
}>(),
{ showPurge: false, purgeDisabled: false },
)
const emit = defineEmits<{
'update:q': [value: string]
'update:filter': [value: MediaFilter]
'update:sort': [value: MediaSort]
'update:view': [value: MediaView]
purge: []
}>()
const searchText = ref(props.controls.q)
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
</template>
</UInput>
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
<div
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
>
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
<UButton
color="neutral"
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
</UButton>
</UDropdownMenu>
<UButton
v-if="showPurge"
data-testid="purge-duplicates"
color="neutral"
variant="outline"
icon="i-lucide-merge"
:disabled="purgeDisabled"
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('purge')"
>
{{ t('Purge duplicates') }}
</UButton>
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
+9 -4
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
const BLEED_UI = {
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
@@ -14,15 +16,18 @@ const BLEED_UI = {
title: 'whitespace-normal font-normal text-wrap',
}
withDefaults(
const props = withDefaults(
defineProps<{
message?: string
icon?: string
bleed?: boolean
roomy?: boolean
}>(),
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
)
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
@@ -46,6 +51,6 @@ onUnmounted(() => {
color="neutral"
:icon="icon"
:title="translate(message)"
:ui="bleed ? BLEED_UI : undefined"
:ui="bleed ? bleedUi : undefined"
/>
</template>
@@ -0,0 +1,375 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
import { ApiError } from '@/composables/useApi'
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
import {
PURGE_BUSY_COPY,
PURGE_STALE_COPY,
REVIEW_DUPLICATES_PATH,
isForbidden,
isHandledGlobally,
keptLine,
mergedCopies,
moreGroupsLine,
pluralGroups,
previewSummary,
thumbnailSource,
} from '@/lib/mediaPurge'
import { useSessionStore } from '@/stores/session'
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
const SKELETON_DELAY_MS = 150
const SKELETON_ROWS = 3
const HTTP_CONFLICT = 409
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
const 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."
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
const PARTIAL_COPY =
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const MODAL_CONTENT =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
const emit = defineEmits<{
close: []
purged: [result: PurgeResult]
partial: [result: PurgeResult]
review: []
}>()
const { getPreview, purge } = useMediaPurge()
const session = useSessionStore()
const open = ref(true)
const status = ref<Status>('loading')
const notice = ref<Notice | null>(null)
const preview = ref<PurgePreview | null>(null)
const partialCounts = ref({ purged: 0, total: 0 })
const refreshing = ref(false)
const showSkeleton = ref(false)
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
const purging = computed(() => status.value === 'purging')
const busy = computed(() => purging.value || refreshing.value)
const nothing = computed(() => status.value === 'nothing')
const partial = computed(() => status.value === 'partial')
const waitingForPreview = computed(() => status.value === 'loading')
const canConfirm = computed(() => preview.value !== null && !busy.value)
const confirmLabel = computed(() => {
if (purging.value) {
return t('Purging…')
}
return preview.value
? t('Purge {0}', [pluralGroups(preview.value.groups)])
: t('Purge duplicates')
})
const noticeText = computed(() => {
if (notice.value === 'partial') {
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
}
return {
failed: t(FAILED_COPY),
stale: t(PURGE_STALE_COPY),
busy: t(PURGE_BUSY_COPY),
none: '',
}[notice.value ?? 'none']
})
const noticeIsError = computed(() => notice.value === 'failed')
const ui = computed(() => ({
content: MODAL_CONTENT,
header: nothing.value
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: nothing.value
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}))
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function giveUpAfterAuthFailure(error: unknown) {
if (isForbidden(error)) {
session.refresh()
close()
}
}
function applyPreview(next: PurgePreview) {
preview.value = next
if (next.groups === 0) {
status.value = 'nothing'
notice.value = null
}
}
async function loadPreview() {
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const first = await getPreview()
applyPreview(first)
if (first.groups > 0) {
status.value = 'ready'
}
} catch (error) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
return
}
status.value = 'error'
notice.value = 'failed'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
async function refreshPreview() {
refreshing.value = true
try {
applyPreview(await getPreview())
} catch (error) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
}
} finally {
refreshing.value = false
}
}
function close() {
open.value = false
emit('close')
}
async function settle(result: PurgeResult) {
if (result.failed_groups === 0) {
emit('purged', result)
close()
return
}
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
notice.value = 'partial'
status.value = 'partial'
emit('partial', result)
await refreshPreview()
}
async function fail(error: unknown) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
return
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
status.value = 'conflict'
await refreshPreview()
return
}
notice.value = 'failed'
status.value = 'error'
}
async function confirm() {
if (!preview.value || busy.value) {
return
}
const { fingerprint } = preview.value
status.value = 'purging'
try {
await settle(await purge(fingerprint))
} catch (error) {
await fail(error)
}
}
function onOpenChange(value: boolean) {
if (!value && busy.value) {
return
}
if (!value) {
close()
}
}
function onCancel() {
if (!busy.value) {
close()
}
}
function onReview(event: MouseEvent) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return
}
event.preventDefault()
emit('review')
}
onMounted(async () => {
loadPreview()
await nextTick()
cancelButton.value?.$el?.focus()
})
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<UModal
:open="open"
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
:dismissible="!busy"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
:ui="ui"
@update:open="onOpenChange"
>
<template v-if="nothing" #title>
<span class="flex flex-col items-center gap-3">
<span
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
>
<UIcon name="i-lucide-copy" class="size-5" />
</span>
{{ t('Nothing to purge') }}
</span>
</template>
<template v-if="!nothing" #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="notice && noticeText"
:color="noticeIsError ? 'error' : 'warning'"
variant="soft"
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
:description="noticeText"
:close="false"
role="alert"
/>
<template v-if="waitingForPreview && showSkeleton">
<USkeleton
data-testid="purge-skeleton"
class="h-4 w-4/5 rounded-none bg-accented"
/>
<div class="border border-default">
<div
v-for="row in SKELETON_ROWS"
:key="row"
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
>
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
</div>
</div>
</div>
</template>
<template v-if="preview">
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
previewSummary(preview)
}}</span>
<ul data-testid="purge-groups" class="border border-default">
<li
v-for="group in preview.list"
:key="group.keep_id"
data-testid="purge-group"
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
>
<MediaThumb
:item="thumbnailSource(group.url, group.filename)"
size="sm"
class="h-7.5 w-10 border border-default"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium text-highlighted">{{
group.filename
}}</span>
<span class="text-xs/5 text-muted">{{
keptLine(group.used_in)
}}</span>
</div>
<span class="text-xs whitespace-nowrap text-toned">{{
mergedCopies(group.merged)
}}</span>
</li>
<li
v-if="preview.more > 0"
data-testid="purge-more"
class="border-t border-muted px-3 py-2 text-muted"
>
{{ moreGroupsLine(preview.more) }}
</li>
</ul>
<a
:href="REVIEW_DUPLICATES_PATH"
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
@click="onReview"
>
{{ t('Review in Duplicates') }}
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
</a>
</template>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
:class="[
BUTTON_BASE,
'text-highlighted disabled:text-dimmed disabled:opacity-100',
]"
:disabled="busy"
@click="onCancel"
>
{{ nothing || partial ? t('Close') : t('Cancel') }}
</UButton>
<UButton
v-if="partial"
color="error"
icon="i-lucide-rotate-cw"
block
:class="BUTTON_BASE"
:disabled="!canConfirm"
@click="confirm"
>
{{ t('Retry') }}
</UButton>
<UButton
v-else-if="!nothing"
color="error"
:loading="purging"
block
:class="BUTTON_BASE"
:disabled="!canConfirm"
@click="confirm"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,129 @@
import { onBeforeUnmount, ref } from 'vue'
import {
useMediaPurge,
type PurgeSettings,
type PurgeSettingsPatch,
} from '@/composables/useMediaPurge'
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
export type CleanupLoadState = 'loading' | 'ready' | 'error'
export type SaveStatus = 'idle' | 'saving' | 'saved'
export type SaveFailure = 'enabled' | 'days'
const SKELETON_DELAY_MS = 150
const SAVED_VISIBLE_MS = 2000
export function useMediaCleanupSettings(onForbidden: () => void) {
const { getSettings, updateSettings } = useMediaPurge()
const settings = ref<PurgeSettings | null>(null)
const loadState = ref<CleanupLoadState>('loading')
const showSkeleton = ref(false)
const saveStatus = ref<SaveStatus>('idle')
const saveFailure = ref<SaveFailure | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let savedTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
let pendingSaves = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
async function load() {
latestLoad += 1
const ticket = latestLoad
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const loaded = await getSettings()
if (ticket === latestLoad) {
settings.value = loaded
loadState.value = 'ready'
}
} catch (error) {
if (ticket !== latestLoad) {
return
}
if (isHandledGlobally(error)) {
if (isForbidden(error)) {
onForbidden()
}
return
}
loadState.value = 'error'
} finally {
if (ticket === latestLoad) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
async function refresh() {
try {
settings.value = await getSettings()
} catch (error) {
if (isForbidden(error)) {
onForbidden()
}
}
}
function finishSaving(succeeded: boolean) {
if (pendingSaves > 0) {
return
}
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
pendingSaves += 1
clearSavedTimer()
saveStatus.value = 'saving'
let succeeded = false
try {
settings.value = await updateSettings(patch)
saveFailure.value = null
succeeded = true
} catch (error) {
if (isForbidden(error)) {
onForbidden()
} else if (!isHandledGlobally(error)) {
saveFailure.value = field
}
} finally {
pendingSaves -= 1
finishSaving(succeeded)
}
return succeeded
}
onBeforeUnmount(() => {
clearSkeletonTimer()
clearSavedTimer()
})
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
}
+78
View File
@@ -0,0 +1,78 @@
import { useApi } from '@/composables/useApi'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface PurgeSettings {
auto_purge_enabled: boolean
auto_purge_interval_days: number
last_purge_at: string | null
last_purge_merged: number
last_purge_articles_updated: number
enabled_at: string | null
next_purge_at: string | null
}
export type PurgeSettingsPatch =
| { auto_purge_enabled: boolean }
| { auto_purge_interval_days: number }
export interface PurgePreviewGroup {
keep_id: number
filename: string
url: string
used_in: number
merged: number
}
export interface PurgePreview {
groups: number
copies: number
articles: number
list: PurgePreviewGroup[]
more: number
fingerprint: string
}
export interface PurgeResult {
groups_total: number
groups_purged: number
merged: number
articles_updated: number
failed_groups: number
}
const SETTINGS_PATH = '/v1/media/purge-settings'
const PREVIEW_PATH = '/v1/media/purge-preview'
const PURGE_PATH = '/v1/media/purge'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useMediaPurge() {
const { request } = useApi()
function getSettings() {
return request<PurgeSettings>(SETTINGS_PATH)
}
function updateSettings(patch: PurgeSettingsPatch) {
return request<PurgeSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify(patch),
})
}
function getPreview() {
return request<PurgePreview>(PREVIEW_PATH)
}
async function purge(fingerprint: string) {
const result = await request<PurgeResult>(PURGE_PATH, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ fingerprint }),
})
bumpMediaRevision()
return result
}
return { getSettings, updateSettings, getPreview, purge }
}
+49
View File
@@ -955,6 +955,55 @@ export const messages = {
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
'Media cleanup': 'Nettoyage des médias',
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
'Automatic purge': 'Purge automatique',
'Runs in the background. Off by default.': 'S’exécute en arrière-plan. Désactivée par défaut.',
Every: 'Tous les',
'Between 1 and 365 days.': 'Entre 1 et 365 jours.',
days: 'jours',
'Never run': 'Jamais exécutée',
'Last purge: {0} · {1} merged · {2} updated':
'Dernière purge : {0} · {1} fusionnées · {2} mis à jour',
'Next purge: {0}': 'Prochaine purge : {0}',
"Couldn't save. Try again in a moment.": 'Impossible d’enregistrer. Réessayez dans un instant.',
'Enter a whole number of days between 1 and 365.':
'Saisissez un nombre entier de jours entre 1 et 365.',
'Only owners and admins can change this.':
'Seuls les propriétaires et les administrateurs peuvent modifier ceci.',
'Purge now': 'Purger maintenant',
'Merge every group of duplicates that has an image in use, right away.':
'Fusionnez tout de suite chaque groupe de doublons dont une image est utilisée.',
'Purge duplicates': 'Purger les doublons',
"You're offline. Changing automatic purge and purging duplicates need a connection.":
'Vous êtes hors ligne. Modifier la purge automatique et purger les doublons nécessitent une connexion.',
'Purge duplicates?': 'Purger les doublons ?',
"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.":
'Les articles qui utilisent un doublon passeront à la copie la plus utilisée de cette image, et les autres copies seront supprimées. Cette action est irréversible.',
'1 group': '1 groupe',
'{0} groups': '{0} groupes',
'{0} · {1} will be deleted · {2} will be updated':
'{0} · {1} seront supprimées · {2} seront mis à jour',
'Kept · used in {0}': 'Conservée · utilisée dans {0}',
'{0} merged': '{0} fusionnées',
'and 1 more group': 'et 1 autre groupe',
'and {0} more groups': 'et {0} autres groupes',
'Review in Duplicates': 'Voir dans Doublons',
'Purge {0}': 'Purger {0}',
'Purging…': 'Purge…',
Close: 'Fermer',
'Nothing to purge': 'Rien à purger',
'No duplicate is used in an article, so there is nothing to merge.':
'Aucun doublon n’est utilisé dans un article, il n’y a donc rien à fusionner.',
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.":
'Impossible de purger les doublons. Rien n’a été modifié. Réessayez dans un instant.',
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish.":
'Certains groupes n’ont pas pu être fusionnés. {0} groupes sur {1} ont été purgés. Réessayez pour terminer.',
'The library changed, so nothing was purged. Check the new summary and try again.':
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
'A purge is already running.': 'Une purge est déjà en cours.',
'Duplicates purged': 'Doublons purgés',
} as const
export type MessageKey = keyof typeof messages
+107
View File
@@ -0,0 +1,107 @@
import { t } from '@/i18n'
import { ApiError } from '@/composables/useApi'
import type { PurgeSettings } from '@/composables/useMediaPurge'
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
export const MIN_INTERVAL_DAYS = 1
export const MAX_INTERVAL_DAYS = 365
export const DEFAULT_INTERVAL_DAYS = 30
export const PURGE_TOAST_DURATION_MS = 5000
export const REVIEW_DUPLICATES_PATH = '/media?filter=duplicates'
export const PURGE_STALE_COPY =
'The library changed, so nothing was purged. Check the new summary and try again.'
export const PURGE_BUSY_COPY = 'A purge is already running.'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const WHOLE_NUMBER = /^\d+$/
const SVG_EXTENSION = /\.svg$/i
const PNG_EXTENSION = /\.png$/i
export function isForbidden(error: unknown) {
return error instanceof ApiError && error.status === HTTP_FORBIDDEN
}
export function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === HTTP_UNAUTHORIZED || isForbidden(error))
}
export function parseIntervalDays(text: string) {
const trimmed = text.trim()
if (!WHOLE_NUMBER.test(trimmed)) {
return null
}
const days = Number.parseInt(trimmed, 10)
return days >= MIN_INTERVAL_DAYS && days <= MAX_INTERVAL_DAYS ? days : null
}
export function pluralCopies(count: number) {
return t(count === 1 ? '1 copy' : '{0} copies', [count])
}
export function pluralGroups(count: number) {
return t(count === 1 ? '1 group' : '{0} groups', [count])
}
export function mergedCopies(count: number) {
return t('{0} merged', [pluralCopies(count)])
}
export function lastPurgeLine(settings: PurgeSettings | null) {
if (!settings?.last_purge_at) {
return t('Never run')
}
return t('Last purge: {0} · {1} merged · {2} updated', [
formatMediaDate(settings.last_purge_at),
pluralCopies(settings.last_purge_merged ?? 0),
pluralArticles(settings.last_purge_articles_updated ?? 0),
])
}
export function nextPurgeLine(iso: string) {
return t('Next purge: {0}', [formatMediaDate(iso)])
}
export function previewSummary(counts: { groups: number; copies: number; articles: number }) {
return t('{0} · {1} will be deleted · {2} will be updated', [
pluralGroups(counts.groups),
pluralCopies(counts.copies),
pluralArticles(counts.articles),
])
}
export function keptLine(usedIn: number) {
return t('Kept · used in {0}', [pluralArticles(usedIn)])
}
export function moreGroupsLine(more: number) {
return t(more === 1 ? 'and 1 more group' : 'and {0} more groups', [more])
}
export function thumbnailSource(url: string, filename: string) {
let contentType = ''
if (SVG_EXTENSION.test(filename)) {
contentType = 'image/svg+xml'
} else if (PNG_EXTENSION.test(filename)) {
contentType = 'image/png'
}
return { url, content_type: contentType, sha256: '' }
}
export function purgedToast(result: { merged: number; articles_updated: number }) {
return {
id: 'media-purged',
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return t('Duplicates purged')
},
get description() {
return t('{0} merged. {1} updated.', [
pluralCopies(result.merged),
pluralArticles(result.articles_updated),
])
},
duration: PURGE_TOAST_DURATION_MS,
}
}
+52
View File
@@ -21,6 +21,7 @@ import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import { ApiError } from '@/composables/useApi'
import {
@@ -32,6 +33,7 @@ import {
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
@@ -41,7 +43,9 @@ import {
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
@@ -71,6 +75,7 @@ const OFFLINE_COPY =
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
@@ -91,12 +96,14 @@ const deleteOpen = ref(false)
const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
const purgeOpen = ref(false)
const dragDepth = ref(0)
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let purgeChangedList = false
let skipFocusRestore = false
let pendingSelectId: number | null = null
let bulkDeletedTotal = 0
@@ -138,6 +145,12 @@ const filterLabel = computed(
],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const canPurge = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const showPurge = computed(
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
@@ -150,6 +163,7 @@ const dialogOpen = computed(
renameOpen.value ||
deleteOpen.value ||
bulkDeleteOpen.value ||
purgeOpen.value ||
mergeTarget.value !== null,
)
const hasSelection = computed(() => selection.count.value > 0)
@@ -724,6 +738,33 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
mergeTarget.value = { group, keep }
}
function openPurge(event?: Event) {
if (offline.value) {
return
}
opener = (event?.currentTarget as HTMLElement | null) ?? null
purgeChangedList = false
purgeOpen.value = true
}
function onPurged(result: PurgeResult) {
toast.add(purgedToast(result))
purgeChangedList = true
}
function onPurgePartial() {
purgeChangedList = true
}
function closePurge() {
purgeOpen.value = false
if (purgeChangedList) {
purgeChangedList = false
refreshInBackground()
}
restoreFocusToOpener(null)
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
const articlesText =
@@ -873,10 +914,13 @@ async function mergeAction() {
<MediaToolbar
:controls="controls"
:counts="counts"
:show-purge="showPurge"
:purge-disabled="offline"
@update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })"
@update:view="updateControls({ view: $event })"
@purge="openPurge"
/>
<MediaUploadPanel
@@ -1022,6 +1066,14 @@ async function mergeAction() {
@close="bulkDeleteOpen = false"
/>
<PurgeDuplicatesDialog
v-if="purgeOpen"
@close="closePurge"
@purged="onPurged"
@partial="onPurgePartial"
@review="purgeOpen = false"
/>
<MergeMediaDialog
v-if="mergeTarget"
:keep="mergeTarget.keep"
+10 -1
View File
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
import UpdatesCard from '@/components/UpdatesCard.vue'
import { ApiError } from '@/composables/useApi'
@@ -19,6 +20,8 @@ import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '
const SKELETON_DELAY_MS = 150
const POLL_INTERVAL_MS = 1000
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const OFFLINE_COPY =
"You're offline. Changing automatic purge and purging duplicates need a connection."
const RESTART_FAILED_COPY =
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
const RESTART_WARNING =
@@ -372,7 +375,11 @@ onBeforeUnmount(() => {
</script>
<template>
<DashboardShell>
<DashboardShell
bleed-offline-banner
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<RestartOverlay
v-if="update.restartRun"
:kind="update.restartRun.kind"
@@ -449,6 +456,8 @@ onBeforeUnmount(() => {
@update="onUpdate"
@restart="onRestart"
/>
<MediaCleanupCard />
</div>
</DashboardShell>
</template>
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
deferred,
dialog,
findButton,
fixture,
listBody,
listCalls,
mountPage,
settle,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DUPLICATES = '/media?filter=duplicates'
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
: listBody(fixture())
}
function purgeButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
}
async function openFromMedia(handlers: Handlers = {}) {
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
purgeButton()!.focus()
purgeButton()!.click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The Duplicates toolbar button', () => {
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
expect(purgeButton()).not.toBeNull()
expect(textOf(purgeButton())).toBe('Purge duplicates')
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
})
it.each([
['the filter is All', '/media', 'owner', duplicatesList()],
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
])('is hidden when %s', async (_name, url, role, list) => {
await mountPage({ list }, url, undefined, { role })
expect(purgeButton()).toBeNull()
})
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
purgeButton()!.click()
await flushPromises()
expect(purgeButton()!.disabled).toBe(true)
expect(document.body.textContent).toContain("You're offline.")
expect(dialog()).toBeNull()
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
})
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
const sort = document.body.querySelector('[aria-label="Sort"]')!
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
const button = purgeButton()!
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.className).toContain('h-11')
expect(button.className).toContain('w-full')
})
})
describe('The purge dialog from Media', () => {
it('opens in the loading state and sends the preview request', async () => {
const pending = deferred<unknown>()
await openFromMedia({ preview: () => pending.promise })
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
expect(textOf(dialog())).toContain('Purge duplicates?')
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
})
it('confirms with the preview fingerprint and shows the same summary', async () => {
await openFromMedia()
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
'4 groups · 9 copies will be deleted · 12 articles will be updated',
)
findButton('Purge 4 groups', dialog()!)!.click()
await flushPromises()
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
})
it('shows the error notice and keeps the dialog open on a failure', async () => {
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
})
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
let purged = false
await openFromMedia({
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
all: 12,
unused: 2,
duplicates: purged ? 0 : 2,
})
: listBody(fixture()),
purge: () => {
purged = true
return purgeResult()
},
})
const reloads = listCalls().length
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(listCalls().length).toBe(reloads + 1)
expect(document.body.textContent).toContain('No duplicates')
expect(purgeButton()).toBeNull()
})
it('reloads the list when Close follows a partial result', async () => {
await openFromMedia({
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(toasts.add).not.toHaveBeenCalled()
const reloads = listCalls().length
findButton('Close', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(listCalls().length).toBe(reloads + 1)
})
it('only closes the dialog when Review in Duplicates is followed', async () => {
await openFromMedia()
const before = router.currentRoute.value.fullPath
dialog()!.querySelector('a')!.click()
await settle()
expect(dialog()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe(before)
})
})
describe('Merge into this one stays', () => {
it('still sends the merge request with the ids', async () => {
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
const first = findButton('Merge into this one')!
first.click()
await flushPromises()
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
})
})
@@ -0,0 +1,529 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import {
BUSY_COPY,
FAILED_COPY,
PREVIEW_PATH,
PURGE_PATH,
SETTINGS_PATH,
STALE_COPY,
bodyOf,
callsTo,
deferredValue,
dialogEl,
emptyPreview,
enabledSettings,
findButton,
goOffline,
groupRows,
isBefore,
lastLine,
manyPreview,
mountSettings,
nextLine,
openDialog,
previewOf,
pressEscape,
purgeButton,
purgeResult,
singlePreview,
textOf,
unmountSettings,
type PurgeMocks,
} from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const 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."
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
async function opened(mocks: PurgeMocks = {}) {
await mountSettings({ settings: () => enabledSettings(), ...mocks })
return openDialog()
}
function buttonLabels() {
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
}
function confirmButton(label: string) {
return findButton(label, dialogEl()!)!
}
function noticeText() {
return textOf(dialogEl()!.querySelector('[role="alert"]'))
}
function afterRefresh(settings = enabledSettings()) {
let reads = 0
return () => (reads++ === 0 ? enabledSettings() : settings)
}
function purgedSettings(overrides = {}) {
return enabledSettings({
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
next_purge_at: '2026-11-04T09:00:00Z',
...overrides,
})
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Purge dialog: opening', () => {
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await opened({ preview: () => pending.promise })
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
const box = dialogEl()!
expect(textOf(box)).toContain('Purge duplicates?')
expect(textOf(box)).toContain(DESCRIPTION)
expect(document.activeElement).toBe(findButton('Cancel', box))
vi.advanceTimersByTime(149)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
})
it('is not openable while offline', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
purgeButton().click()
await flushPromises()
expect(purgeButton().disabled).toBe(true)
expect(dialogEl()).toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: the preview', () => {
it('shows the summary, the rows, the link and both buttons', async () => {
await opened()
const box = dialogEl()!
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
const rows = groupRows()
expect(rows).toHaveLength(4)
expect(
rows.map((row) =>
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
),
).toEqual([
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
])
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
const link = box.querySelector('a')!
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
expect(textOf(link)).toBe('Review in Duplicates')
expect(link.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
})
it.each([
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
[
singlePreview(2, 1, 2),
'1 group · 1 copy will be deleted · 2 articles will be updated',
'Purge 1 group',
'Kept · used in 2 articles',
'1 copy merged',
],
[
singlePreview(1, 1, 1),
'1 group · 1 copy will be deleted · 1 article will be updated',
'Purge 1 group',
'Kept · used in 1 article',
'1 copy merged',
],
])(
'pluralises the summary, the confirm and the rows',
async (preview, summary, confirm, kept, merged) => {
await opened({ preview: () => preview })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
expect(buttonLabels()).toEqual(['Cancel', confirm])
const row = textOf(groupRows()[0])
expect(row).toContain(kept)
expect(row).toContain(merged)
},
)
it('stops at 5 rows and says how many groups are left', async () => {
await opened({ preview: manyPreview })
expect(groupRows()).toHaveLength(5)
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 3 more groups',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
})
it('says "and 1 more group" for one', async () => {
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 1 more group',
)
})
it('shows Nothing to purge with a single Close button', async () => {
await opened({ preview: emptyPreview })
const box = dialogEl()!
expect(textOf(box)).toContain('Nothing to purge')
expect(textOf(box)).toContain(
'No duplicate is used in an article, so there is nothing to merge.',
)
expect(box.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Close'])
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
expect(box.querySelector('a')).toBeNull()
findButton('Close', box)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
await opened({ preview })
expect(noticeText()).toBe(FAILED_COPY)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
confirmButton('Purge duplicates').click()
await flushPromises()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: leaving', () => {
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
findButton('Cancel', dialogEl()!)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
pressEscape(dialogEl())
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with a click on the overlay and returns focus to the opener', async () => {
const opener = await opened()
const overlay = document.body.querySelector('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('follows the review link to the Duplicates tab', async () => {
await opened()
dialogEl()!.querySelector('a')!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
})
})
describe('Purge dialog: purging', () => {
it('sends exactly the fingerprint of the preview, once', async () => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
})
it('cannot be dismissed while the request runs', async () => {
const pending = deferredValue<unknown>()
await opened({ purge: () => pending.promise })
confirmButton('Purge 4 groups').click()
await flushPromises()
const confirm = confirmButton('Purging…')
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
pressEscape(dialogEl())
document.body
.querySelector('[data-slot="overlay"]')
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
await flushPromises()
expect(dialogEl()).not.toBeNull()
pending.resolve(purgeResult())
await flushPromises()
expect(dialogEl()).toBeNull()
})
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(toasts.add).toHaveBeenCalledTimes(1)
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
expect(document.activeElement).toBe(opener)
})
it('shows no Next purge when automatic purge is off', async () => {
await opened({
settings: afterRefresh(
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(nextLine()).toBeNull()
})
it('pluralises the toast', async () => {
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
)
})
it('emits purged with the counts and then closes', async () => {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn(async (path: string) =>
path === PREVIEW_PATH ? previewOf() : purgeResult(),
),
})
const wrapper = mount(PurgeDuplicatesDialog, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
})
})
describe('Purge dialog: failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])(
'keeps the dialog open after %s and retries with the same fingerprint',
async (_name, purge) => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
const alert = box.querySelector('[role="alert"]')!
expect(textOf(alert)).toBe(FAILED_COPY)
expect(alert.querySelector('svg')).not.toBeNull()
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
expect(groupRows()).toHaveLength(4)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
},
)
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
let previews = 0
await opened({
settings: afterRefresh(purgedSettings()),
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
purge: () =>
purgeResult({
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'1 group · 1 copy will be deleted · 2 articles will be updated',
)
expect(buttonLabels()).toEqual(['Close', 'Retry'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
confirmButton('Retry').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
})
it('shows Nothing to purge when a partial result leaves no group', async () => {
let previews = 0
await opened({
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(textOf(dialogEl())).toContain('Nothing to purge')
expect(buttonLabels()).toEqual(['Close'])
})
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
let previews = 0
const refreshed = previewOf({
groups: 3,
copies: 7,
articles: 10,
list: previewOf().list.slice(0, 3),
fingerprint: 'cd34',
})
await opened({
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(STALE_COPY)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'3 groups · 7 copies will be deleted · 10 articles will be updated',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 3 groups').click()
await flushPromises()
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
})
it('shows the server text when a purge is already running', async () => {
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(noticeText()).toBe(BUSY_COPY)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(dialogEl()).not.toBeNull()
})
})
@@ -15,6 +15,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
if (path === STATUS_PATH) {
return Promise.resolve(status)
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
@@ -0,0 +1,466 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import {
INVALID_DAYS_COPY,
OFFLINE_COPY,
PREVIEW_PATH,
SAVE_FAILED_COPY,
SETTINGS_PATH,
bodyOf,
callsTo,
cardEl,
commitByBlur,
commitByEnter,
daysError,
daysInput,
deferredValue,
enabledSettings,
findButton,
goOffline,
lastLine,
mountSettings,
nextLine,
purgeButton,
purgeKit,
purgeSettings,
saveStatus,
switchEl,
switchError,
textOf,
typeInto,
unmountSettings,
type SettingsCall,
} from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DESCRIPTION =
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
}
function savedInterval(call: SettingsCall) {
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
return enabledSettings({ auto_purge_interval_days: days })
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Media cleanup card: default and on', () => {
it('Default: off, never run', async () => {
await mountSettings({ settings: () => purgeSettings() })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
expect(textOf(card)).toContain(DESCRIPTION)
expect(textOf(card)).toContain('Automatic purge')
expect(textOf(card)).toContain('Runs in the background. Off by default.')
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(textOf(card)).not.toContain('Every')
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Never run')
expect(card.querySelector('[role="separator"]')).not.toBeNull()
expect(textOf(card)).toContain('Purge now')
expect(textOf(card)).toContain(
'Merge every group of duplicates that has an image in use, right away.',
)
expect(purgeButton().disabled).toBe(false)
expect(findButton('Save', card)).toBeUndefined()
})
it('On: 30 days, last run', async () => {
await mountSettings({ settings: () => enabledSettings() })
const card = cardEl()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(card)).toContain('Every')
expect(textOf(card)).toContain('Between 1 and 365 days.')
expect(daysInput()!.value).toBe('30')
expect(textOf(card)).toContain('days')
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it.each([
[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'],
])(
'pluralises the Last purge line for %i merged and %i articles',
async (merged, articles, line) => {
await mountSettings({
settings: () =>
enabledSettings({
last_purge_merged: merged,
last_purge_articles_updated: articles,
}),
})
expect(lastLine()).toBe(line)
},
)
})
describe('Media cleanup card: the switch', () => {
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const patch = deferredValue<unknown>()
await mountSettings({
settings: patching(
() => patch.promise,
() => purgeSettings(),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(call![0]).toBe(SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
expect(saveStatus()).toBe('Saving…')
expect(
cardEl()
.querySelector('[data-testid="cleanup-save-status"]')!
.getAttribute('aria-live'),
).toBe('polite')
expect(switchEl().disabled).toBe(false)
patch.resolve(
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
)
await flushPromises()
expect(saveStatus()).toBe('Saved')
expect(daysInput()!.value).toBe('30')
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
vi.advanceTimersByTime(1999)
await flushPromises()
expect(saveStatus()).toBe('Saved')
vi.advanceTimersByTime(1)
await flushPromises()
expect(saveStatus()).toBe('')
})
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
await mountSettings({
settings: patching(() =>
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
})
})
describe('Media cleanup card: the days field', () => {
it.each([
['45', 'blur', 45],
['45', 'Enter', 45],
['1', 'Enter', 1],
['365', 'blur', 365],
])('saves %s once when it %s commits', async (typed, commit, sent) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
const calls = callsTo('PATCH', SETTINGS_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
expect(saveStatus()).toBe('Saved')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it('sends one request when Enter is followed by the blur', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
})
it('sends nothing for an unchanged value', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
input.focus()
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(saveStatus()).toBe('')
})
it.each(['', '0', '366', '400', '1.5'])(
'rejects "%s" without sending anything and keeps the text',
async (typed) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(input.value).toBe(typed)
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
expect(daysError()!.querySelector('svg')).not.toBeNull()
expect(saveStatus()).toBe('')
},
)
it('also validates on Enter', async () => {
await mountSettings({ settings: patching(savedInterval) })
await typeInto(daysInput()!, '366')
await commitByEnter(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
})
it('clears the validation error once the value is valid and sends it', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '400')
await commitByBlur(input)
expect(daysError()).not.toBeNull()
await typeInto(input, '60')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(input.getAttribute('aria-invalid')).toBeNull()
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
auto_purge_interval_days: 60,
})
})
})
describe('Media cleanup card: save failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
await mountSettings({ settings: patching(failure) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(input.value).toBe('30')
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
switchEl().click()
await flushPromises()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('removes the message on the next successful save', async () => {
let failing = true
await mountSettings({
settings: patching((call) =>
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
),
})
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).not.toBeNull()
failing = false
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(saveStatus()).toBe('Saved')
})
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
const refresh = vi.spyOn(useSessionStore(), 'refresh')
purgeKit.meRole = 'editor'
switchEl().click()
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(1)
expect(toasts.add).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/settings')
expect(switchError()).toBeNull()
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
expect(switchEl().disabled).toBe(true)
})
})
describe('Media cleanup card: read-only', () => {
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.value).toBe('30')
expect(daysInput()!.disabled).toBe(true)
expect(lastLine()).toBe('Never run')
expect(nextLine()).toBeNull()
expect(textOf(card.querySelector('[role="status"]'))).toBe(
'Only owners and admins can change this.',
)
expect(card.querySelector('[role="separator"]')).toBeNull()
expect(textOf(card)).not.toContain('Purge now')
expect(findButton('Purge duplicates', card)).toBeUndefined()
switchEl().click()
await commitByBlur(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
})
it('treats a missing role the same way', async () => {
await mountSettings({}, { role: undefined })
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
})
})
describe('Media cleanup card: loading and failure to load', () => {
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await mountSettings({ settings: () => pending.promise })
vi.advanceTimersByTime(149)
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(cardEl().getAttribute('aria-busy')).toBe('true')
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
expect(purgeButton().disabled).toBe(true)
pending.resolve(purgeSettings())
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
expect(lastLine()).toBe('Never run')
expect(purgeButton().disabled).toBe(false)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
let failing = true
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
const alert = cardEl().querySelector('[role="alert"]')!
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
failing = false
findButton('Try again', cardEl())!.click()
await flushPromises()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
expect(lastLine()).toBe('Never run')
})
})
describe('Media cleanup card: offline and 401', () => {
it('shows the banner and disables the controls, sending nothing', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
expect(document.body.textContent).toContain(OFFLINE_COPY)
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.disabled).toBe(true)
expect(purgeButton().disabled).toBe(true)
switchEl().click()
purgeButton().click()
await flushPromises()
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
it('goes to the login page on a 401 and shows no copy of its own', async () => {
await mountSettings(
{ settings: () => Promise.reject(new ApiError(401)) },
{ role: 'owner', realApi: true },
)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({
redirect: '/settings',
reason: 'expired',
})
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
})
it('shows no save failure for a 401 on a change', async () => {
await mountSettings(
{ settings: patching(() => Promise.reject(new ApiError(401))) },
{ role: 'owner', realApi: true },
)
switchEl().click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
})
})
@@ -32,6 +32,8 @@ import {
updateSucceeded,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
}
const handler = handlers[path]
if (!handler) {
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
}
return handler()
@@ -262,6 +267,9 @@ function silenceStatusReads() {
if (path === STATUS_PATH) {
return Promise.reject(new NetworkError())
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
}
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
if (path === STATUS_PATH) {
return answers()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
siteUp = healthy
@@ -21,6 +21,8 @@ import {
startable,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
if (path === DOWNLOAD_PATH) {
return downloadHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -19,6 +19,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
if (path === CHECK_PATH) {
return checkHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -6,6 +6,7 @@ import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
if (path === SETTINGS_PATH) {
return settings()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
+16 -2
View File
@@ -236,6 +236,8 @@ export interface Handlers {
hash?: (path: string) => unknown
remove?: (id: number) => unknown
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export function libraryHandlers(
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
if (method === 'GET' && path.startsWith('/v1/media/?')) {
return (handlers.list ?? (() => listBody(fixture())))(path)
}
if (method === 'GET' && path === '/v1/media/purge-preview') {
return (handlers.preview ?? (() => undefined))()
}
if (method === 'POST' && path === '/v1/media/purge') {
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1]))
@@ -316,12 +324,18 @@ export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean } = {},
options: { realApi?: boolean; role?: string } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
if (options.realApi) {
+351
View File
@@ -0,0 +1,351 @@
import { vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import SettingsView from '../SettingsView.vue'
export const SETTINGS_PATH = '/v1/media/purge-settings'
export const PREVIEW_PATH = '/v1/media/purge-preview'
export const PURGE_PATH = '/v1/media/purge'
export const STALE_COPY =
'The library changed, so nothing was purged. Check the new summary and try again.'
export const BUSY_COPY = 'A purge is already running.'
export const FAILED_COPY =
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
export const OFFLINE_COPY =
"You're offline. Changing automatic purge and purging duplicates need a connection."
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
return {
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,
...overrides,
}
}
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
return purgeSettings({
auto_purge_enabled: true,
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
...overrides,
})
}
const GROUPS = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
return {
groups: 4,
copies: 9,
articles: 12,
list: GROUPS.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
export function manyPreview() {
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
}
export function emptyPreview() {
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
}
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
return previewOf({
groups: 1,
copies,
articles,
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
fingerprint: 'one',
})
}
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
return {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
...overrides,
}
}
export interface SettingsCall {
method: string
body: unknown
}
export interface PurgeMocks {
settings?: (call: SettingsCall) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export const purgeKit = {
requestMock: vi.fn(),
wrapper: null as VueWrapper | null,
meRole: 'owner' as string | undefined,
getMe: vi.fn(),
}
export function installPurgeApi(mocks: PurgeMocks) {
return (path: string, options?: RequestInit) => {
const method = options?.method ?? 'GET'
if (path === SETTINGS_PATH) {
const body = options?.body ? JSON.parse(String(options.body)) : undefined
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
}
if (path === PREVIEW_PATH && method === 'GET') {
return (mocks.preview ?? (() => previewOf()))()
}
if (path === PURGE_PATH && method === 'POST') {
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
}
return undefined
}
}
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
return vi.fn(async (path: string, init?: RequestInit) => {
const reply = (status: number, body?: unknown) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => '',
})
try {
const body = await request(path, init)
return reply(body === undefined ? 204 : 200, body)
} catch (error) {
if (error instanceof ApiError) {
return reply(error.status)
}
throw error
}
})
}
export async function mountSettings(
mocks: PurgeMocks = {},
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
purgeKit.meRole = options.role
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: options.role as never,
}
session.status = 'ready'
const dispatch = installPurgeApi(mocks)
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/settings/') {
return { version: 'v2.2.0-beta' }
}
if (path === '/v1/system/update') {
return new Promise(() => undefined)
}
return dispatch(path, init)
})
purgeKit.getMe = vi.fn(async () => ({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: purgeKit.meRole,
}))
if (options.realApi) {
const actual =
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
vi.mocked(useApi).mockImplementation(actual.useApi)
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
} else {
vi.mocked(useApi).mockReturnValue({
getMe: purgeKit.getMe,
request: purgeKit.requestMock,
})
}
await router.push('/settings')
purgeKit.wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return purgeKit.wrapper
}
export async function unmountSettings() {
purgeKit.wrapper?.unmount()
purgeKit.wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
await router.push('/login')
document.body.innerHTML = ''
}
export function cardEl() {
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export function purgeButton() {
return findButton('Purge duplicates', cardEl())!
}
export function switchEl() {
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
}
export function daysInput() {
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
}
export function daysError() {
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
}
export function switchError() {
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
}
export function saveStatus() {
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
}
export function lastLine() {
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
}
export function nextLine() {
return cardEl().querySelector('[data-testid="cleanup-next"]')
}
export function dialogEl() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function callsTo(method: string, path: string) {
return purgeKit.requestMock.mock.calls.filter(
([called, options]) => called === path && (options?.method ?? 'GET') === method,
)
}
export function bodyOf(call: unknown[]) {
return JSON.parse(String((call[1] as RequestInit).body))
}
export async function typeInto(input: HTMLInputElement, value: string) {
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function commitByBlur(input: HTMLInputElement) {
input.blur()
await flushPromises()
}
export async function commitByEnter(input: HTMLInputElement) {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export async function goOffline() {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
}
export function pressEscape(target: Element | null) {
;(target ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
}
export async function openDialog() {
const opener = purgeButton()
opener.focus()
opener.click()
await flushPromises()
return opener
}
export function isBefore(first: Element, second: Element) {
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
}
export function groupRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
}
export function deferredValue<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+12
View File
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
finished_at: RESTART_FINISHED_AT,
})
}
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
export const DEFAULT_PURGE_SETTINGS = {
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,
}
+164 -69
View File
@@ -11,11 +11,80 @@ import { visualStates, type ApiMock, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark'
interface Source {
board: string
group: string
states: string[]
alias: Record<string, string>
}
interface Screen {
name: string
board: string
group: string
states: string[]
sources: Source[]
}
const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html'
const extraSources: Record<string, Source[]> = {
settings: [
{
board: cleanupBoard,
group: 'Settings',
states: [
'Default: off, never run',
'On: 30 days, last run',
'Saving',
'Saved',
'Save failed',
'Validation error',
'Read-only (non-admin)',
'Loading skeleton',
'Offline',
],
alias: {},
},
{
board: cleanupBoard,
group: 'Dialog',
states: [
'Loading preview',
'Confirm',
'Confirm: many groups',
'In progress',
'Nothing to purge',
'Error',
'Partial failure',
'Conflict (409)',
],
alias: {},
},
{
board: cleanupBoard,
group: 'Results',
states: ['Success', 'Success, automatic purge on'],
alias: {},
},
],
media: [
{
board: cleanupBoard,
group: 'Media',
states: [
'Duplicates tab with the button',
'Dialog from Media',
'Member: button hidden',
'Offline: button disabled',
],
alias: { 'Offline: button disabled': 'Offline' },
},
],
}
const boardAlias: Record<string, Record<string, string>> = {
media: { 'Duplicates: purge all': 'Purge duplicates: confirm' },
}
const webRoot = resolve(import.meta.dirname, '..')
@@ -95,25 +164,42 @@ async function readScreen(name: string): Promise<Screen> {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const statesStart = markdown.search(/^## States$/m)
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
const nextSection = afterStates.slice(1).search(/^## /m)
const statesMarkdown = nextSection === -1 ? afterStates : afterStates.slice(0, nextSection + 1)
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
return { name, board: reference[1]!, group: reference[2]!, states }
const primary: Source = {
board: reference[1]!,
group: reference[2]!,
states,
alias: boardAlias[name] ?? {},
}
return {
name,
board: primary.board,
group: primary.group,
states,
sources: [primary, ...(extraSources[name] ?? [])],
}
}
function pickStates(screen: Screen, wanted?: string) {
function pickSources(screen: Screen, wanted?: string) {
if (!wanted) {
return screen.states
return screen.sources
}
const match = screen.states.find(
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
)
if (!match) {
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
const matches = (state: string) =>
state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted)
const picked = screen.sources
.map((source) => ({ ...source, states: source.states.filter(matches) }))
.filter((source) => source.states.length > 0)
if (picked.length === 0) {
const known = screen.sources.flatMap((source) => source.states)
throw new Error(`Unknown state "${wanted}". Known: ${known.join(', ')}`)
}
return [match]
return picked
}
async function serveDesign() {
@@ -444,7 +530,7 @@ async function diff(browser: Browser, designPath: string, appPath: string, diffP
async function main() {
const args = parseArgs(process.argv.slice(2))
const screen = await readScreen(args.screen)
const states = pickStates(screen, args.state)
const sources = pickSources(screen, args.state)
const setups = visualStates[screen.name] ?? {}
const design = await serveDesign()
@@ -458,67 +544,76 @@ async function main() {
try {
for (const viewport of args.viewports) {
for (const theme of args.themes) {
const board = await openBoard(browser, design.origin, screen, theme)
for (const state of states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
continue
}
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(board, screen, state, viewport, designPath)
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
for (const source of sources) {
const sourceScreen = { ...screen, board: source.board, group: source.group }
const board = await openBoard(browser, design.origin, sourceScreen, theme)
for (const state of source.states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
continue
}
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(
board,
sourceScreen,
source.alias[state] ?? state,
viewport,
designPath,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent
const problems = [
...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
: []),
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
if (problems.length) {
failures++
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent
const problems = [
...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
: []),
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
if (problems.length) {
failures++
}
const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
}
const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
await board.context().close()
}
await board.context().close()
}
}
} finally {
@@ -535,7 +630,7 @@ async function main() {
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
const report = [
`# Visual check: ${screen.name}`,
`Board: ${screen.board}, group "${screen.group}"`,
`Boards: ${sources.map((source) => `${source.board} / ${source.group}`).join('; ')}`,
'',
verdict,
'',
+356 -1
View File
@@ -2246,6 +2246,336 @@ const apiKeysStates: Record<string, VisualState> = {
},
}
const purgeSettingsPath = 'GET /v1/media/purge-settings'
const purgePatchPath = 'PATCH /v1/media/purge-settings'
const purgePreviewPath = 'GET /v1/media/purge-preview'
const purgeRunPath = 'POST /v1/media/purge'
const purgeStaleCopy =
'The library changed, so nothing was purged. Check the new summary and try again.'
function purgeSettingsBody(overrides: Record<string, unknown> = {}) {
return {
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,
...overrides,
}
}
const purgeLastRun = {
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
}
const purgeOnBody = purgeSettingsBody({
...purgeLastRun,
auto_purge_enabled: true,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
})
const purgeDoneLast = {
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
}
const purgeGroups = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
function purgePreviewBody(overrides: Record<string, unknown> = {}) {
return {
groups: 4,
copies: 9,
articles: 12,
list: purgeGroups.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
const purgePreviewMany = purgePreviewBody({
groups: 8,
copies: 15,
articles: 24,
list: purgeGroups,
more: 3,
})
const purgePreviewOne = purgePreviewBody({
groups: 1,
copies: 1,
articles: 2,
list: [purgeGroups[1]],
fingerprint: 'one',
})
const purgePreviewThree = purgePreviewBody({
groups: 3,
copies: 7,
articles: 10,
list: [purgeGroups[0], purgeGroups[2], purgeGroups[3]],
fingerprint: 'cd34',
})
const purgePreviewNone = purgePreviewBody({
groups: 0,
copies: 0,
articles: 0,
list: [],
fingerprint: 'none',
})
const purgeRunPartial = {
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}
const purgeRunDone = {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
}
function sessionWithRole(role: string): ApiMock {
return { status: 200, body: { ...(verifiedSession.body as object), role } }
}
function cleanupApi(
settings: ApiMock,
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': sessionWithRole(role),
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/system/update': { status: 200, hang: true },
[purgeSettingsPath]: settings,
...overrides,
}
}
function settingsBody(body: unknown): ApiMock {
return { status: 200, body }
}
function cleanupState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/settings', api, steps }
}
async function openCleanupDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmCleanupDialog(label: string, wait?: string) {
return async (page: Page) => {
await openCleanupDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: label, exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
async function typeDays(page: Page, value: string) {
const field = page.locator('#auto-purge-days')
await field.fill(value)
await field.press('Enter')
await page.mouse.move(0, 0)
}
function afterFirst(first: unknown, later: unknown): ApiMock {
return { status: 200, bodyFor: (_url, call) => (call === 1 ? first : later) }
}
function cleanupDialogApi(overrides: Record<string, ApiMock> = {}) {
return cleanupApi(settingsBody(purgeSettingsBody()), {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
...overrides,
})
}
async function holdToast(page: Page) {
await page.getByText('Duplicates purged', { exact: true }).hover()
}
function successState(settings: ApiMock) {
return cleanupState(
cleanupApi(settings, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
[purgeRunPath]: settingsBody(purgeRunDone),
}),
async (page) => {
await confirmCleanupDialog('Purge 4 groups', 'Duplicates purged')(page)
await holdToast(page)
},
)
}
const cleanupStates: Record<string, VisualState> = {
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
Saving: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: { status: 200, hang: true },
}),
async (page) => {
await page.getByRole('switch').click()
await page.locator('#auto-purge-days').focus()
await page.mouse.move(0, 0)
},
),
Saved: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: settingsBody(purgeOnBody),
}),
async (page) => {
await page.getByRole('switch').click()
await page.getByText('Saved', { exact: true }).waitFor()
await page.mouse.move(0, 0)
},
),
'Save failed': cleanupState(
cleanupApi(settingsBody(purgeOnBody), { [purgePatchPath]: { status: 500 } }),
async (page) => {
await typeDays(page, '45')
await page.getByText("Couldn't save. Try again in a moment.").waitFor()
},
),
'Validation error': cleanupState(cleanupApi(settingsBody(purgeOnBody)), async (page) => {
await typeDays(page, '400')
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
openCleanupDialog,
),
Confirm: cleanupState(cleanupDialogApi(), openCleanupDialog),
'Confirm: many groups': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewMany } }),
openCleanupDialog,
),
'In progress': cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 200, hang: true } }),
confirmCleanupDialog('Purge 4 groups'),
),
'Nothing to purge': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewNone } }),
openCleanupDialog,
),
Error: cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 500 } }),
confirmCleanupDialog(
'Purge 4 groups',
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.",
),
),
'Partial failure': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewOne),
[purgeRunPath]: settingsBody(purgeRunPartial),
}),
confirmCleanupDialog(
'Purge 4 groups',
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
),
),
'Conflict (409)': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewThree),
[purgeRunPath]: { status: 409, body: purgeStaleCopy },
}),
confirmCleanupDialog('Purge 4 groups', purgeStaleCopy),
),
Success: successState(afterFirst(purgeSettingsBody(), purgeSettingsBody(purgeDoneLast))),
'Success, automatic purge on': successState(
afterFirst(
purgeOnBody,
purgeSettingsBody({
...purgeDoneLast,
auto_purge_enabled: true,
next_purge_at: '2026-11-04T09:00:00Z',
}),
),
),
}
async function openMediaPurgeDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const mediaPurgeStates: Record<string, VisualState> = {
'Duplicates: purge all': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Duplicates tab with the button': mediaState(
mediaDuplicatesApi(),
undefined,
mediaDuplicatesPath,
),
'Dialog from Media': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Member: button hidden': mediaState(
mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }),
undefined,
mediaDuplicatesPath,
),
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -2391,6 +2721,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
...cleanupStates,
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
@@ -3364,6 +3695,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
media: mediaStates,
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}
const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }'
function withoutCleanupCard(state: VisualState): VisualState {
if (state.skip) {
return state
}
return {
...state,
api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } },
steps: async (page) => {
await page.addStyleTag({ content: hideCleanupCard })
await state.steps?.(page)
await page.addStyleTag({ content: hideCleanupCard })
await page.mouse.move(0, 0)
},
}
}
for (const [name, state] of Object.entries(visualStates.settings!)) {
if (!(name in cleanupStates)) {
visualStates.settings![name] = withoutCleanupCard(state)
}
}