feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

@@ -0,0 +1,211 @@
Feature: The editor's Variant select and the warning on blocks whose variant is gone
As an owner or admin writing an article
I want to choose a variant when I insert an image from the library, and be warned when a variant used by an image in my article was deleted or disabled
So that the article asks the right size and I notice before readers get an error
Background:
Given the spec of record is docs/design/screens/image-variants.md section 6 and its states 6a to 6e, docs/design/screens/media-picker.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html
And this slice is SPA only: it uses GET /v1/image-variants (owner/admin, features/image-variants.feature) and the media library API unchanged, and builds on features/editor_media_picker.feature, which stays as it is except where this file says so
And the Settings screens and the media page are not in this slice
And the Variant select is a USelectMenu in the Library tab's selection panel, under the thumbnail row, before the "Alt text (optional)" field
And the list of variants is requested with GET /v1/image-variants once per opening of the dialog, when the Library tab first shows an image selection, and only enabled variants are offered
And a variant option reads "<Name> · <dimensions> · <format>" where the name is capitalised, the dimensions read "400×300", "800 × auto" or "auto × 2400" as in the Settings list, and the format reads WebP, JPEG, PNG or Original; the first option reads "Original · <width>×<height> · <JPEG|PNG|WEBP>" from the selected image
And the options are Original first, then the enabled variants in the order the API returns them (name ascending)
And the url line under the select shows what will be inserted, in mono 12 px muted: "/static/uploadedImages/<storedName>" for Original and "/static/uploadedImages/<storedName>?v=<name>" for a variant (the design's "/media/<file>" is replaced per the design's own implementation notes)
And the select label reads "Variant" (medium weight), the control is 40 px high on desktop and 44 px high with 16 px text on mobile, and its open list has 36 px options on desktop and 44 px on mobile with a check on the selected one
And the fixture is the library of features/editor_media_picker.feature (north-wall.jpg id 1 stored "1.jpg", 4032 x 3024 JPEG, 3.9 MB) and the variants thumb (cover 400 x 300 webp), card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), all enabled, in name order card, hero, og, square, thumb, and "print" disabled
And every new English string has a French entry in web/src/i18n/messages.ts, and no comment is added to any file
Rule: The Variant select
Scenario: Selecting an image shows the select with Original chosen
Given the Library tab shows the fixture
When the user selects "north-wall.jpg"
Then GET /v1/image-variants is sent once
And the panel shows the thumbnail row, then the label "Variant" over a select reading "Original · 4032×3024 · JPEG", then the url line "/static/uploadedImages/1.jpg", then the alt text field
And "Insert image" is enabled
Scenario: The open list offers Original and the enabled variants
When the user opens the select
Then the options read, in order:
| Original · 4032×3024 · JPEG |
| Card · 800 × auto · WebP |
| Hero · 1200×630 · WebP |
| Og · 1200×630 · JPEG |
| Square · 600×600 · JPEG |
| Thumb · 400×300 · WebP |
And "Print" is not offered
And the select border is primary with a 1 px primary ring while it is open
Scenario: Choosing a variant changes the url line
When the user chooses "Hero · 1200×630 · WebP"
Then the select reads that option and the url line reads "/static/uploadedImages/1.jpg?v=hero"
Scenario: The choice is reset when another image is selected
Given "north-wall.jpg" is selected with the variant "Hero"
When the user selects "kitchen-after.webp"
Then the select reads "Original · 2400×1600 · WEBP" and the url line has no "?v="
Scenario: The variants list is not requested again within one opening
Given the user selected one image and then another
Then GET /v1/image-variants was sent once
Scenario: No variants defined
Given GET /v1/image-variants resolves {"items": []}
When the user selects "north-wall.jpg"
Then the select offers only "Original · 4032×3024 · JPEG"
And the help line reads "No variants defined. Add one in Settings → Media → Variants." with an info icon, and "Settings → Media → Variants" links to /settings/media/variants
And the url line is not shown
Scenario: Only disabled variants count as none
Given every variant is disabled
Then the select offers only Original and the "No variants defined" help shows
Scenario: An SVG offers only Original
When the user selects "logo-renew.svg"
Then the select offers only "Original · Vector · SVG" and no help line shows
Scenario Outline: The variants list fails to load
Given GET /v1/image-variants <failure>
When the user selects "north-wall.jpg"
Then the select offers only Original, no error alert shows, and "Insert image" is enabled
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Keyboard
Given the select has focus
Then Enter or Space opens it, the arrow keys move between options, Enter chooses, Escape closes it and returns focus to the select
Rule: Inserting a variant
Scenario Outline: The inserted url carries the variant
Given "north-wall.jpg" is selected with the variant "<option>" and the alt text "A wall"
When the user activates "Insert image"
Then insertImage is called with "<src>", "A wall" and image id 1
And the image is in the editor's content with data-image-id 1
And SaveStatus reads "Unsaved changes", the toast "Image added" shows, the dialog closes and no POST, PATCH or DELETE has been sent
Examples:
| option | src |
| Original | /static/uploadedImages/1.jpg |
| Hero | /static/uploadedImages/1.jpg?v=hero |
| Thumb | /static/uploadedImages/1.jpg?v=thumb |
Scenario: The Upload tab is unchanged
When the user inserts an image from the Upload tab
Then the select is not shown and the inserted url has no "?v="
Scenario: An image already in the article can be inserted again with another variant
When the user selects "north-wall.jpg", which carries "In this article", chooses "Thumb" and activates "Insert image"
Then a second image node with src ending "?v=thumb" and data-image-id 1 is inserted
Scenario: The cache-busting parameter coexists with the variant
Given the media revision is 3
Then an image node whose src is "/static/uploadedImages/1.jpg?v=hero" is displayed as "/static/uploadedImages/1.jpg?v=hero&rev=3"
Scenario: Leaving without inserting changes nothing
Given a variant is chosen
When the user cancels, closes or presses Escape
Then the dialog closes, the editor's content and dirty state are unchanged and no toast shows
Rule: A block whose variant was deleted or disabled
Background:
Given the article body holds an image node with src "/static/uploadedImages/1.jpg?v=hero" and data-image-id 1
Scenario: A deleted variant
Given GET /v1/image-variants resolves without "hero"
When the editor opens the article
Then the block keeps its src and its data-image-id
And the block is a 1 px --ui-warning bordered box holding the image dimmed to 50 % with an image-off icon, then a warning alert
And the alert has a triangle-alert icon, the title "Variant 'hero' no longer exists.", the text "This image returns an error on the public site." and a button "Pick another variant" with a layers icon
And the block does not show "This image is no longer available"
Scenario: A disabled variant
Given GET /v1/image-variants resolves with "hero" disabled
When the editor opens the article
Then the same block shows the title "Variant 'hero' is disabled." and the same text and button
Scenario: An enabled variant has no warning but a chip
Given GET /v1/image-variants resolves with "hero" enabled (cover 1200 x 630)
Then the block shows no warning
And a chip "hero · 1200×630" sits 8 px from the top-left of the image, mono 12 px medium, 20 px high, on --ui-bg with a 1 px accented border
Scenario: A disabled variant still shows its chip
Given "hero" is disabled
Then the chip "hero · 1200×630" shows on the dimmed image
Scenario: A deleted variant shows no chip
Given "hero" is deleted
Then no chip shows on the block
Scenario: A block without a variant is unchanged
Given an image node with src "/static/uploadedImages/1.jpg"
Then it shows no chip and no warning and behaves as before
Scenario: The variants list is requested only when needed
Given the article body holds no "?v=" in an image src
When the editor opens the article
Then GET /v1/image-variants is not sent
Scenario: The list is requested once for all blocks
Given the body holds two blocks with "?v=hero" and "?v=thumb"
Then GET /v1/image-variants is sent once and each block is judged against it
Scenario Outline: The variants list fails
Given GET /v1/image-variants <failure>
When the editor opens the article containing "?v=hero"
Then no warning and no chip show and the editor stays usable
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Pick another variant opens the select on the block
When the user activates "Pick another variant"
Then the same Variant select opens anchored on the alert with Original and the enabled variants
And Escape closes it and returns focus to the button
Scenario Outline: Choosing an option rewrites the block
Given the select is open on the block of image 1
When the user chooses "<option>"
Then the block's src becomes "<src>", its data-image-id and alt are unchanged
And the warning disappears and the chip shows "<chip>" when a variant is chosen
And SaveStatus reads "Unsaved changes" and no request other than the already made list request is sent
Examples:
| option | src | chip |
| Thumb | /static/uploadedImages/1.jpg?v=thumb | thumb · 400×300 |
| Original | /static/uploadedImages/1.jpg | no chip |
Scenario: Only the clicked block is rewritten
Given two blocks of image 1 both with "?v=hero"
When the user rewrites the first one
Then the second keeps "?v=hero" and its warning
Scenario: The warning is not saved in the article
When the article is saved
Then the saved body contains the img element with its src and no warning markup
Scenario: The warning is announced
Then the alert has role="alert" semantics or an aria-live region so that a screen reader reads the title
Scenario: Touch targets
Given the viewport is below 640 px
Then the "Pick another variant" button is at least 44 px high
Rule: Accessibility, vocabulary and hygiene
Scenario: Vocabulary
Then the screens say "variant", never "thumbnail preset", "version" or "rendition"
Scenario: French strings
Given the locale is French
Then every new string has a French entry and no English string appears
@@ -0,0 +1,44 @@
# QA: Editor picker Variant select and block warnings
## Implementation note for coder (informational, not a QA assertion)
No Go change. The variant of a block is read from the `v` query parameter of the image node's src; the lookup is by name against GET /v1/image-variants (all variants, enabled or not). The hosted `rev=` cache-bust parameter from withMediaRevision is appended after `?v=` and is ignored by the server. A broken variant block must not use the existing "This image is no longer available" message, which says the image was deleted. Only enabled variants are offered in the select. New strings need English and French entries in web/src/i18n/messages.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Reuse `web/src/views/__tests__/EditorMediaPicker.spec.ts` and its fixtures. Mock useApi's `request` per case (GET /v1/media/, GET /v1/image-variants). Mount EditorView at /article/12/edit with a resolved verified session (role "owner"). Mock `matchMedia` for the 640 px breakpoint, run layout cases at both widths. Fake timers only for the 150 ms skeletons and 120 ms debounce already used there. No test freezes the system date.
1. **Select, default** - selecting "north-wall.jpg" sends GET /v1/image-variants once; the panel order is thumbnail row, "Variant" label, select, url line, alt text; Original is chosen; "Insert image" enabled.
2. **Options** - the six options in order and format labels; "Print" (disabled) absent; the open style (primary border, check); the url line for Original and for a variant; the choice resets when another image is selected; the list is not requested again within one opening.
3. **No variants** - `{"items": []}` and all-disabled: only Original, the help line and its link target, no url line.
4. **SVG** - only "Original · Vector · SVG" and no help.
5. **List failure, Examples-driven** - 500, 403 and network failure: Original only, no alert, insert works.
6. **Keyboard** - Enter and Space open, arrows move, Enter chooses, Escape closes and returns focus.
7. **Insert, Examples-driven** - Original, Hero, Thumb: insertImage arguments, data-image-id, dirty state, toast, closing, no write request. Upload tab unchanged. Inserting an image already in the article twice. The `rev=` parameter after `?v=`.
8. **Cancel paths** - Cancel, x, Escape leave the editor untouched.
9. **Block states** - deleted variant (title, text, button, border, dimmed placeholder with image-off, no "no longer available"), disabled variant (title), enabled variant (no warning, chip text `hero · 1200×630`, chip classes), disabled variant chip, deleted variant no chip, plain src unchanged.
10. **When the list is requested** - not requested for a body without `?v=`; requested once for several blocks; failure Examples (500, 403, network): no warning, editor usable.
11. **Pick another variant** - the select opens on the block, Escape closes with focus back; choosing Thumb rewrites only that block's src to `?v=thumb`, keeps data-image-id and alt, the warning disappears and the chip shows, dirty state set; choosing Original removes the parameter and shows no chip; a second block of the same image is untouched.
12. **Save** - the saved HTML contains the img element and no warning markup.
13. **Accessibility** - role or live region on the warning, 44 px button class on mobile, vocabulary scan.
14. **French** - `setLocale('fr')`; every new key exists in messages.ts and the dialog and block render without missing keys.
## Visual (task visual:check -- image-variants <state>, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, exact values in `docs/design/source/VariantsFrame.dc.html` (Picker, VSel, Editor). Add each state to `web/visual/states.ts` with the board's data: the article "How we price a bathroom renovation", the library tiles north-wall.jpg (selected), the five enabled variants in board order thumb, card, hero, square, og, and the block chip "hero · 1200×630". The gate is hard: every non-skipped capture within 10 %. None is clock-sensitive.
- [ ] 6a Variant select (Hero chosen, url line shows `?v=hero` as drawn with the real path)
- [ ] 6b Select open (Original plus five enabled variants)
- [ ] 6c No variants defined (Original only, help line)
- [ ] 6d Variant deleted (block warning "Variant 'hero' no longer exists.")
- [ ] 6e Variant disabled (block warning "Variant 'hero' is disabled.")
- [ ] Layout from the board frame: select label and control heights (40 px / 44 px, 16 px text on mobile), option height (36 px / 44 px), the block box (1 px warning border around the dimmed image and the alert), chip position (8 px from the top-left).
- [ ] The board draws the url line as `/media/north-wall.jpg?v=hero`; the capture will differ by that string only. If the 10 % gate fails on it, ask the user for a per-state exception (needs `approvedBy`), do not edit the board.
- [ ] Not drawn, no visual state: the Alt text field under the select, list failure, SVG, keyboard.
- [ ] Not covered by screenshots: keyboard and focus order, focus return after the select closes, hover, motion.
## Manual verification
- **Round trip against `task dev`**: insert an image with a variant, save the article, open the public article and confirm the image loads from the variant url; delete or disable that variant in Settings, reload the editor and check the warning; use "Pick another variant" and save again.
- **French**: read the select, its help and the block warning.
- **Real browser** at mobile width: select list and block warning touch targets.
@@ -0,0 +1,430 @@
Feature: Image variants on the Media page: badge, filter, sort, Variants tab, delete dialogs and activity
As an owner or admin of a RenewCMS site
I want the Media page to show which images have generated variants, let me generate, preview and purge them for one image, and warn me what a delete also removes
So that I can keep the cache of variants under control without leaving the library
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 3, 4 and 8 (the Generate all and Cache purged: by image rows), docs/design/screens/media.md, docs/design/screens/activity.md, the board docs/design/source/RenewCMS Screens Image variants.dc.html drawn by VariantsFrame.dc.html (frames 3a to 3f and 4a to 4h), and docs/design/routes.md
And this slice is SPA only: the API is the one of features/image_variants_per_image_api.feature and features/image-variants.feature, and features/media_browse.feature and features/media_upload_selection_duplicates.feature stay as they are except where this file says so
And the editor picker is in features/image_variants_editor_picker.feature, and the Settings → Media → Cache screen is not in this slice
And every new English string has a French entry in web/src/i18n/messages.ts, only design copy is verbatim, and French wording is proposed
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour, and no comment is added to any file
And the stored url of an image is "/static/uploadedImages/<storedName>" and the url of its variant is that plus "?v=<name>"
And sizes use the library's formatter ("8 KB", "260 KB", "3.9 MB"), dimensions in the library read "4032 × 3024" and in variants "400×300", "800 × auto", "auto × 2400"
And the library fixture is the 12-image fixture of features/media_browse.feature, extended with variant_count and variant_size from the board:
| id | filename | size | variant_count | variant_size | total |
| 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4.2 MB |
| 2 | kitchen-after.webp | 1258291 | 5 | 419840 | 1.6 MB |
| 3 | kitchen-before.webp | 1153434 | 5 | 404480 | 1.5 MB |
| 4 | logo-renew.svg | 8192 | 0 | 0 | 8 KB |
| 5 | floorboards-copy.jpg | 2516582 | 3 | 245760 | 2.6 MB |
| 6 | IMG_2208.jpg | 2516582 | 0 | 0 | 2.4 MB |
| 7 | IMG_4471.jpg | 3250586 | 2 | 153600 | 3.3 MB |
| 8 | window-frame.jpg | 1887436 | 0 | 0 | 1.8 MB |
| 9 | stone-wall-detail.jpg | 3250586 | 1 | 61440 | 3.2 MB |
| 10 | bathroom-tiles.png | 880640 | 3 | 194560 | 1.1 MB |
| 11 | lime-plaster.jpg | 2202009 | 4 | 286720 | 2.4 MB |
| 12 | floorboards.jpg | 2516582 | 5 | 440320 | 2.8 MB |
And the five enabled variants of the Variants tab, in the order the API returns them (name), are card (inside 800 x auto webp), hero (cover 1200 x 630 webp), og (cover 1200 x 630 jpeg), square (cover 600 x 600 jpeg), thumb (cover 400 x 300 webp), and the disabled variant "print" is never listed
And the Variants tab of image 1 reads its rows from GET /v1/media/1/variants: output 800×600, 1200×630, 1200×630, 600×600, 400×300 and sizes in bytes thumb 18432, card 65536, hero 98304, square 83968, og 145408
And the session role comes from GET /v1/auth/me and is "owner" unless a scenario says otherwise
And every success toast lasts 5 s and toast ids follow the action-plus-id convention
Rule: The library badge
Scenario: A card with generated variants carries the badge
Given GET /v1/media/ resolves 200 with the extended fixture
When the user navigates to /media
Then the card "north-wall.jpg" shows the badge "4 variants" after "Used in 2"
And the card "kitchen-after.webp" shows "Used in 3" then "5 variants"
And the badge has the info-soft fill, the layers icon, 12 px medium text and is 20 px high
Scenario Outline: The badge text and its absence
Given the Default state has loaded
Then the card for "<filename>" shows the badges "<badges>"
Examples:
| filename | badges |
| north-wall.jpg | Used in 2, 4 variants |
| stone-wall-detail.jpg | Duplicate, Used in 1, 1 variant |
| window-frame.jpg | Unused |
| logo-renew.svg | Used in 5 |
| IMG_2208.jpg | Duplicate, Used in 2 |
Scenario: The accessible name of a card is unchanged
Then the card for "north-wall.jpg" is still named "north-wall.jpg, 3.9 MB, used in 2 articles"
Scenario: List view shows no variants badge
Given the Default state has loaded with ?view=list
Then the table and the card list show no "variants" badge
Rule: The variants filter
Scenario: The segmented control sits between the search field and the sort menu
Given the Default state has loaded at desktop width
Then the toolbar reads, left to right, the tabs, the search field, a segmented control "All", "With variants", "Without" (30 px high, 1 px accented border, 12 px horizontal padding) and the sort menu
And "All" is selected, with the primary fill and inverted text
Scenario: On mobile the control is its own full-width row
Given the Default state has loaded below md width
Then the toolbar stacks search, tabs, the segmented control with three equal segments of 44 px and 12 px text, then the sort menu and the grid toggle
Scenario Outline: Choosing a segment writes the URL and requests the list
Given the Default state has loaded
When the user selects "<segment>"
Then the URL <url> and GET /v1/media/ is sent with "<request>" and limit=100
And the list shows a skeleton after 150 ms
Examples:
| segment | url | request |
| With variants | gains ?variants=with | filter=all&sort=newest&variants=with |
| Without | gains ?variants=without| filter=all&sort=newest&variants=without |
Scenario: All removes the parameter
Given the URL is /media?variants=with
When the user selects "All"
Then ?variants leaves the URL and the request carries no variants parameter
Scenario Outline: The query string is the source of truth
When the user opens /media<url>
Then GET /v1/media/ is sent with "<request>" and the control shows "<control>"
Examples:
| url | request | control |
| ?variants=with | filter=all&sort=newest&variants=with | segment "With variants" selected |
| ?variants=without&filter=unused | filter=unused&sort=newest&variants=without | tab "Unused" and segment "Without" selected |
| ?variants=all | filter=all&sort=newest | segment "All" selected, URL kept clean |
| ?variants=zzz | filter=all&sort=newest | segment "All" selected, URL kept clean |
Scenario: With variants returns only the images that have cached files
Given GET /v1/media/?...&variants=with&sort=total_size resolves 200 with ids 1, 7, 9, 12, 5, 11, 2, 3, 10
Then nine cards show in that order and each carries a variants badge
Scenario: Without variants returns the images with no cached file
Given GET /v1/media/?...&variants=without resolves 200 with ids 4, 6, 8
Then three cards show and none carries a variants badge
Rule: Sorting by total size
Scenario: The new option is last in the sort menu
When the user opens the sort menu
Then the options read "Newest", "Oldest", "Name", "Largest", "Largest total size (original + variants)" in that order, the last one 340 px wide on desktop and labelled "Largest total size" on mobile
And the selected option carries a check
Scenario: Choosing it sends sort=total_size
When the user picks "Largest total size (original + variants)"
Then the URL gains sort=total_size and GET /v1/media/ is sent with sort=total_size
And the sort button reads "Largest total size (original + variants)" on desktop and "Largest total size" on mobile
Scenario Outline: The meta line shows the total while sorted by total size
Given the list is sorted by total size
Then the card for "<filename>" shows the meta line "<meta>" in place of "size · dimensions"
Examples:
| filename | meta |
| north-wall.jpg | 4.2 MB total · 3.9 MB + 260 KB |
| kitchen-after.webp | 1.6 MB total · 1.2 MB + 410 KB |
| window-frame.jpg | 1.8 MB total |
| logo-renew.svg | 8 KB total |
Scenario: Other sorts keep the usual meta line
Given the list is sorted by Newest
Then the card for "north-wall.jpg" shows "3.9 MB · 4032 × 3024"
Scenario: A stale sort value is dropped
When the user opens /media?sort=total
Then the sort shows "Newest" and the URL is clean
Rule: Nothing to show with the variants filter
Scenario: With variants and an empty cache
Given GET /v1/media/?...&variants=with resolves 200 with no item and no q is set and the tab is All
Then the layers icon shows with the title "No images with variants", the text "Variants are generated when an image is requested with ?v=<name>, or from its Variants tab." and an outline button "Show all images"
And the toolbar, including the segmented control, stays visible
Scenario: Show all images resets the variants filter
When the user activates "Show all images"
Then ?variants leaves the URL, the segmented control reads "All" and the Default cards reappear
Rule: Delete dialogs name the generated variants they also remove
Scenario: A single delete of an image with variants
Given the detail panel is open for "north-wall.jpg" (used in 2, 4 variants of 266240 bytes)
When the user activates "Delete"
Then the dialog keeps its title "Delete an image that's in use?", its description and its list of the two articles
And a note shows in the body with the layers icon on a warning-soft background: "Also removes 4 generated variants (260 KB)"
And no extra request is sent to compute it
Scenario Outline: The note is singular for one variant and absent for none
Given the detail panel is open for "<filename>"
When the user activates "Delete"
Then the note reads "<note>"
Examples:
| filename | note |
| stone-wall-detail.jpg | Also removes 1 generated variant (60 KB) |
| window-frame.jpg | no note is shown |
Scenario: Bulk delete sums the selection
Given the user selected "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg"
When the user activates "Delete" in the bulk bar
Then the bulk dialog keeps its copy and its one row per image
And the note under the rows reads "Also removes 9 generated variants (670 KB)"
Scenario: A bulk delete with no variant in the selection shows no note
Given the user selected "window-frame.jpg" and "IMG_2208.jpg"
Then the dialog shows no note
Scenario: The delete flow itself is unchanged
Then the pending, failure, 404 and success behaviours of features/media_browse.feature apply, and after a successful delete the list is requested again in the background
Rule: The Variants tab in the detail panel
Scenario: The panel gets two tabs for an owner or an admin
When the user opens the panel of "north-wall.jpg"
Then two tabs sit under the 56 px header: "Details" (selected) and "Variants", 40 px high on desktop and 44 px on mobile, 20 px apart, with a 2 px primary underline on the selected one
And the Details tab is the existing panel body, unchanged
Scenario: A session that is not owner or admin sees no tab
Given the session role is neither "owner" nor "admin"
When the user opens a panel
Then no tab strip shows, no request to /v1/media/{id}/variants is sent, and the panel is the existing one
Scenario: Selecting the tab requests the variants once
When the user selects "Variants"
Then GET /v1/media/1/variants is sent
And selecting "Details" then "Variants" again does not send it a second time for the same open panel
Scenario: Loading
Given GET /v1/media/1/variants has not answered
Then nothing is drawn in the tab body for 149 ms
And at 150 ms a region announces "Loading variants" with aria-busy="true" and holds 5 skeleton rows
Scenario Outline: The variants request fails
Given GET /v1/media/1/variants fails with <failure>
Then an error alert shows the title "Couldn't load variants", the text "Check your connection and try again." and a "Try again" button with a rotate-cw icon
When the user activates "Try again"
Then the request is sent again
Examples:
| failure |
| status 500 |
| a network failure |
Scenario: Partly generated
Given GET /v1/media/1/variants resolves with card, hero, square and thumb generated and og not
Then the tab shows, in order: the Original block, the summary, the list
And the Original block (72 x 54 thumbnail, 12 px gap, 12 px padding, 1 px border, --ui-bg-muted) reads "Original · kept untouched", "north-wall.jpg", "JPEG · 4032 × 3024 · 3.9 MB"
And the summary reads "4 of 5 generated" in semibold next to a "Generate all" button with the zap icon
And the list is a bordered group of five rows in the order card, hero, og, square, thumb
Scenario Outline: A generated row
Then the row "<name>" shows its name in mono 13 px medium, the badge "Generated" (success-soft, circle-check), and the meta "<meta>"
And the part "<saving> vs original" is in the success colour and the meta before it is toned 12 px text
Examples:
| name | meta | saving |
| thumb | 400×300 · WebP · 18 KB · -99% | -99% |
| card | 800×600 · WebP · 64 KB · -98% | -98% |
| hero | 1200×630 · WebP · 96 KB · -98% | -98% |
| square | 600×600 · JPEG · 82 KB · -98% | -98% |
Scenario: A not generated row
Then the row "og" shows the badge "Not generated" (outline, circle-dashed) and the meta "1200×630 · JPEG" with no size and no saving
Scenario Outline: The saving against the original
Given the original is 4089446 bytes
When a variant file is <size> bytes
Then the saving reads "<text>"
Examples:
| size | text |
| 18432 | -99% |
| 65536 | -98% |
| 98304 | -98% |
| 145408 | -96% |
| 3250586 | -21% |
| 4089446 | 0% |
| 4500000 | +10% |
Scenario: Nothing generated yet
Given GET /v1/media/1/variants resolves with no row generated
Then the summary reads "0 of 5 generated" over the muted line "Nothing generated yet."
And every row shows "Not generated"
Scenario: Row actions on desktop are icon buttons
Given the viewport is 640 px wide or more
Then a generated row shows three 28 px square icon buttons Copy URL (link), Preview (eye) and Purge (eraser), each with a tooltip and an accessible name equal to its label
And a not generated row shows Copy URL (link) and Generate now (zap)
Scenario: Row actions on mobile are labelled buttons on their own line
Given the viewport is below 640 px
Then each action is a 44 px high labelled button on a line under the meta, "Copy URL", "Preview", "Purge" or "Copy URL", "Generate now"
Scenario: Generate all is disabled when everything is generated
Given all five rows are generated
Then "Generate all" is disabled
Scenario: Generate now
When the user activates "Generate now" on the row "og"
Then POST /v1/media/1/variants/og is sent
And the row "og" shows the badge "Generating" (info-soft, loader-circle), its actions are gone, its background is --ui-bg-elevated and the other rows stay as they are
And the summary shows "Generating variants…" in the info colour and "Generate all" reads "Generating…" with a loader-circle and is disabled
When the API answers 200 with the item generated
Then the row shows "Generated" with its meta, the summary reads "5 of 5 generated"
And a toast shows icon circle-check, id "media-variant-generated-1-og" and title "Generated og"
Scenario: Generate all
Given only thumb is generated
When the user activates "Generate all"
Then POST /v1/media/1/variants is sent
And the four rows not generated show "Generating", the generated row stays, and the summary and button read as above
When the API answers 200 with "generated" 4 and the five items
Then every row shows "Generated" and a toast shows id "media-variants-generated-1" and title "Generated 4 variants"
Scenario: A single generated variant reads in the singular
When Generate all answers "generated" 1
Then the toast title is "Generated 1 variant"
Scenario: Generate all with a failure
When Generate all answers 200 with "generated" 4, "failed" 1, failures [{"name": "hero", "cause": "too_large"}] and the five items
Then the row "hero" shows the badge "Failed" (error-soft, circle-x), a line with a circle-alert icon in the error colour at 12 px reading "Image too large", and a "Retry" button with a rotate-cw icon (labelled, 28 px high, 12 px text)
And the summary reads "4 of 5 generated" and the toast reads "Generated 4 variants"
Scenario Outline: A single generation fails with a cause
When POST /v1/media/1/variants/hero answers <status> with the plain text body
Then the row "hero" shows "Failed" and the line "<cause>" with "Retry"
And an error toast shows the title "Couldn't generate hero." and the text "<cause>."
Examples:
| status | cause |
| 413 | Image too large |
| 415 | Unsupported source format |
Scenario: Failed state is not remembered
Given the row "hero" shows "Failed"
When the panel is closed and opened again, and the tab is selected
Then the row "hero" shows "Not generated"
Scenario: Retry sends the same request
When the user activates "Retry" on the row "hero"
Then POST /v1/media/1/variants/hero is sent and the row shows "Generating"
Scenario Outline: Other failures of a generation
When POST /v1/media/1/variants/og answers <status>
Then the row "og" returns to "Not generated"
And <result>
Examples:
| status | result |
| 500 | a toast shows "Something went wrong" and "The server couldn't finish. Try again. If it keeps happening, check the server log." |
| 403 | a toast shows "You don't have access" and "Only admins can manage image variants." |
| 404 | a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the tab is requested again |
| network failure | the same toast as 500 |
Scenario: The image is gone
When the tab is requested again after a 404 and GET /v1/media/1/variants answers 404
Then the panel closes, the image leaves the list and the info toast "That image no longer exists" shows, as in features/media_browse.feature
Scenario: Copy URL copies the absolute variant url
When the user activates "Copy URL" on the row "thumb"
Then "{origin}/static/uploadedImages/<storedName>?v=thumb" is written to the clipboard, also for a row that is not generated
And a toast shows id "media-variant-url-copied-1-thumb", title "URL copied" and text "Paste it wherever you need the image address."
Scenario: Purge a generated row
When the user activates "Purge" on the row "thumb"
Then DELETE /v1/media/1/variants/thumb is sent
When the API answers 200 {"purged_files": 1, "purged_bytes": 18432}
Then the row shows "Not generated" with no size, the summary reads "3 of 5 generated" and a toast shows id "media-variants-purged-1-thumb" and title "Purged 1 file (18 KB)"
Scenario Outline: Purge fails
When DELETE /v1/media/1/variants/thumb answers <status>
Then the row stays "Generated" and the toast of the back-office error table for <status> shows
Examples:
| status |
| 403 |
| 404 |
| 500 |
Scenario: Preview
When the user activates "Preview" on the row "thumb"
Then a dialog opens, 880 px wide on desktop and a bottom sheet on mobile, titled "Preview: thumb", with a "Close" button
And two panes sit side by side on desktop (stacked on mobile), each 300 px high (180 px on mobile), bordered, on --ui-bg-muted, overflow scrolling to pan
And the left pane shows the variant file at actual size under "thumb · 400×300 · WebP" and "18 KB · saves 99%"
And the right pane shows the original at actual size under "Original · 4032×3024 · JPEG" and "3.9 MB"
And the hint reads "Both images are shown at actual size. Scroll to pan."
And focus is trapped in the dialog and returns to the Preview button on close
Scenario: No variants defined
Given GET /v1/media/1/variants resolves with no item and "unsupported" null
Then the Original block shows, then the empty block with the layers icon, the title "No variants defined", the text "Define a variant in Settings to get resized copies of this image." and a link "Settings → Media → Variants" with an arrow-up-right icon to /settings/media/variants
And no summary, no list and no "Generate all" show
Scenario: An SVG source
Given the panel is open for "logo-renew.svg" and GET /v1/media/4/variants resolves {"unsupported": "svg", "items": []}
Then the Original block reads "SVG · Vector · 8 KB"
And an info alert shows the title "Variants aren't generated for this format." and the text "SVG images are served as they are."
And no summary, no list and no "Generate all" show
Rule: Generate all and purge by image in the Activity screen
Scenario Outline: The two new sentences
Given an activity event with action "<action>" and outcome "success" and target name "north-wall.jpg"
Then the Action cell reads "<sentence>" with "north-wall.jpg" in semibold, last
Examples:
| action | sentence |
| media.variant_generate_all| Generated all variants of north-wall.jpg |
| media.variant_purge_image | Purged the variants of north-wall.jpg |
Scenario Outline: The failed sentences
Given an activity event with action "<action>", outcome "failed" and target name "north-wall.jpg"
Then the Action cell reads "<sentence>" and the Failed badge shows
Examples:
| action | sentence |
| media.variant_generate_all| Failed to generate the variants of north-wall.jpg |
| media.variant_purge_image | Failed to purge the variants of north-wall.jpg |
Scenario Outline: The detail line under the sentence
Given an activity event with action "<action>" and params <params>
Then a muted 12 px line under the sentence reads "<detail>" in the row and in the details panel
Examples:
| action | params | detail |
| media.variant_generate_all| {"generated": 5, "failed": 0, "causes": []} | 5 variants generated |
| media.variant_generate_all| {"generated": 1, "failed": 0, "causes": []} | 1 variant generated |
| media.variant_generate_all| {"generated": 4, "failed": 1, "causes": ["too_large"]} | 4 generated · 1 failed (Image too large) |
| media.variant_generate_all| {"generated": 3, "failed": 2, "causes": ["too_large", "unsupported_format"]} | 3 generated · 2 failed (Image too large, Unsupported source format) |
| media.variant_purge_image | {"purged_files": 4, "purged_bytes": 266240} | Removed 4 files (260 KB) |
| media.variant_purge_image | {"purged_files": 1, "purged_bytes": 18432} | Removed 1 file (18 KB) |
Scenario: Events without a detail line are unchanged
Given an event whose action has no detail line, such as "media.rename"
Then the row shows no second line
Scenario: The French interface
Given the locale is French
Then the two sentences, their failed forms and the detail lines come from messages.ts and no English string appears
Rule: Accessibility, vocabulary and hygiene
Scenario: Status is never colour alone
Then each row status shows an icon and a word (Generated circle-check, Not generated circle-dashed, Generating loader, Failed circle-x) and each icon-only button has an accessible name and a tooltip
Scenario: Touch targets on mobile
Given the viewport is below 640 px
Then the segmented control, the tabs, the row buttons and the dialog buttons are at least 44 px high
Scenario: Progress is announced
When a generation starts and ends
Then an aria-live="polite" region announces "Generating variants" and the result
Scenario: Vocabulary
Then the screens say "variant", "generated" and "purge", never "thumbnail preset", "version" or "rendition"
Scenario: French strings
Given the locale is French
Then every new string of this file has a French entry and no English string appears
@@ -0,0 +1,60 @@
# QA: Image variants on the Media page
## Implementation note for coder (informational, not a QA assertion)
The API is built by features/image_variants_per_image_api.feature. This slice has no Go change. The role check lives in the panel via the session role from GET /v1/auth/me (there is no "admin" auth requirement in router meta). Plain-text API errors are read through ApiError.body and mapped by status; never display the raw body. The percent-saved helper lives in one module under web/src/lib/variants/ and rounds half away from zero, then clamps the magnitude to 99 while the file is non-empty. New strings need English and French entries in web/src/i18n/messages.ts. A mock of GET /v1/media/{id}/variants returns the API order (name); only the visual mock uses the board order.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` per case (GET /v1/media/, GET /v1/media/{id}/variants, POST /v1/media/{id}/variants[/{name}], DELETE /v1/media/{id}/variants/{name}, DELETE /v1/media/{id}, GET /v1/activity). Reuse `web/src/views/__tests__/mediaTestKit.ts` and `variantsTestKit.ts`; add `variant_count` and `variant_size` to the 12-image fixture of the Background. Mount MediaView inside DashboardShell with a resolved verified session (id 1, role "owner") and a test router at /media. Mock `matchMedia` for the 640 px and md breakpoints; run layout cases at desktop and below. Fake timers for the 150 ms skeleton and for nothing else. Mock navigator.clipboard. No test freezes the system date and none asserts a relative date.
1. **Badge, Examples-driven** - the five card rows, the position after Used in / Duplicate, singular "1 variant", no badge at 0, the unchanged accessible name, no badge in list view.
2. **Filter control** - placement desktop and mobile, selected style, each segment sends `variants=with|without` with `limit=100` and shows the skeleton after 150 ms, "All" removes the parameter, the four URL rows (`?variants=all` and `zzz` dropped and the URL cleaned), combination with `?filter=unused`.
3. **Sort** - the five options in order, the new label and its mobile label, `sort=total_size` in the URL and request, the stale `?sort=total` dropped.
4. **Meta line** - the four rows while sorted by total size, and the usual line under Newest.
5. **Empty filter** - 3e copy, the toolbar stays, "Show all images" removes `?variants` and restores the default cards; no `q` set is the only case this panel shows.
6. **Delete dialogs** - the note for 4 and for 1 variant and its absence at 0, in-use title and description unchanged, the bulk sum "9 generated variants (670 KB)" for the three selected images and no note when the sum is 0, no extra request, the unchanged pending, failure and 404 flows, the background refresh after success.
7. **Tabs** - present for owner and admin, absent for another role with no variants request, `GET /v1/media/{id}/variants` sent once per open panel on first selection of the tab, Details body unchanged.
8. **Loading and load error, Examples-driven** - 149 and 150 ms, aria-busy and label, five skeleton rows, 500 and network alert and Try again.
9. **Partly generated** - Original block copy, summary "4 of 5 generated", row order and every cell of the generated and not generated rows.
10. **Saving percent, Examples-driven** - the seven size rows against 4089446 bytes, plus "-100%" never shown.
11. **Nothing generated, no variants defined, SVG** - copy, link target `/settings/media/variants`, no list, no Generate all.
12. **Row actions** - icon buttons with tooltips and accessible names on desktop, labelled buttons below 640 px, the actions per state, Generate all disabled when all five are generated.
13. **Generate now** - request, Generating row and summary and disabled button (resolve later with fake timers off, using a deferred promise), success toast id and title, 403, 404 (tab reloaded; second 404 runs the image-gone flow), 500 and network rows return the row to Not generated.
14. **Generate all** - request, rows in flight, success toast ("Generated 4 variants", singular "Generated 1 variant"), partial failure rows with causes and Retry, the two single-failure causes (413 and 415) with their toast text.
15. **Failed state is local** - closing and reopening shows Not generated; Retry re-sends the per-name POST.
16. **Copy URL** - absolute url with `?v=`, for a not generated row too, toast id, and a rejected clipboard shows no success toast.
17. **Purge** - request, response `{purged_files: 1, purged_bytes: 18432}`, row, summary and the toast title; 403, 404, 500.
18. **Preview** - copy of both panes and the hint, 880 px / bottom sheet, focus trap and focus return, images are the variant url and the original url.
19. **Activity sentences** - the two sentences, the failed forms, the detail lines for every params row including the singular forms and the two-cause join, no second line for other actions, French catalogue test (every new key present in messages.ts).
20. **Accessibility** - icon plus word for every status, live region text, 44 px classes on mobile, vocabulary scan (no "thumbnail preset", "version", "rendition").
## Visual (task visual:check -- image-variants <state>, desktop and mobile, light and dark)
Reference: docs/design/screens/image-variants.md, board "Image variants", exact values in `docs/design/source/VariantsFrame.dc.html` and `AppFrame.dc.html`. Add each state to `web/visual/states.ts` with the board's data. The gate is hard: every non-skipped capture within 10 %, both viewports, both themes. None of these states is clock-sensitive, so none is skipped. Board sample data: the 12 library images with their tone, sizes, used counts, variant counts and variant sizes (IM in VariantsFrame), the five enabled variants in board order thumb, card, hero, square, og (the mock returns the board order for the capture only), north-wall.jpg 4032 x 3024 3.9 MB, row sizes 18 KB, 64 KB, 96 KB, 82 KB, 142 KB. Session name "Marta Alves".
- [ ] 3a Grid with badges
- [ ] 3b Sort menu open, new option selected
- [ ] 3c With variants, sorted by total size (ids 1, 7, 9, 12, 5, 11, 2, 3, 10)
- [ ] 3d Without variants (ids 4, 6, 8)
- [ ] 3e Nothing to show (With variants, empty)
- [ ] 3f Delete confirm for north-wall.jpg with the "Also removes 4 generated variants (260 KB)" note
- [ ] 4a Partly generated (thumb, card, hero, square generated, og not)
- [ ] 4b Nothing generated yet
- [ ] 4c Generating (thumb generated, card and hero generating, square and og not) - reached by pressing Generate all against a mock whose POST stays pending
- [ ] 4d Generation failed (card "Unsupported source format", hero "Image too large", others generated) - reached by a Generate all that resolves with those failures
- [ ] 4e No variants defined
- [ ] 4f SVG source (logo-renew.svg)
- [ ] 4h Preview dialog on "thumb"
- [ ] Layout from the board frame, not components.md: the segmented control (30 px desktop, 44 px mobile segments, accented border), the badge fill and icon, the panel tabs (40 px / 44 px), the Original block (72 x 54 thumbnail), the row paddings (10 px 12 px) and gaps (4 px desktop, 8 px mobile), the 28 px square icon buttons, the preview pane heights (300 px / 180 px).
- [ ] 4g (animated GIF) has no state: the source cannot be uploaded.
- [ ] Not drawn, no visual state: load error, toasts, the 403/404/500 paths, bulk delete note, the Activity sentences and detail lines (Vitest only).
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the tab strip, Generate all, each row's buttons in order, the dialogs trap focus, Escape closes Preview), tooltips on hover, motion.
## Manual verification
- **Round trip against `task dev`** with real images: upload an image, open its Variants tab, press Generate now on one row and Generate all, check the sizes and "saves N%" against the files, Purge a row, Preview at actual size, Copy URL then open the pasted URL.
- **Library**: filter With variants / Without and sort by total size against the real cache; delete an image with variants and confirm the note matches the files removed.
- **Real clipboard** in a secure context (localhost).
- **French**: switch the language and read the tab, the dialogs, the toasts and the Activity rows.
- **Activity**: read the two new rows and their detail lines in the real Activity screen against section 8.
@@ -0,0 +1,514 @@
Feature: Image variants per image: library fields, per-image routes and generate on upload
As an owner or admin of a RenewCMS site
I want to see, generate and purge the variants of one image, and to have variants generated when I upload
So that the media screens show what is cached for each image, and the activity log records when
I generate all the variants of an image or purge them
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 3, 4, 6 and 8 and its "Back-office API errors" table
And the routes the design marks "proposed" are fixed here, and features/image-variants.feature is the contract for the variants themselves
And this slice is API only: the SPA is in features/image_variants_media_screens.feature and features/image_variants_editor_picker.feature, docs/design is unchanged and the public serving of ?v=<name> is unchanged
And it excludes the Settings → Media → Cache routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge)
And the stored name of an image is image.StoredName(Path), the public url of one of its variants is "/static/uploadedImages/<storedName>?v=<name>", and a variant file is generated by the same use case path as a public request (same limits, same render slots, same singleflight key, same index row), so a file generated here is a cache hit for the public url and the other way round
And a variant file counts as generated for an image only when its index row exists and carries the variant's current settings fingerprint
And internal/domain/image gains the sentinels ErrVariantSourceUnsupported (an SVG source) and ErrVariantSourceUnreadable (the source cannot be decoded), asserted with errors.Is, and reuses ErrSourceTooLarge, ErrVariantNotFound and ErrImageNotFound
And internal/domain/image gains one pure function returning the output width and height for a source size and a variant's settings, following the table of features/image-variants.feature "Output geometry never upscales", and both the renderer and the per-image listing use it
And image.VariantFileRepository gains one method returning, for every source at once, the number of index rows and their total bytes keyed by source stored name, so a library page never queries per image
And internal/domain/audit gains the actions media.variant_generate_all and media.variant_purge_image (category "media", target {"type": "image", "id": "<image id>", "name": "<filename>"}) and the params generated, failed and causes
And the new routes are mounted in api/router.go inside NewMediaRouter, in the group behind middleware.RequireOwnerOrAdmin, so a request with an API key has no user_id claim and answers 401
And request validation, when there is a body, goes through validate tags and validator.Validate.Struct, and error responses are plain text from http.Error
And new apperr constants are VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format." and VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated.", and the existing VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant." is reused
And a per-image variant is JSON {"name", "width", "height", "fit", "format", "output_width", "output_height", "output_format", "generated", "size", "url"} where width and height are the variant's settings (null when auto), output_* are the dimensions and format the file has or will have, generated is a boolean, size is the file size in bytes or null when not generated, and url is the public url above
And a generation failure cause is one of "too_large" (ErrSourceTooLarge), "unsupported_format" (ErrVariantSourceUnreadable) or "failed" (anything else)
And every library item gains the integers variant_count and variant_size (the number of index rows of that image whatever the variant's state, and their bytes), both 0 for an image with no file
Rule: Only owners and admins reach the per-image variant routes
Scenario Outline: Every per-image variant route is guarded
When the client sends <method> <path> with <credentials>
Then the response status is <status>
And the plain text body is <body>
And no variant, index row, blob or activity event is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/media/1/variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/1/variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/1/variants | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/1/variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| DELETE | /v1/media/1/variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
| POST | /v1/media/1/variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed
When <role> sends GET /v1/media/1/variants with a valid cookie
Then the response status is 200
Examples:
| role |
| the owner |
| an admin |
Scenario Outline: A malformed id or an unknown image
When an admin sends <method> /v1/media/<id>/variants<suffix>
Then the response status is <status> and the plain text body is <body>
Examples:
| method | id | suffix | status | body |
| GET | abc | | 400 | apperr.IdUint32ErrorMessage |
| POST | abc | | 400 | apperr.IdUint32ErrorMessage |
| GET | 9999 | | 404 | Image not found |
| POST | 9999 | | 404 | Image not found |
| POST | 9999 | /hero | 404 | Image not found |
| DELETE | 9999 | /hero | 404 | Image not found |
Rule: Listing the variants of one image
Background:
Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG of 4089446 bytes stored as "a1b2.jpg"
And image 2 "logo.svg" is an SVG stored as "e5f6.svg"
And the variants, in name order: "card" (inside, 800 x auto, webp, 80), "hero" (cover, 1200 x 630, webp, 82), "og" (cover, 1200 x 630, jpeg, 80), "print" (inside, auto x 2400, png, disabled), "square" (cover, 600 x 600, jpeg, 85), "thumb" (cover, 400 x 300, webp, 80), all enabled unless said
And the index holds current rows for a1b2.jpg: thumb 18432 bytes, card 65536, hero 98304, square 83968, and a row for the disabled "print" of 3250586 bytes
Scenario: The enabled variants, in name order, with their output and what is cached
When an admin sends GET /v1/media/1/variants
Then the response status is 200
And "unsupported" is null
And "items" are card, hero, og, square, thumb, in that order, and "print" is absent
And the items read:
| name | width | height | output_width | output_height | output_format | generated | size |
| card | 800 | null | 800 | 600 | webp | true | 65536 |
| hero | 1200 | 630 | 1200 | 630 | webp | true | 98304 |
| og | 1200 | 630 | 1200 | 630 | jpeg | false | null |
| square | 600 | 600 | 600 | 600 | jpeg | true | 83968 |
| thumb | 400 | 300 | 400 | 300 | webp | true | 18432 |
And the url of "thumb" is "/static/uploadedImages/a1b2.jpg?v=thumb"
Scenario: A row whose fingerprint is stale is not generated
Given the index row of "hero" for a1b2.jpg carries the fingerprint of an older quality
When an admin sends GET /v1/media/1/variants
Then "hero" has generated false and size null
Scenario Outline: A variant in the original format takes the format of the source
Given a variant "keep" with format original over a source of content type <content_type> and webp encoding <webp>
When an admin sends GET /v1/media/<id>/variants
Then "keep" has output_format "<output_format>"
Examples:
| content_type | webp | output_format |
| image/jpeg | available | jpeg |
| image/png | available | png |
| image/webp | available | webp |
| image/webp | unavailable | png |
Scenario Outline: The output size never exceeds the source
Given a source of <sw> x <sh> pixels and a variant with width <w>, height <h> and fit <fit>
When an admin sends GET /v1/media/<id>/variants
Then the variant has output_width <ow> and output_height <oh>
And they equal the dimensions the renderer produces for the same source and settings
Examples:
| sw | sh | w | h | fit | ow | oh |
| 4032 | 3024 | 800 | | cover | 800 | 600 |
| 4032 | 3024 | | 2400 | inside | 3200 | 2400 |
| 4032 | 3024 | 8000 | 8000 | cover | 3024 | 3024 |
| 300 | 200 | 800 | | cover | 300 | 200 |
| 300 | 200 | 400 | 300 | cover | 267 | 200 |
| 300 | 200 | 400 | 300 | contain | 400 | 300 |
| 300 | 200 | 1200 | 1200 | inside | 300 | 200 |
Scenario: A source that no variant can be generated for answers an empty list
When an admin sends GET /v1/media/2/variants
Then the response status is 200 and the body is {"unsupported": "svg", "items": []}
Scenario: No enabled variant answers an empty array, not null
Given every variant is disabled
When an admin sends GET /v1/media/1/variants
Then the response status is 200 and the raw body holds "items":[] and "unsupported":null
Scenario: Listing reads nothing it should not change
When an admin sends GET /v1/media/1/variants
Then no blob is opened, no render happens, no row is written and no activity event is recorded
Rule: Generating one variant of one image
Background:
Given image 1 "north-wall.jpg" is a 1000 x 800 PNG stored as "a1b2.png"
And the variants "thumb" (cover 400 x 300 jpeg 80, enabled), "hero" (cover 1200 x 630 jpeg 82, enabled) and "off" (inside 300 x auto png, disabled)
And no generated file exists yet
Scenario: The first generation renders, stores and indexes the file
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 200
And the body is the per-image variant "thumb" with generated true, output 400 x 300, output_format "jpeg" and size equal to the stored blob's size
And exactly one blob was put in image.Storage and one index row exists for thumb and a1b2.png
And the original blob a1b2.png is unchanged
And no activity event is recorded
Scenario: The public url then serves that file without rendering
Given POST /v1/media/1/variants/thumb answered 200
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
Then the body is byte-identical to the stored blob and the renderer was called once in total
Scenario: Generating a variant that is already generated renders nothing
Given POST /v1/media/1/variants/thumb answered 200
When an admin sends POST /v1/media/1/variants/thumb again
Then the response status is 200 with the same size and the renderer was called once in total
Scenario: A public request and an admin request at the same time render once
When the public client and an admin request thumb for a1b2.png concurrently while the render blocks
Then the renderer is called once and image.Storage Put is called once
Scenario Outline: A request that cannot be served
When an admin sends POST /v1/media/<id>/variants/<name>
Then the response status is <status> and the plain text body is <body>
And nothing is rendered and nothing is stored
Examples:
| id | name | status | body |
| 1 | missing | 404 | Variant not found |
| 1 | off | 404 | Variant not found |
| 1 | Thumb | 404 | Variant not found |
Scenario: An SVG answers 422
Given image 2 is an SVG stored as "e5f6.svg"
When an admin sends POST /v1/media/2/variants/thumb
Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage
And nothing is rendered and nothing is stored
Scenario Outline: A source beyond a cap answers 413
Given <cap> is lower than the source
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 413 and the plain text body is apperr.VariantSourceTooLargeErrorMessage
And nothing is stored
Examples:
| cap |
| IMAGE_VARIANT_MAX_SOURCE_BYTES |
| IMAGE_VARIANT_MAX_SOURCE_PIXELS |
Scenario: A source that cannot be decoded answers 415
Given the stored blob a1b2.png holds bytes that are not an image
When an admin sends POST /v1/media/1/variants/thumb
Then the response status is 415 and the plain text body is apperr.VariantSourceUnreadableErrorMessage
And nothing is stored
Scenario: Any other render failure is a generic 500 and is not cached
Given the renderer fails for its next call
When an admin sends POST /v1/media/1/variants/thumb twice
Then the first response is 500 with the body "Internal server error" and no blob or row exists
And the second response is 200
Scenario: A generation does not change the original, the other variants or the library list
When an admin sends POST /v1/media/1/variants/thumb
Then the stored image record, the other variants and the index rows of other images are unchanged
Rule: Generating every missing variant of one image
Background:
Given image 1 "north-wall.jpg" is a 4032 x 3024 JPEG stored as "a1b2.jpg"
And the enabled variants "card", "hero", "og", "square" and "thumb" and the disabled variant "print"
And no generated file exists yet
Scenario: Generate all renders every enabled variant and answers the refreshed list
When an admin sends POST /v1/media/1/variants
Then the response status is 200
And "generated" is 5, "failed" is 0 and "failures" is an empty array
And "items" are the five enabled variants in name order, all with generated true and their sizes
And exactly five blobs and five index rows exist and "print" has none
Scenario: Variants already generated are skipped and not counted
Given "card" and "thumb" are already generated
When an admin sends POST /v1/media/1/variants
Then "generated" is 3, the renderer was called three times in total beyond the earlier two, and all five items have generated true
Scenario Outline: A failing variant is reported and the others are still generated
Given generating "hero" fails with <failure>
When an admin sends POST /v1/media/1/variants
Then the response status is 200
And "generated" is 4, "failed" is 1 and "failures" is [{"name": "hero", "cause": "<cause>"}]
And the "hero" item has generated false and the other four have generated true
Examples:
| failure | cause |
| ErrSourceTooLarge | too_large |
| ErrVariantSourceUnreadable | unsupported_format |
| a renderer error that wraps no sentinel | failed |
Scenario: Nothing to generate answers zero
Given all five enabled variants are generated
When an admin sends POST /v1/media/1/variants
Then the response status is 200, "generated" is 0 and "failed" is 0 and the renderer is not called
Scenario: No enabled variant answers zero
Given every variant is disabled
When an admin sends POST /v1/media/1/variants
Then the response status is 200, "generated" is 0, "failed" is 0 and "items" is an empty array
Scenario: An SVG answers 422
Given image 2 is an SVG
When an admin sends POST /v1/media/2/variants
Then the response status is 422 and the plain text body is apperr.VariantSourceUnsupportedErrorMessage
Rule: Purging the generated files of one variant of one image
Background:
Given image 1 stored as "a1b2.jpg" and image 3 stored as "c3d4.jpg"
And "thumb" and "hero" are generated for both images, thumb files of 18432 bytes and hero files of 98304 bytes
Scenario: Purging a generated variant removes its file and its row
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and the body is {"purged_files": 1, "purged_bytes": 18432}
And the thumb blob of image 1 is deleted from image.Storage and its index row is gone
And the hero file of image 1 and both files of image 3 are untouched
Scenario: The next public request generates the file again
Given DELETE /v1/media/1/variants/thumb answered 200
When the public client sends GET /static/uploadedImages/a1b2.jpg?v=thumb
Then the response is 200 and a new blob was put
Scenario: Purging a variant that is not generated answers zero
Given the "thumb" file of image 1 is not generated
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0}
Scenario: A disabled variant keeps its files and they can be purged
Given "thumb" is disabled
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 200 and "purged_files" is 1
Scenario: An unknown variant answers 404
When an admin sends DELETE /v1/media/1/variants/missing
Then the response status is 404 and the plain text body is "Variant not found"
Scenario: A blob that cannot be deleted keeps its row and the call fails
Given image.Storage fails to delete the thumb blob of image 1
When an admin sends DELETE /v1/media/1/variants/thumb
Then the response status is 500 with the body "Internal server error" and the index row is kept
Rule: Variants flagged "generate on upload" are generated when an image is uploaded
Background:
Given the variants "thumb" (generate_on_upload true, enabled), "hero" (generate_on_upload true, enabled), "og" (generate_on_upload true, disabled) and "card" (generate_on_upload false, enabled)
And image.Storage and the renderer are fakes
Scenario: Flagged and enabled variants are generated before the response
When the owner sends POST /v1/media with a valid 1000 x 800 PNG
Then the response status is 201
And the index holds one row for "thumb" and one for "hero" for the new stored name, and none for "og" or "card"
And the item has variant_count 2 and variant_size equal to the two row sizes
And exactly one activity event exists, the "media.upload" event, and no variant event
Scenario: A failing variant never fails the upload
Given the renderer fails when generating "hero"
When the owner uploads a valid PNG
Then the response status is 201, "thumb" is generated, "hero" is not, and variant_count is 1
And the image exists and the failure is written to the process log with the variant name and the image id
Scenario: A source beyond a cap still uploads
Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is lower than the new image
When the owner uploads it
Then the response status is 201 and variant_count is 0
Scenario: An SVG upload generates nothing
When the owner uploads a valid SVG
Then the response status is 201, the renderer is not called and variant_count is 0
Scenario: No flagged variant means no render
Given no variant has generate_on_upload true
When the owner uploads a valid PNG
Then the renderer is not called
Scenario: A refused upload generates nothing
When the owner uploads a PDF
Then the response status is 415 and the renderer is not called
Scenario Outline: Changing the flag does not backfill existing images
Given the library already holds image 1 with no generated file
When an admin <action>
Then the renderer is not called and image 1 has no generated file
Examples:
| action |
| creates the variant "wide" with generate_on_upload true |
| sends PUT /v1/image-variants/card with generate_on_upload true |
Scenario: Uploading the same bytes again generates files for the new image only
Given the library holds image 1 with generated files
When the owner uploads the same bytes again
Then the new image has its own generated files and image 1's files are unchanged
Rule: The library list carries the variant count and size and can filter and sort on them
Background:
Given the library of features/media_library.feature "Listing" (images 1 to 5, newest is 5) with these sizes and index rows:
| id | filename | size | variant files | variant bytes | total |
| 1 | north-wall.jpg | 4089446 | 4 | 266240 | 4355686 |
| 2 | floorboards.jpg | 2516582 | 1 | 41000 | 2557582 |
| 3 | floorboards-copy.jpg | 2516582 | 0 | 0 | 2516582 |
| 4 | window-frame.jpg | 812345 | 3 | 3000000 | 3812345 |
| 5 | logo.svg | 1204 | 0 | 0 | 1204 |
And the rows of image 4 belong to a disabled variant
And articles use the images as in that feature, so images 3, 4 and 5 are unused
Scenario: Every item carries variant_count and variant_size
When the client sends GET /v1/media/ with a valid cookie
Then item 1 has variant_count 4 and variant_size 266240
And item 4 has variant_count 3 and variant_size 3000000, although its variant is disabled
And items 3 and 5 have variant_count 0 and variant_size 0
And the response order, used_in, duplicate and counts are those of the existing feature
Scenario: The list reads the totals of all sources in one call
When the client sends GET /v1/media/ with a valid cookie
Then the variant file repository's per-source totals method was called once
Scenario: A new upload and a rename answer the same fields
When the owner renames image 1 to "north-wall-01"
Then the response item has variant_count 4 and variant_size 266240
Scenario Outline: variants narrows the list and the counts stay library-wide
When the client sends GET /v1/media/?variants=<variants> with a valid cookie
Then the "items" ids, as a set, are <ids>
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| variants | ids |
| with | 1, 2, 4 |
| without | 3, 5 |
Scenario: variants, filter, q and sort combine
When the client sends GET /v1/media/?variants=with&filter=unused with a valid cookie
Then the "items" ids are 4
When the client sends GET /v1/media/?variants=with&q=floorboards with a valid cookie
Then the "items" ids are 2
Scenario: sort=total_size orders by original plus variants, largest first, then by id
When the client sends GET /v1/media/?sort=total_size with a valid cookie
Then the "items" ids are 1, 4, 2, 3, 5 in that order
Scenario: sort=largest is unchanged and sorts by the original alone
When the client sends GET /v1/media/?sort=largest with a valid cookie
Then the "items" ids are 1, 2, 3, 4, 5 in that order
Scenario: Equal totals fall back to the lowest id first
Given images 2 and 3 have equal totals
When the client sends GET /v1/media/?sort=total_size with a valid cookie
Then image 2 comes before image 3
Scenario: variants=with and sort=total_size page through the list
When the client sends GET /v1/media/?variants=with&sort=total_size&limit=2 with a valid cookie
Then the "items" ids are 1, 4 and "next_cursor" is a non-empty string
When the client sends the same query with limit=2 and that cursor
Then the "items" ids are 2 and "next_cursor" is null
Scenario: variants=with on a library with an empty cache answers an empty list
Given no variant file exists
When the client sends GET /v1/media/?variants=with with a valid cookie
Then the response status is 200, "items" is an empty array and "counts" is unchanged
Scenario Outline: A malformed query answers 400
When the client sends GET /v1/media/?<query> with a valid cookie
Then the response status is 400 and the response body is the text apperr.MediaQueryErrorMessage
Examples:
| query |
| variants=bogus |
| variants=all |
| variants=With |
| variants= |
| sort=Total_size |
| sort=total |
Scenario: The number shown by a delete is the number a delete removes
Given image 1 has four index rows and four blobs
When the client sends DELETE /v1/media/1 with a valid cookie
Then the response status is 204 and exactly those four blobs and four rows are gone
Scenario: An article that uses a variant url still counts as using the image
Given article 1's body contains "/static/uploadedImages/<image 1 stored name>?v=hero"
When the client sends GET /v1/media/1/usage
Then article 1 is listed and the list item has used_in 1
Scenario: Merging keeps the variant parameter of the switched url
Given article 1's body contains the url of image 3 followed by "?v=hero"
When the client merges image 3 into image 2
Then article 1's body contains the url of image 2 followed by "?v=hero"
Rule: Generate all and purge by image are recorded in the activity log
Background:
Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin
And the category is "media" and the target is {"type": "image", "id": "<image id>", "name": "<filename>"}
And image 1 "north-wall.jpg" has five enabled variants and none generated
Scenario: Generate all is recorded with the counts
When the owner sends POST /v1/media/1/variants
Then one event exists with action "media.variant_generate_all", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"generated": 5, "failed": 0, "causes": []}
Scenario: A partial failure is a success that names its causes
Given generating "hero" fails with ErrSourceTooLarge
When the owner sends POST /v1/media/1/variants
Then one event exists with action "media.variant_generate_all", outcome "success" and params {"generated": 4, "failed": 1, "causes": ["too_large"]}
Scenario: Every variant failing is a failed event with a plain reason
Given generating every variant fails with ErrSourceTooLarge
When the owner sends POST /v1/media/1/variants
Then the response status is 200 with "generated" 0 and "failed" 5
And one event exists with action "media.variant_generate_all", outcome "failed" and failure_reason "The source image is too large to generate this variant."
Scenario Outline: Nothing was done, nothing is recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner sends POST /v1/media/1/variants and all five are already generated |
| the owner sends POST /v1/media/1/variants and no variant is enabled |
| the owner sends POST /v1/media/1/variants on an SVG |
| the owner sends POST /v1/media/1/variants/thumb |
| the owner sends POST /v1/media/9999/variants |
| the owner sends GET /v1/media/1/variants |
| a public GET /static/uploadedImages/a1b2.jpg?v=thumb that generates a file |
| an upload that generates files on upload |
Scenario: Purging the files of one variant of an image is recorded
Given "thumb" is generated for image 1 with a file of 18432 bytes
When the owner sends DELETE /v1/media/1/variants/thumb
Then one event exists with action "media.variant_purge_image", target {"type": "image", "id": "1", "name": "north-wall.jpg"}, outcome "success" and params {"purged_files": 1, "purged_bytes": 18432}
Scenario Outline: A purge that removes nothing, or finds nothing, is not recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner sends DELETE /v1/media/1/variants/thumb and nothing is generated |
| the owner sends DELETE /v1/media/1/variants/missing |
| the owner sends DELETE /v1/media/9999/variants/thumb |
Scenario: A purge that fails on storage is recorded as failed
Given image.Storage fails to delete the thumb blob
When the owner sends DELETE /v1/media/1/variants/thumb
Then the response is 500 and one event exists with action "media.variant_purge_image", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario: A failing audit write never changes the response
Given the audit repository fails on Append
When the owner sends POST /v1/media/1/variants
Then the response is 200 with the same body and the files exist
Scenario: The new events read back through the existing activity API
Given one generate-all and one purge-by-image event were recorded
When the owner sends GET /v1/activity?category=media
Then both events are listed with their documented shape, params and target
Rule: Layering and hygiene
Scenario: The dependency rule holds
Then internal/domain/image imports only the stdlib and other internal/domain packages
And the new use cases (list image variants, generate image variant, generate all, purge image variant, generate on upload) import no persistence, mailer, models, gorm, net/http, chi or api package
And the media handler imports no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs the new use cases and wires them into media.NewHandler
And no comment is added to any Go file, test or doc, except tool directives
@@ -0,0 +1,53 @@
# QA procedure: image_variants_per_image_api
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_per_image_http_test.go`, `test/image_variants_generate_on_upload_test.go`, `test/media_variants_list_http_test.go`, `test/image_variants_per_image_audit_test.go`. Extend `test/image_variants_support_test.go`; fake type names must stay unique in the package. No live server, no real SQLite file, no real SMTP, no `time.Sleep`.
## Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
2. Reuse the fakes of `test/image_variants_support_test.go`: `mockVariantRepo`, `mockVariantFileRepo`, `mockVariantStorage`, `countingRenderer`. Add to `mockVariantFileRepo` the per-source totals method (rows and bytes by source stored name) with a call counter. Reuse `mockImageRepo` (width, height, size, content type, SHA256, created_at seeded), `mockArticleRepo`, the user fake and the audit fakes of `test/activity_support_test.go`.
3. Users: owner 1, admin 2, verified plain user 3 (role other than owner/admin), unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie with a lower `token_version`; a valid API key in the key fake.
4. Router: a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`: `jwtauth.Verifier`, the API-key middleware, `AuditContext`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, then the owner/admin group holding the new routes beside the existing purge routes and the ungrouped media routes. Mount at `/v1/media`. Mount the real public handler `staticimages.NewHandler(...)` at `/static/uploadedImages` over the same storage and variant use cases so a generated file is observed through the public url.
5. Images are generated in the test with `image/png`, `image/jpeg`, `image/draw` (as in the other variant tests). The SVG is a real string. Seed the Listing library of features/media_library.feature for the list cases and the numbers of the Rule's table.
## A. Guards
6. Table of the feature: each method, path and credential gives the status and the exact body constant; assert no variant, index, storage or audit fake was touched. API key gets 401. Owner and admin both get 200. Malformed and unknown ids: 400 `apperr.IdUint32ErrorMessage`, 404 `Image not found`.
## B. Listing the variants of one image
7. Seed the Rule's Background. Decode the body and assert order (name), the absence of the disabled variant, `width`/`height` as JSON `null` on the raw body, output size and format, `generated`, `size` (null when not generated), `url`. Stale fingerprint row: `generated` false. Disabled variant's row ignored. SVG: raw body `{"unsupported":"svg","items":[]}`. No enabled variant: raw `"items":[]`.
8. Output format Examples: `countingRenderer.EncodableFormats()` configured for webp on and off; a webp source with `original` gives `png` without an encoder.
9. Geometry table: for each row assert the body's `output_width`/`output_height` and then render the same source and settings with the real renderer and assert the decoded size equals them (one source of truth).
10. Listing renders nothing: renderer count 0, storage `Open`/`Put` counts 0, no audit event.
## C. Generating one
11. First generation: 200, item fields, exactly one `Put`, one index row whose size equals the item `size`, original blob untouched, no audit event. A public GET afterwards returns the same bytes with the renderer count still 1. A second POST: 200, renderer count unchanged.
12. Concurrency (run with `-race`): a renderer blocking on a channel; one admin POST and one public GET for the same pair released together; assert renderer count 1 and `Put` count 1.
13. Refusals: unknown, disabled and wrongly-cased variant names give 404 `Variant not found` with renderer count 0. SVG gives 422 with the constant. Byte cap and pixel cap give 413 (construct the use case with the low cap). Undecodable bytes give 415 with the constant. A renderer failure gives 500 `Internal server error`, no blob, no row, and the next call is 200.
## D. Generating all
14. Fresh image: 200, `generated` 5, `failed` 0, `failures` raw `[]`, five items all generated, five blobs and rows, `print` untouched. Pre-generated subset: counts only the newly generated, renderer count equals the missing ones.
15. Failure Examples: make the renderer fake return `ErrSourceTooLarge`, `ErrVariantSourceUnreadable` and a plain error for `hero` only; assert the status 200, `failures`, the causes and the four others generated. Nothing to generate, no enabled variant and SVG (422) rows.
## E. Purging one
16. 200 with `purged_files` 1 and `purged_bytes` equal to the seeded size; blob `Delete` called for that name only; other variant and the other image untouched; a following public GET renders again. Not generated and disabled-variant rows; unknown variant 404. Storage `Delete` failing gives 500 and the row is kept.
## F. Generate on upload
17. Upload through the real upload path with the Rule's four variants: after the 201, assert rows for `thumb` and `hero` only, `variant_count` 2 and `variant_size` equal to the sum, exactly one audit event (`media.upload`). Renderer failing for `hero`: 201, one row, the log line (capture with a test logger handed to the use case, assert it names the variant and the image id). Pixel cap: 201 and zero rows. SVG, no flagged variant, and 415 upload: renderer count 0. Creating a variant or a PUT with the flag on does not render for existing images. Same bytes uploaded twice: separate rows per stored name.
## G. Library list
18. Seed the Rule's table. `variant_count`/`variant_size` per item (raw JSON integers, 0 not null). Per-source totals fake call count is 1 per list call. `variants=with|without` sets; `counts` unchanged; combination rows; `sort=total_size` order `1,4,2,3,5`, `sort=largest` order `1,2,3,4,5`; tie breaks by id (make two totals equal); cursor walk with `limit=2`; empty cache with `variants=with`; every 400 row with `apperr.MediaQueryErrorMessage`. Rename response carries the fields.
19. Delete consistency: seed four rows and four blobs for image 1, DELETE, assert exactly those four `Delete` calls and rows. Usage and merge rows with a `?v=hero` suffix in the article body.
## H. Audit
20. Generate-all success, partial failure (`causes`), all-failed (`failed` outcome and exact reason), the no-op rows (all generated, no enabled variant, SVG, single generate, unknown image, GET, public generation, upload generation). Purge-by-image success params, the zero rows, the storage-failure row (500 plus failed event with the generic reason). A failing audit repository leaves response and files unchanged. `GET /v1/activity?category=media` lists both new events. Extend the mapping test of `failureReasons` for the three new sentences.
## I. Mutation guard
21. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the generated-flag fingerprint comparison (equal and differing), the `generate_on_upload` AND `enabled` condition (each alone false), skipped already-generated variants, the failure-cause mapping order, `generated > 0` versus `failed > 0` for the outcome, the `purged_files > 0` guard, the total-size sum (original plus variants, not variants alone) and its id tie-break. Bare-bool conditions produce no mutant, so cover both branches anyway.
## J. Layering and hygiene
22. Guard test (`go list -deps` or grep): the domain imports only the stdlib and domain packages; the new use cases import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the media handler imports no `persistence`, `mailer`, `models` or `gorm`. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds. No comment in new or edited Go files, tests or docs. `docs/api.md` lists the new routes, shapes, params and errors.
## Manual (not automated)
23. `task dev:api` over a scratch SQLite file and `UPLOAD_DIR`: upload an image with a flagged variant and check the file lands in `UPLOAD_DIR`; then use the generate-all and purge routes with curl and a real cookie and check the files appear and disappear.
24. S3 driver (`STORAGE_DRIVER=s3`, MinIO): per-image generate and purge, upload with a flagged variant.
25. Latency of an upload of a 10 MB image with five flagged variants.
26. Read the two new events in the real Activity screen against section 8 copy.