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