456 lines
29 KiB
Gherkin
456 lines
29 KiB
Gherkin
Feature: Media page, part 2: upload, selection and duplicates
|
||
|
||
As a verified RenewCMS owner
|
||
I want to upload images to the library from /media, select several images to delete them
|
||
together, and merge identical copies
|
||
So that I can keep the library complete and free of duplicates without leaving the page
|
||
|
||
Background:
|
||
Given /media is the verified route from slice B1, rendered as MediaPage inside
|
||
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
|
||
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
|
||
or repository changes
|
||
And this slice is amended by features/media_pagination_screen.feature: "Select all" covers the
|
||
images of the current page only, the Duplicates tab loads the whole set at size=100 page after
|
||
page until the loaded count reaches total, and hash lookups send page=1&size=100
|
||
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
|
||
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
|
||
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
|
||
the "Select all" text and the partial state exist; the B2 states no longer carry
|
||
skip 'pending' in web/visual/states.ts
|
||
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
|
||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>&page=1&size=100,
|
||
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
|
||
B1 with filter=duplicates; every other /v1 call goes through useApi
|
||
And 401 and 403 on any of these calls, including the XHR upload, send the user to
|
||
/login?redirect=/media&reason=expired and /register/pending as useApi does, with no local
|
||
copy
|
||
And the browser-side checks reuse validateImageFile from web/src/lib/imageUpload.ts: size is
|
||
checked first (10 MB = 10485760 bytes is allowed, one byte more is too large), then the
|
||
file's MIME type and its extension must both be allowed (PNG, JPEG, WEBP, SVG; .png, .jpg,
|
||
.jpeg, .webp, .svg); a rejected file is never hashed and never sent
|
||
And hashing lives in a shared module under web/src/lib that depends on nothing in the Media
|
||
page, so that the editor's media picker slice reuses it: it reads the file, computes the
|
||
SHA-256 with crypto.subtle and resolves the lowercase 64-character hex, or resolves null when
|
||
crypto.subtle is unavailable
|
||
And at most 2 uploads are in flight at once; files are processed in the order they were
|
||
chosen or dropped, and hashing runs inside the file's concurrency slot while its row reads
|
||
"Waiting"
|
||
And uploads from this page are not cropped or resized, and each file is sent as chosen
|
||
And toast ids are "media-bulk-deleted", "media-bulk-delete-failed", "media-merged-{keep_id}"
|
||
and "media-not-found-{id}", and success toasts last 5 s; the bulk-delete failure toast has
|
||
no duration and stays until retried or dismissed
|
||
And the 12-image fixture, its counts { all: 12, unused: 2, duplicates: 2 } and its sample
|
||
data are the ones in features/media_browse.feature; the duplicate groups are {12, 5, 6}
|
||
(floorboards.jpg, floorboards-copy.jpg, IMG_2208.jpg, 3600 × 2400, 2.4 MB each) and
|
||
{7, 9} (IMG_4471.jpg, stone-wall-detail.jpg, 3600 × 2400, 3.1 MB each)
|
||
And where a board frame contradicts the design prose or the fixture, this spec follows the
|
||
prose and the fixture, and the design is corrected by the user, with no visual exception
|
||
|
||
Rule: Upload entry points
|
||
|
||
Scenario Outline: The Upload controls open the file chooser
|
||
Given the Default state has loaded <context>
|
||
When the user activates "<control>"
|
||
Then the file chooser opens, accepts several files and offers image/png, image/jpeg, image/webp and image/svg+xml
|
||
|
||
Examples:
|
||
| context | control |
|
||
| at md width or more | Upload |
|
||
| below md width | Upload |
|
||
| and the library has no image | Upload an image |
|
||
|
||
Scenario: Dropping files anywhere on the page
|
||
Given the Default state has loaded at md width or more
|
||
When the user drags files over the page
|
||
Then a dashed primary-bordered area covers the content, with the upload icon, the title "Drop images to upload" and the text "PNG, JPEG, WEBP, SVG · max 10 MB each"
|
||
And the mobile pinned Upload button is hidden while the area shows
|
||
When the user drags the files away from the page
|
||
Then the area disappears and nothing is uploaded
|
||
When the user drops the files on the page
|
||
Then the area disappears and the upload of each file starts straight away, with no crop step
|
||
|
||
Scenario: Dragging something that is not a file does nothing
|
||
When the user drags selected text over the page
|
||
Then no drop area shows
|
||
|
||
Rule: Browser checks, nothing is sent
|
||
|
||
Scenario Outline: A file that fails the checks becomes a rejected row
|
||
Given the user chooses the file "<file>" of <size> with MIME type "<mime>"
|
||
Then one row is added with the file-x icon, the title "<title>" and the text "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."
|
||
And no hash lookup and no POST /v1/media/ is sent for it
|
||
|
||
Examples:
|
||
| file | size | mime | title |
|
||
| scan.tif | 14.2 MB | image/tiff | scan.tif is 14.2 MB |
|
||
| notes.pdf | 210 KB | application/pdf | notes.pdf isn't an image |
|
||
| photo.png | 10.1 MB | image/png | photo.png is 10.1 MB |
|
||
| fake.jpg | 40 KB | text/plain | fake.jpg isn't an image |
|
||
| logo.gif | 40 KB | image/gif | logo.gif isn't an image |
|
||
| pic.jpg | 40 KB | image/png | pic.jpg isn't an image |
|
||
|
||
Scenario: Every rejected file leaves the panel with "Nothing was uploaded"
|
||
Given the user chooses scan.tif (14.2 MB), notes.pdf (210 KB) and broken.png (3.0 MB, rejected by the server with 400)
|
||
Then the panel header reads "Nothing was uploaded" with "3 files were rejected" and a "Dismiss" button
|
||
And the rows read "scan.tif is 14.2 MB", "notes.pdf isn't an image" and "broken.png couldn't be read" with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
|
||
And no summary line shows
|
||
|
||
Scenario: Rejected and failed rows without an added image
|
||
Given the user chooses one rejected file and one file whose upload fails with 500
|
||
Then the panel header reads "Nothing was uploaded" with "2 files need attention"
|
||
And no summary line shows
|
||
|
||
Rule: The upload panel and its rows
|
||
|
||
Scenario: Files upload two at a time with progress
|
||
Given the user chooses 6 valid images and the duplicate lookup finds nothing
|
||
Then the panel shows above the images, below the toolbar, with the title "Uploading 6 images", "0 of 6 done" and a "Cancel all" button
|
||
And rows 1 and 2 read "Uploading", and rows 3 to 6 read "Waiting" (each row: 40 px thumbnail, filename, size)
|
||
When the first upload reports 1.1 MB sent of 2.6 MB
|
||
Then its row reads "Uploading 1.1 of 2.6 MB" over a 4 px progress bar filled to 42 % and shows a cancel x
|
||
When the first upload resolves 201
|
||
Then its row reads "Added to your library" with a circle-check icon, the header reads "1 of 6 done", and row 3 starts uploading
|
||
And at no time are more than 2 requests to POST /v1/media/ in flight
|
||
|
||
Scenario: The panel header counts settled rows
|
||
Given a panel with rows Added, Duplicate, Rejected, Failed, Uploading and Waiting
|
||
Then the header reads "4 of 6 done"
|
||
|
||
Scenario: Singular forms
|
||
Given the user chooses 1 valid image
|
||
Then the panel title reads "Uploading 1 image"
|
||
|
||
Scenario: New files added while the panel is open
|
||
Given the panel is showing rows
|
||
When the user chooses or drops more files
|
||
Then their rows are appended and the header counts cover all rows
|
||
|
||
Scenario: A new upload refreshes the list in the background
|
||
Given an upload resolves 201
|
||
Then the list is requested again with no skeleton, so the new image shows and the counts update
|
||
|
||
Scenario: A row whose content already exists
|
||
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
|
||
When the SHA-256 of the file is computed
|
||
Then GET /v1/media/?hash=<that hex>&page=1&size=100 is sent before any POST
|
||
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
|
||
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
|
||
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
|
||
|
||
Scenario: Several existing matches name the oldest one
|
||
Given the lookup matches several images
|
||
Then the row names the oldest match
|
||
|
||
Scenario: Use existing selects the existing image
|
||
Given the row for "floorboards-2.jpg" is showing "Already in your library" for id 12
|
||
When the user activates "Use existing"
|
||
Then the row is dropped from the panel
|
||
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
|
||
|
||
Scenario: Use existing when the image is not in the loaded list
|
||
Given the existing image is not in the loaded list because of the search, the tab or the current page
|
||
When the user activates "Use existing"
|
||
Then the search and the tab reset to All and the image is selected
|
||
And if it is still not loaded the row is dropped without selecting
|
||
|
||
Scenario: Upload anyway stores a second copy
|
||
Given the row for "floorboards-2.jpg" is showing "Already in your library"
|
||
When the user activates "Upload anyway"
|
||
Then the file is sent with POST /v1/media/ and the row follows the normal upload states
|
||
And when it resolves 201 the list is reloaded and the Duplicates tab count reflects the new copy
|
||
|
||
Scenario Outline: The duplicate check is skipped when it cannot run
|
||
Given the user chooses a valid image and <condition>
|
||
Then the file is sent with POST /v1/media/ without waiting for the answer to a lookup
|
||
And any duplicate then shows up under Duplicates
|
||
|
||
Examples:
|
||
| condition |
|
||
| crypto.subtle is undefined (an insecure context) |
|
||
| GET /v1/media/?hash= fails with 500 or never reaches the server |
|
||
|
||
Scenario Outline: Server answers for an upload that passed the checks
|
||
Given a valid image "<file>" of <size> is uploading
|
||
When POST /v1/media/ resolves <status>
|
||
Then the row reads <row>
|
||
|
||
Examples:
|
||
| file | size | status | row |
|
||
| roof.png | 1.4 MB | 201 | circle-check "Added to your library" |
|
||
| scan.png | 9.9 MB | 413 | file-x "scan.png is 9.9 MB" over "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
|
||
| notes.png | 210 KB | 415 | file-x "notes.png isn't an image" over the same text |
|
||
| broken.png | 3.0 MB | 400 | file-x "broken.png couldn't be read" over "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." |
|
||
| roof.png | 1.4 MB | 500 | circle-x "Upload failed" over "The server didn't accept the file. Nothing was added to your library." and a "Try again" button (icon rotate-cw) |
|
||
| roof.png | 1.4 MB | no answer (network) | the same Failed row |
|
||
|
||
Scenario: Try again retries one file
|
||
Given a row is "Upload failed"
|
||
When the user activates "Try again"
|
||
Then only that file is sent again with POST /v1/media/ (no new duplicate lookup), and the row follows the normal upload states
|
||
|
||
Scenario: Cancelling one upload or all of them
|
||
Given several files are uploading or waiting
|
||
When the user activates the cancel x on an uploading row
|
||
Then that request is aborted and the row is removed, and the next waiting file starts
|
||
When the user activates "Cancel all"
|
||
Then every request in flight is aborted and every waiting row is removed, Added, rejected and failed rows stay, and when no row is left the panel closes
|
||
|
||
Scenario: The panel settles with a summary
|
||
Given 5 files settled as 2 Added, 1 Already in your library (still undecided), 1 rejected and 1 failed
|
||
Then the header reads "Upload finished" with "5 images" and a "Dismiss" button in place of "Cancel all"
|
||
And a summary line reads "2 uploaded · 1 already in your library · 2 need attention"
|
||
And the page announces the summary when the last row settles
|
||
When the user activates "Dismiss"
|
||
Then the panel and its rows disappear, including rejected and failed rows, which stay until then
|
||
|
||
Scenario Outline: The summary line counts each outcome and omits empty parts
|
||
Given the panel has settled with <uploaded> added, <dup> already in the library, <attention> rejected or failed
|
||
Then the summary line reads "<line>"
|
||
|
||
Examples:
|
||
| uploaded | dup | attention | line |
|
||
| 2 | 1 | 2 | 2 uploaded · 1 already in your library · 2 need attention |
|
||
| 3 | 0 | 0 | 3 uploaded |
|
||
| 0 | 1 | 0 | 1 already in your library |
|
||
| 1 | 0 | 1 | 1 uploaded · 1 need attention |
|
||
|
||
Rule: Too many uploads (429)
|
||
|
||
Scenario: The server rate limits an upload
|
||
Given a file is uploading and files are waiting
|
||
When POST /v1/media/ resolves 429 with the header Retry-After: 42
|
||
Then an info alert with the timer icon reads "Too many uploads" over "For your security, wait a moment before uploading again."
|
||
And the Upload button reads "Try again in 0:42" with the timer icon and is disabled, and so is the mobile pinned button
|
||
And Added rows stay in the panel
|
||
And the rate-limited file goes back to Waiting and the queue pauses
|
||
And the countdown is driven by useRetryAfter and ticks once per second
|
||
When the countdown reaches 0:00
|
||
Then the alert disappears, the Upload button reads "Upload" and is enabled again
|
||
And the paused file and the waiting files resume by themselves
|
||
|
||
Scenario: A missing Retry-After counts as 60 seconds
|
||
When POST /v1/media/ resolves 429 without a readable Retry-After header
|
||
Then the countdown starts at 1:00
|
||
|
||
Scenario: Dropping or choosing files while rate limited
|
||
Given the countdown is running
|
||
When the user drops files on the page
|
||
Then nothing is uploaded and the drop area does not show
|
||
|
||
Rule: Offline
|
||
|
||
Scenario: Upload, bulk delete and merge need a connection
|
||
Given the Default state has loaded and navigator.onLine becomes false
|
||
Then the offline banner from B1 shows
|
||
And "Upload" (header, mobile pinned button and "Upload an image"), "Delete" in the bulk bar and every "Merge into this one" are disabled, and dropping files does nothing
|
||
And browsing, selecting and "Clear" still work
|
||
When navigator.onLine becomes true
|
||
Then they are enabled again
|
||
|
||
Rule: Selection
|
||
|
||
Scenario: Selecting images
|
||
Given the Default state has loaded at md width or more
|
||
Then no checkbox is shown on the cards
|
||
When the pointer rests on a card, or a card takes keyboard focus
|
||
Then its checkbox shows at the top left (36 px)
|
||
When the user ticks the checkbox of "north-wall.jpg" (or presses Space on the card)
|
||
Then the card takes the sand fill (--rc-sand) with a primary outline, and every card now shows its checkbox
|
||
And the page announces "north-wall.jpg selected, 1 selected"
|
||
And the keyboard path is Space on the card: the checkbox itself is not an extra tab stop
|
||
And Enter still opens the detail panel, and ticking a checkbox does not
|
||
|
||
Scenario: Unselecting announces the new count
|
||
Given 3 images are selected
|
||
When the user unselects "north-wall.jpg"
|
||
Then the page announces "north-wall.jpg unselected, 2 selected"
|
||
And when the last one is unselected it announces "Selection cleared"
|
||
|
||
Scenario: Selection on mobile
|
||
Given the Default state has loaded below md width
|
||
Then every card shows a 44 px checkbox, and so does every row of the card list in list view
|
||
And tapping the checkbox toggles the selection, and tapping the card opens the detail panel
|
||
|
||
Scenario: Select all and the partial state
|
||
Given the Default state has loaded
|
||
Then the row above the grid reads "Select all" and "12 images", with an unchecked checkbox
|
||
When 3 images are selected
|
||
Then that checkbox shows the partial state
|
||
When the user activates it while it is partial
|
||
Then all 12 images of the current page are selected and it shows checked (never the other pages)
|
||
When the user activates it while it is checked
|
||
Then the selection is emptied
|
||
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
|
||
|
||
Scenario: Escape and Clear empty the selection
|
||
Given 3 images are selected and the detail panel is closed
|
||
When the user presses Escape, or activates "Clear" in the bulk bar
|
||
Then the selection is emptied and the bulk bar disappears
|
||
Given the detail panel is open over a selection
|
||
When the user presses Escape
|
||
Then only the panel closes and the selection stays
|
||
|
||
Scenario: The selection follows the query
|
||
Given 3 images are selected
|
||
When the user changes the search text, the tab or the sort
|
||
Then the selection is cleared
|
||
When the user switches between grid and list
|
||
Then the selection is kept
|
||
|
||
Scenario: The bulk bar
|
||
Given the images "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" are selected
|
||
Then a sticky bar sits at the bottom: 400 px wide and 24 px above the bottom edge, centred over the content area on desktop; full width with 16 px side margins and 16 px above the bottom edge on mobile
|
||
And it has the inverted fill, the text "3 selected", a danger "Delete" button (icon trash-2) and an inverted "Clear" button
|
||
And on mobile it replaces the pinned Upload button
|
||
And toasts sit above it on mobile
|
||
|
||
Rule: Bulk delete
|
||
|
||
Scenario: Bulk delete, confirm
|
||
Given "north-wall.jpg" (used in 2), "kitchen-after.webp" (used in 3) and "window-frame.jpg" (unused) are selected
|
||
When the user activates "Delete"
|
||
Then a dialog opens titled "Delete 3 images?" with the description "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone."
|
||
And the body has one bordered row per selected image: the filename and the badge "Used in 2", "Used in 3" or "Unused"
|
||
And the buttons read "Cancel" first, then a danger "Delete 3 images"
|
||
And no usage request is sent
|
||
|
||
Scenario: Bulk delete, singular
|
||
Given one image is selected
|
||
When the user activates "Delete"
|
||
Then the dialog is titled "Delete 1 image?" and the confirm button reads "Delete 1 image"
|
||
|
||
Scenario: Bulk delete, in progress
|
||
Given the bulk delete dialog is open for 3 images
|
||
When the user activates "Delete 3 images"
|
||
Then one DELETE /v1/media/{id} is sent for each of the 3 images
|
||
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
|
||
|
||
Scenario: Bulk delete, done
|
||
Given a bulk delete of ids 1, 2 and 8 is in flight
|
||
When every DELETE resolves 204
|
||
Then the dialog closes, the 3 cards leave the grid and the selection is empty
|
||
And focus moves to the card now at the position of the first deleted card, or to the page heading when no card remains
|
||
And a success toast appears: icon circle-check, id "media-bulk-deleted", title "3 images deleted", text "They're gone from your library."
|
||
And the list is then requested again in the background, with no skeleton
|
||
|
||
Scenario: Bulk delete, partly failed
|
||
Given a bulk delete of ids 1, 2 and 8 is in flight
|
||
When DELETE /v1/media/1 and DELETE /v1/media/2 resolve 204 and DELETE /v1/media/8 resolves 500
|
||
Then the dialog closes, the cards for ids 1 and 2 leave the grid, and "window-frame.jpg" stays in the grid and stays selected, so the bulk bar reads "1 selected"
|
||
And an error toast appears and stays until retried or dismissed: icon circle-x, id "media-bulk-delete-failed", title "Couldn't delete 1 image", text "2 images were deleted. window-frame.jpg is still in your library. Try again." and a "Retry" button (icon rotate-cw)
|
||
When the user activates "Retry"
|
||
Then DELETE /v1/media/8 is sent again with no dialog, and on 204 the card leaves and the toast is replaced by a success toast
|
||
|
||
Scenario: Bulk delete, several failures
|
||
Given a bulk delete of 4 images in which two fail
|
||
Then the toast title reads "Couldn't delete 2 images" and the text names both files, "a.jpg and b.jpg are still in your library. Try again."
|
||
|
||
Scenario: Bulk delete, an image that no longer exists
|
||
Given a bulk delete of 3 images is in flight
|
||
When one DELETE resolves 404 and the others 204
|
||
Then the 404 image leaves the list and counts as deleted, and the success toast reads "3 images deleted"
|
||
|
||
Scenario: Bulk delete, every request fails
|
||
Given a bulk delete of 3 images is in flight
|
||
When every DELETE resolves 500 or never arrives
|
||
Then the dialog stays open with an error alert "Couldn't delete the images, so they're still there. Try again in a moment." and both buttons enabled
|
||
|
||
Rule: Duplicates
|
||
|
||
Scenario: The Duplicates tab is enabled
|
||
Given the Default state has loaded
|
||
When the user selects the "Duplicates 2" tab
|
||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates at size=100, page 1, 2, ... until the loaded count reaches total
|
||
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
|
||
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
|
||
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
|
||
|
||
Scenario: Duplicates, groups
|
||
Given GET /v1/media/?filter=duplicates&sort=newest&page=1&size=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||
When the user opens the Duplicates tab
|
||
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
|
||
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
|
||
And group 1 has one column per copy, in the order "floorboards.jpg", "floorboards-copy.jpg", "IMG_2208.jpg" (most used, then oldest, then lowest id), and group 2 has "IMG_4471.jpg" then "stone-wall-detail.jpg"; below md the columns stack
|
||
And each copy shows its thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", an underlined "Used in 4 articles" that opens its detail panel, and a "Merge into this one" button (icon merge)
|
||
And "floorboards.jpg" and "IMG_4471.jpg" carry the sand fill, a primary outline, the "Suggested" tag and a primary button, and the other copies are outlined with outline buttons
|
||
And each group's footer reads "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it."
|
||
|
||
Scenario: Group header details
|
||
Given a group of SVG images whose uploader is unknown
|
||
Then the header reads "2 identical images · 8 KB each · Vector" and each copy reads "Uploaded 14 Jun 2026" without "by"
|
||
|
||
Scenario Outline: The suggested copy
|
||
Given a group of copies <copies>
|
||
Then the suggested copy is <suggested>
|
||
|
||
Examples:
|
||
| copies | suggested |
|
||
| used in 4 (14 Jun), 3 (2 Sep) and 2 (30 Aug) | the copy used in 4 |
|
||
| used in 2 (29 Aug) and 2 (21 Aug) | the copy of 21 Aug (same use, oldest) |
|
||
| used in 0 (3 Sep) and 0 (1 Sep) | the copy of 1 Sep |
|
||
|
||
Scenario: Duplicates, empty
|
||
Given GET /v1/media/?filter=duplicates resolves 200 with no item and counts.duplicates 0
|
||
Then the Duplicates tab has no count badge
|
||
And a panel shows the copy icon, "No duplicates", "Every image in your library is different." and a "Back to all images" button
|
||
When the user activates "Back to all images"
|
||
Then the tab returns to All and ?filter leaves the URL
|
||
|
||
Rule: Merge
|
||
|
||
Scenario: Merge, confirm
|
||
Given the Duplicates tab shows group 1
|
||
When the user activates "Merge into this one" on "floorboards.jpg"
|
||
Then a dialog opens titled "Merge into floorboards.jpg?" with the description "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone."
|
||
And the body shows the kept image: its thumbnail, "floorboards.jpg" and "Kept · used in 4 articles now"
|
||
And the buttons read "Cancel" first, then a danger "Merge 2 copies"
|
||
|
||
Scenario: Choosing another copy keeps that one
|
||
When the user activates "Merge into this one" on "IMG_2208.jpg" in group 1
|
||
Then the dialog is titled "Merge into IMG_2208.jpg?" and its article count is the sum of the used_in of "floorboards.jpg" and "floorboards-copy.jpg"
|
||
|
||
Scenario: Merge, singular
|
||
Given a group of 2 copies
|
||
Then the description reads "Articles that use the other copy will be switched to …" and the confirm button reads "Merge 1 copy"
|
||
|
||
Scenario: Merge, in progress
|
||
Given the Merge dialog is open for group 1 with "floorboards.jpg" kept
|
||
When the user activates "Merge 2 copies"
|
||
Then POST /v1/media/merge is sent with { "keep_id": 12, "merge_ids": [5, 6] }
|
||
And both buttons are disabled, the confirm button reads "Merging…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
|
||
|
||
Scenario: Merge, done
|
||
Given a merge for group 1 is in flight
|
||
When POST /v1/media/merge resolves 200 with { "articles_updated": 5, "merged": 2 }
|
||
Then the dialog closes and group 1 leaves the list
|
||
And a success toast appears: icon circle-check, id "media-merged-12", title "Copies merged", text "2 copies merged. 5 articles updated."
|
||
And the page announces the same text, and the list is requested again in the background so the counts and the Duplicate flags update
|
||
And when it was the last group, the "No duplicates" panel shows and the tab loses its count badge
|
||
|
||
Scenario Outline: Merge, errors keep the dialog open or remove the image
|
||
Given a merge for group 1 is in flight
|
||
When POST /v1/media/merge <failure>
|
||
Then <result>
|
||
|
||
Examples:
|
||
| failure | result |
|
||
| resolves with a 500 response | the dialog stays open with the error alert "Couldn't merge the copies. Nothing was changed. Try again in a moment." and both buttons enabled again |
|
||
| never reaches the server | the same alert |
|
||
| resolves with a 409 response | the dialog stays open with "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.", and the list is reloaded |
|
||
| resolves with a 404 response | the dialog closes, the info toast "That image no longer exists" (id "media-not-found-12") shows, and the list is reloaded |
|
||
|
||
Rule: Sessions
|
||
|
||
Scenario Outline: Session errors are global for the new calls
|
||
When <call> resolves <status>
|
||
Then the user is sent to <destination> and no local copy shows
|
||
|
||
Examples:
|
||
| call | status | destination |
|
||
| POST /v1/media/ (XHR) | 401 | /login?redirect=/media&reason=expired |
|
||
| POST /v1/media/ (XHR) | 403 | /register/pending |
|
||
| POST /v1/media/merge | 401 | /login?redirect=/media&reason=expired |
|
||
| DELETE /v1/media/{id} | 403 | /register/pending |
|
||
| GET /v1/media/?hash= | 401 | /login?redirect=/media&reason=expired |
|