From 2303b12377c6701f002d8bb34c8a4f4bec386a98 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 5 Oct 2026 23:57:50 +0200 Subject: [PATCH] feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button) --- features/media_cleanup_spa.feature | 385 +++++++++++++ features/media_cleanup_spa.qa.md | 102 ++++ web/src/components/DashboardShell.vue | 5 +- web/src/components/MediaCleanupCard.vue | 388 +++++++++++++ web/src/components/MediaToolbar.vue | 32 +- web/src/components/OfflineBanner.vue | 13 +- web/src/components/PurgeDuplicatesDialog.vue | 375 +++++++++++++ .../composables/useMediaCleanupSettings.ts | 129 +++++ web/src/composables/useMediaPurge.ts | 78 +++ web/src/i18n/messages.ts | 49 ++ web/src/lib/mediaPurge.ts | 107 ++++ web/src/views/MediaView.vue | 52 ++ web/src/views/SettingsView.vue | 11 +- .../views/__tests__/MediaPurgeEntry.spec.ts | 217 +++++++ .../__tests__/PurgeDuplicatesDialog.spec.ts | 529 ++++++++++++++++++ .../SettingsContainerUpdates.spec.ts | 5 + .../__tests__/SettingsMediaCleanup.spec.ts | 466 +++++++++++++++ .../views/__tests__/SettingsRestart.spec.ts | 11 + .../__tests__/SettingsUpdateDownload.spec.ts | 5 + .../views/__tests__/SettingsUpdates.spec.ts | 5 + web/src/views/__tests__/SettingsView.spec.ts | 4 + web/src/views/__tests__/mediaTestKit.ts | 18 +- web/src/views/__tests__/purgeTestKit.ts | 351 ++++++++++++ web/src/views/__tests__/updateTestKit.ts | 12 + web/visual/check.ts | 233 +++++--- web/visual/states.ts | 357 +++++++++++- 26 files changed, 3856 insertions(+), 83 deletions(-) create mode 100644 features/media_cleanup_spa.feature create mode 100644 features/media_cleanup_spa.qa.md create mode 100644 web/src/components/MediaCleanupCard.vue create mode 100644 web/src/components/PurgeDuplicatesDialog.vue create mode 100644 web/src/composables/useMediaCleanupSettings.ts create mode 100644 web/src/composables/useMediaPurge.ts create mode 100644 web/src/lib/mediaPurge.ts create mode 100644 web/src/views/__tests__/MediaPurgeEntry.spec.ts create mode 100644 web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts create mode 100644 web/src/views/__tests__/SettingsMediaCleanup.spec.ts create mode 100644 web/src/views/__tests__/purgeTestKit.ts diff --git a/features/media_cleanup_spa.feature b/features/media_cleanup_spa.feature new file mode 100644 index 0000000..aaf2cb5 --- /dev/null +++ b/features/media_cleanup_spa.feature @@ -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=&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": ""}, 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 and last_purge_articles_updated + When the card renders + Then the line reads "" + + 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 "" and + Then PATCH is sent exactly once with {"auto_purge_interval_days": } + 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 "" and leaves the field or presses Enter + Then no PATCH is sent + And the field keeps "" 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 and PATCH answers + Then the control returns to its previous value () + 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 + 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 , copies , articles with one group kept in article(s) and merged + Then the summary reads "" + And the confirm reads "" + And the row reads "Kept · used in " and "" + + 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 + 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 + 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} diff --git a/features/media_cleanup_spa.qa.md b/features/media_cleanup_spa.qa.md new file mode 100644 index 0000000..468e8e3 --- /dev/null +++ b/features/media_cleanup_spa.qa.md @@ -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: '' }`, 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. diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 64731ec..c28b633 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -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() { diff --git a/web/src/components/MediaCleanupCard.vue b/web/src/components/MediaCleanupCard.vue new file mode 100644 index 0000000..ccb450c --- /dev/null +++ b/web/src/components/MediaCleanupCard.vue @@ -0,0 +1,388 @@ + + + diff --git a/web/src/components/MediaToolbar.vue b/web/src/components/MediaToolbar.vue index f3f84c1..a34fb80 100644 --- a/web/src/components/MediaToolbar.vue +++ b/web/src/components/MediaToolbar.vue @@ -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(() => -
+
+ + {{ t('Purge duplicates') }} + +
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" /> diff --git a/web/src/components/PurgeDuplicatesDialog.vue b/web/src/components/PurgeDuplicatesDialog.vue new file mode 100644 index 0000000..d4f30d2 --- /dev/null +++ b/web/src/components/PurgeDuplicatesDialog.vue @@ -0,0 +1,375 @@ + + + diff --git a/web/src/composables/useMediaCleanupSettings.ts b/web/src/composables/useMediaCleanupSettings.ts new file mode 100644 index 0000000..af8e51f --- /dev/null +++ b/web/src/composables/useMediaCleanupSettings.ts @@ -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(null) + const loadState = ref('loading') + const showSkeleton = ref(false) + const saveStatus = ref('idle') + const saveFailure = ref(null) + let skeletonTimer: ReturnType | null = null + let savedTimer: ReturnType | 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 } +} diff --git a/web/src/composables/useMediaPurge.ts b/web/src/composables/useMediaPurge.ts new file mode 100644 index 0000000..647ce88 --- /dev/null +++ b/web/src/composables/useMediaPurge.ts @@ -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(SETTINGS_PATH) + } + + function updateSettings(patch: PurgeSettingsPatch) { + return request(SETTINGS_PATH, { + method: 'PATCH', + headers: JSON_HEADERS, + body: JSON.stringify(patch), + }) + } + + function getPreview() { + return request(PREVIEW_PATH) + } + + async function purge(fingerprint: string) { + const result = await request(PURGE_PATH, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ fingerprint }), + }) + bumpMediaRevision() + return result + } + + return { getSettings, updateSettings, getPreview, purge } +} diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index f51c56a..0118f40 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -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 diff --git a/web/src/lib/mediaPurge.ts b/web/src/lib/mediaPurge.ts new file mode 100644 index 0000000..b66db14 --- /dev/null +++ b/web/src/lib/mediaPurge.ts @@ -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, + } +} diff --git a/web/src/views/MediaView.vue b/web/src/views/MediaView.vue index 20e968a..a289254 100644 --- a/web/src/views/MediaView.vue +++ b/web/src/views/MediaView.vue @@ -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([]) 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([]) const copies = ref([]) 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() { + + { diff --git a/web/src/views/__tests__/MediaPurgeEntry.spec.ts b/web/src/views/__tests__/MediaPurgeEntry.spec.ts new file mode 100644 index 0000000..912df46 --- /dev/null +++ b/web/src/views/__tests__/MediaPurgeEntry.spec.ts @@ -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() + 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() + 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('[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() + 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] }) + }) +}) diff --git a/web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts b/web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts new file mode 100644 index 0000000..475c21a --- /dev/null +++ b/web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts @@ -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() + 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() + 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() + 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() + 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() + }) +}) diff --git a/web/src/views/__tests__/SettingsContainerUpdates.spec.ts b/web/src/views/__tests__/SettingsContainerUpdates.spec.ts index efb350f..34df783 100644 --- a/web/src/views/__tests__/SettingsContainerUpdates.spec.ts +++ b/web/src/views/__tests__/SettingsContainerUpdates.spec.ts @@ -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({ diff --git a/web/src/views/__tests__/SettingsMediaCleanup.spec.ts b/web/src/views/__tests__/SettingsMediaCleanup.spec.ts new file mode 100644 index 0000000..b2c97ca --- /dev/null +++ b/web/src/views/__tests__/SettingsMediaCleanup.spec.ts @@ -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() + 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() + 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() + 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() + 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) + }) +}) diff --git a/web/src/views/__tests__/SettingsRestart.spec.ts b/web/src/views/__tests__/SettingsRestart.spec.ts index f135bdb..c3a3ffa 100644 --- a/web/src/views/__tests__/SettingsRestart.spec.ts +++ b/web/src/views/__tests__/SettingsRestart.spec.ts @@ -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 diff --git a/web/src/views/__tests__/SettingsUpdateDownload.spec.ts b/web/src/views/__tests__/SettingsUpdateDownload.spec.ts index 3f8ca34..f4141e6 100644 --- a/web/src/views/__tests__/SettingsUpdateDownload.spec.ts +++ b/web/src/views/__tests__/SettingsUpdateDownload.spec.ts @@ -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({ diff --git a/web/src/views/__tests__/SettingsUpdates.spec.ts b/web/src/views/__tests__/SettingsUpdates.spec.ts index 4655099..38c9424 100644 --- a/web/src/views/__tests__/SettingsUpdates.spec.ts +++ b/web/src/views/__tests__/SettingsUpdates.spec.ts @@ -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({ diff --git a/web/src/views/__tests__/SettingsView.spec.ts b/web/src/views/__tests__/SettingsView.spec.ts index 9d56b0b..ea9eefe 100644 --- a/web/src/views/__tests__/SettingsView.spec.ts +++ b/web/src/views/__tests__/SettingsView.spec.ts @@ -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() @@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise) { 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({ diff --git a/web/src/views/__tests__/mediaTestKit.ts b/web/src/views/__tests__/mediaTestKit.ts index efe0bda..574e74d 100644 --- a/web/src/views/__tests__/mediaTestKit.ts +++ b/web/src/views/__tests__/mediaTestKit.ts @@ -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) { diff --git a/web/src/views/__tests__/purgeTestKit.ts b/web/src/views/__tests__/purgeTestKit.ts new file mode 100644 index 0000000..60c4bad --- /dev/null +++ b/web/src/views/__tests__/purgeTestKit.ts @@ -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 { + 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 = {}) { + 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 { + 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 { + 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) { + 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('@/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('[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('button')).find( + (button) => textOf(button) === label, + ) +} + +export function purgeButton() { + return findButton('Purge duplicates', cardEl())! +} + +export function switchEl() { + return cardEl().querySelector('[role="switch"]')! +} + +export function daysInput() { + return cardEl().querySelector('#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('[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('[data-testid="purge-group"]')) +} + +export function deferredValue() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/web/src/views/__tests__/updateTestKit.ts b/web/src/views/__tests__/updateTestKit.ts index a8d0e55..070d694 100644 --- a/web/src/views/__tests__/updateTestKit.ts +++ b/web/src/views/__tests__/updateTestKit.ts @@ -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, +} diff --git a/web/visual/check.ts b/web/visual/check.ts index 0fa61f1..f94c0cd 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -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 +} + interface Screen { name: string board: string group: string states: string[] + sources: Source[] +} + +const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html' + +const extraSources: Record = { + 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> = { + media: { 'Duplicates: purge all': 'Purge duplicates: confirm' }, } const webRoot = resolve(import.meta.dirname, '..') @@ -95,25 +164,42 @@ async function readScreen(name: string): Promise { 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, '', diff --git a/web/visual/states.ts b/web/visual/states.ts index 7a5a759..56cafce 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -2246,6 +2246,336 @@ const apiKeysStates: Record = { }, } +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 = {}) { + 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 = {}) { + 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 = {}, + role = 'owner', +): Record { + 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, + steps?: (page: Page) => Promise, +): 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 = {}) { + 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 = { + '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 = { + '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> = { profile: { 'Account menu': { @@ -2391,6 +2721,7 @@ export const visualStates: Record> = { '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> = { 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) + } +}