Files
RenewCMS/features/image_variants_cache.feature

628 lines
40 KiB
Gherkin
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Feature: Image variants cache panel: cache statistics, purge by scope, request tracking and the Cache screen
As an owner or admin of a RenewCMS site
I want to see what the variant cache holds and to purge all of it, the unused part, one variant or one image
So that I can reclaim disk without deleting a variant, and the activity log records every purge
Background:
Given the spec of record is docs/design/screens/image-variants.md sections 5 and 8, its "Back-office API errors" table, the cache frames of docs/design/source/VariantsFrame.dc.html and docs/design/routes.md
And the routes the design marks "proposed" are fixed here: GET /v1/image-variants/cache and POST /v1/image-variants/cache/purge, mounted in NewImageVariantRouter in api/router.go behind the existing owner or admin middleware chain, so a request with an API key has no user_id claim and answers 401
And features/image-variants.feature and features/image_variants_per_image_api.feature are the contract for variants, index rows and per-image purge, and are unchanged
And a cached file is one VariantFile index row, a row counts whatever the state of its fingerprint, and sizes are in bytes (1 MB = 1048576)
And the page is /settings/media/cache, a verified route rendered as CachePage inside DashboardShell, and it supersedes the aria-disabled "Cache" tab of features/image_variants_screens.feature, which becomes a link to this route
And request validation goes through validate tags and validator.Validate.Struct, an unreadable JSON body answers 400 with apperr.BodyErrorMessage, a failed validation answers 422, and error responses are plain text from http.Error
And new apperr constants are CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image." and CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
And request tracking has two parts: the last time each generated file was requested, and a counter of rejected requests per UTC day
And tracking never writes to the database on a request: hits are kept in memory by a thread-safe tracker behind a domain port, merged per (variant, source) key, and flushed in one batch by a background loop every 30 seconds, when 500 distinct keys are pending, on GET /v1/image-variants/cache before it reads, and on graceful shutdown
And the flush reaches the database through a domain repository port, the models are added to internal/infrastructure/persistence/database/migrate.go: a nullable last_requested_at column on ImageVariantFile and a new model ImageVariantRejectionDay with a unique day and a count
And the SQL stays portable across SQLite, MySQL/MariaDB and PostgreSQL: plain GROUP BY, ORDER BY, LIMIT and comparisons, no dialect function and no upsert syntax outside GORM's portable clauses
And the cache body is JSON with snake_case fields, arrays are never null, and the Default fixture below is used by the scenarios
And the Default fixture holds six variants, the enabled ones are card, hero, og, square and thumb, the disabled one is print, and the index holds:
| variant | files | bytes |
| hero | 244 | 88080384 |
| card | 318 | 60817408 |
| thumb | 412 | 22020096 |
| print | 74 | 22020096 |
| square | 140 | 19922944 |
| og | 96 | 9437184 |
And 312 files (42991616 bytes) of enabled variants have a reference time older than 30 days, the reference time of a row being last_requested_at when set and created_at otherwise
And the ten images using the most variant bytes are, in order: north-wall.jpg (id 1, 6 files, 6082765 bytes), lime-plaster.jpg (6, 5138022), kitchen-after.webp (5, 4613734), kitchen-before.webp (5, 4089446), floorboards.jpg (5, 3774873), IMG_4471.jpg (4, 3250585), stone-wall-detail.jpg (4, 2936012), bathroom-tiles.png (4, 2726297), roof-lantern.jpg (3, 2411724) and chimney-stack.jpg (3, 2202009)
And 37 rejected requests were counted over the last 7 UTC days
Rule: Only owners and admins reach the cache routes
Scenario Outline: Every cache 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 index row, blob, counter or activity event is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/image-variants/cache | no cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants/cache | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/image-variants/cache | the cookie of an unverified user | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/image-variants/cache | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/image-variants/cache/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/image-variants/cache | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
| POST | /v1/image-variants/cache/purge | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
Scenario Outline: Owners and admins are both allowed
When <role> sends GET /v1/image-variants/cache with a valid cookie
Then the response status is 200
Examples:
| role |
| the owner |
| an admin |
Rule: The cache statistics
Scenario: The Default fixture
When an admin sends GET /v1/image-variants/cache
Then the response status is 200
And the body holds "generated_files" 1284 and "disk_used" 222298112
And it holds "variants_defined" 6, "variants_enabled" 5 and "variants_disabled" 1
And it holds "rejected_requests" 37 and "rejected_window_days" 7
And it holds "unused" {"files": 312, "bytes": 42991616, "days": 30}
And "by_variant" lists every defined variant sorted by bytes descending then name ascending: hero, card, print, thumb, square, og, each as {"name", "enabled", "files", "bytes"}
And "top_images" lists the ten images above in that order, each as {"id", "filename", "url", "files", "bytes"} where url is "/static/uploadedImages/<storedName>"
Scenario: Counts are the sum of the index rows
Then "generated_files" equals the sum of the "files" of "by_variant" and "disk_used" equals the sum of their "bytes"
Scenario: A variant without a file is listed with zero
Given the variant "banner" is defined and enabled and has no file
When an admin sends GET /v1/image-variants/cache
Then "by_variant" holds {"name": "banner", "enabled": true, "files": 0, "bytes": 0} and "variants_defined" is 7
Scenario: A disabled variant keeps its files in the totals
Then "by_variant" holds print with "enabled" false, "files" 74 and "bytes" 22020096
And print is counted in "generated_files" and "disk_used" but not in "unused"
Scenario: An image deleted behind the index is not a top image
Given index rows exist for a stored name that no image record has
When an admin sends GET /v1/image-variants/cache
Then the rows count in "generated_files", "disk_used" and "by_variant" and no entry of "top_images" names that stored name
Scenario: Top images are capped at ten and tie-break by id
Given eleven images have variant files and images 7 and 8 have equal bytes
When an admin sends GET /v1/image-variants/cache
Then "top_images" has ten entries and image 7 comes before image 8
Scenario: An empty cache
Given the index holds no row
When an admin sends GET /v1/image-variants/cache
Then the response status is 200
And "generated_files" is 0, "disk_used" is 0, "unused" is {"files": 0, "bytes": 0, "days": 30}
And the raw body holds "top_images":[] and every "by_variant" entry has files 0
And "variants_defined" and "rejected_requests" are unchanged
Scenario: No variant defined
Given no variant exists
When an admin sends GET /v1/image-variants/cache
Then the raw body holds "by_variant":[] and "variants_defined":0
Scenario: Unused counts only enabled variants older than the cutoff
Given a row of "hero" with last_requested_at 29 days ago and a row of "hero" with last_requested_at 31 days ago
And a row of "print", a disabled variant, with last_requested_at 90 days ago
And a row of "card" with no last_requested_at and a created_at 45 days ago
Then the rows of "hero" 31 days ago and of "card" are counted in "unused", and the other two are not
Scenario: A row requested exactly 30 days ago is still in use
Given a row whose reference time is exactly the cutoff
Then it is not counted in "unused"
Scenario: The statistics are aggregated, not read per image
When an admin sends GET /v1/image-variants/cache
Then the file repository answered the totals, the unused totals and the top images in one aggregated call each, never one query per image or per file
Scenario: The read flushes pending tracking first
Given 12 hits and 3 rejections are pending in memory
When an admin sends GET /v1/image-variants/cache
Then one batch write happens before the read and "rejected_requests" includes the 3 pending
Scenario: The statistics change nothing else
When an admin sends GET /v1/image-variants/cache
Then no blob is opened, no render happens, no row is deleted and no activity event is recorded
Scenario: A failing repository answers 500
Given the file repository fails to total
When an admin sends GET /v1/image-variants/cache
Then the response status is 500 and the plain text body is "Internal server error"
Rule: The last time each generated file was requested is tracked without a write per hit
Scenario Outline: What counts as a request for a file
When <request>
Then <effect> for the pair (variant, stored name) in memory and no database write happens
Examples:
| request | effect |
| the public client gets /static/uploadedImages/a1b2.jpg?v=thumb, cached | the last requested time is set |
| the public client gets it and the file is generated by that request | the last requested time is set |
| the public client gets it with a matching If-None-Match and receives 304 | the last requested time is set |
| the public client gets /static/uploadedImages/a1b2.jpg with no variant | nothing is recorded |
| the public client gets it with ?v=unknown | nothing is recorded |
| the public client gets it with ?v=print, a disabled variant | nothing is recorded |
| the public client gets /static/uploadedImages/missing.jpg?v=thumb | nothing is recorded |
| an admin sends POST /v1/media/1/variants/thumb | nothing is recorded |
| an admin sends GET /v1/image-variants/cache | nothing is recorded |
Scenario: A thousand hits on one file are one pending key
When the public client requests the same cached file 1000 times
Then the database received zero writes during the requests
And after a flush exactly one batch write holds one update for that file with the time of the last hit
Scenario: The flush writes one batch for many files
Given 40 distinct files were requested
When the tracker flushes
Then the repository received one batch of 40 updates in one call
Scenario: Nothing pending means no write
When the tracker flushes and nothing was recorded since the last flush
Then the repository is not called
Scenario: The flush loop and the size trigger
Given 500 distinct keys are pending
Then a flush is started without waiting for the interval
And a flush also happens at each 30 second tick and on graceful shutdown
Scenario: A failed flush keeps the pending hits
Given the repository fails on the batch
When the tracker flushes, then the repository recovers and the tracker flushes again
Then the second batch holds the hits of both flushes with the latest time per file and nothing is lost or counted twice
Scenario: A request is never blocked by or failed by the tracker
Given the repository fails on every batch
When the public client requests a cached file
Then the response is 200 with the file bytes
Scenario: A stored time never moves back
Given a row with last_requested_at 10:05
When a batch carries the time 10:01 for that file
Then last_requested_at is still 10:05
Scenario: A purged file ignores a late update
Given the batch carries a time for a file that was purged since
When the tracker flushes
Then no row is created and the call succeeds
Scenario: Concurrent requests are race free
When 50 goroutines record hits and rejections while another flushes
Then the totals written equal the recorded ones, with the race detector enabled
Rule: Rejected public requests are counted per day
Scenario Outline: What counts as rejected
When the public client sends GET <url>
Then <effect>
Examples:
| url | effect | status |
| /static/uploadedImages/a1b2.jpg?v=unknown | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?v=print | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?width=400 | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/a1b2.jpg?v=Thumb | the rejected counter of today is +1 | 400 |
| /static/uploadedImages/missing.jpg?v=thumb | the counter is unchanged | 404 |
| /static/uploadedImages/a1b2.jpg?v=thumb | the counter is unchanged | 200 |
| /static/uploadedImages/a1b2.jpg | the counter is unchanged | 200 |
Scenario: A rejection is an in-memory increment
When the public client sends 1000 rejected requests
Then the database received zero writes during the requests
And after a flush one batch adds 1000 to today's day row
Scenario: The window is today and the six previous UTC days
Given the tracker clock reads 2026-10-08 23:59 UTC and day rows of 2026-10-02 (5) and 2026-10-01 (9)
When an admin sends GET /v1/image-variants/cache
Then "rejected_requests" counts the row of 2026-10-02 and not the row of 2026-10-01
Scenario: A day boundary starts a new bucket
Given a rejection at 2026-10-08 23:59:59 UTC and another at 2026-10-09 00:00:00 UTC
Then they are counted in two different day rows
Scenario: Old day rows are pruned by the flush
Given a day row older than 7 days
When the tracker flushes
Then that row is deleted and the rows of the window are kept
Scenario: A failed flush keeps the pending rejections
Given the repository fails on the batch
When the tracker flushes, then the repository recovers and flushes again
Then the rejections are written once
Rule: Purging the cache by scope
Background:
Given the Default fixture with blobs in image.Storage for every row
And images 1 "north-wall.jpg" and 2 "lime-plaster.jpg" hold index rows
Scenario: Purge all removes every generated file of every variant
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200
And the body is {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0}
And every generated blob is deleted from image.Storage and the index holds no row
And the original blobs and every variant definition are unchanged
Scenario: Purge all includes the files of disabled variants
Then the 74 files of "print" are among the 1284 purged
Scenario: Purge unused removes the unused files of enabled variants only
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "unused"}
Then the response status is 200
And the body is {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0}
And the remaining 972 rows are untouched, including any row of "print" and every row requested within 30 days
Scenario: Purge by variant removes the files of that variant only
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "hero"}
Then the body is {"scope": "variant", "purged_files": 244, "purged_bytes": 88080384, "failed_files": 0}
And the files of the other variants are untouched
Scenario: Purge by variant works for a disabled variant
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "variant", "variant": "print"}
Then "purged_files" is 74
Scenario: Purge by image removes every variant file of that image
Given image 1 holds 6 rows of 6082765 bytes
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "image", "image_id": 1}
Then the body is {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0}
And the files of image 2 are untouched and the original of image 1 is unchanged
Scenario: The next public request generates a purged file again
Given a purge with {"scope": "all"} 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 an empty cache answers zero
Given the index holds no row
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200 and the body is {"scope": "all", "purged_files": 0, "purged_bytes": 0, "failed_files": 0}
Scenario: A blob already missing from storage does not stop a purge
Given image.Storage reports a missing blob for one row
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then its row is removed and counted as purged and the others are purged
Scenario Outline: A request that cannot be served
When an admin sends POST /v1/image-variants/cache/purge with <body>
Then the response status is <status> and the plain text body is <message>
And no blob is deleted and no row is deleted and no activity event is recorded
Examples:
| body | status | message |
| {"scope": "everything"} | 422 | apperr.CachePurgeScopeErrorMessage |
| {"scope": "ALL"} | 422 | apperr.CachePurgeScopeErrorMessage |
| {} | 422 | apperr.CachePurgeScopeErrorMessage |
| {"scope": "variant"} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "variant", "variant": ""} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image"} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image", "image_id": 0} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "image", "image_id": -1} | 422 | apperr.CachePurgeTargetErrorMessage |
| {"scope": "variant", "variant": "missing"} | 404 | apperr.VariantNotFoundErrorMessage |
| {"scope": "image", "image_id": 9999} | 404 | Image not found |
| not json | 400 | apperr.BodyErrorMessage |
Scenario Outline: A target given for a scope that ignores it is ignored
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "<scope>", "variant": "hero", "image_id": 1}
Then the response status is 200 and the purged files are those of the scope "<scope>" alone
Examples:
| scope |
| all |
| unused |
Rule: A purge that does not complete reports what it did
Background:
Given the Default fixture and image.Storage fails to delete the blobs of 644 rows
Scenario: A partial purge answers 200 with the failed count
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 200
And the body is {"scope": "all", "purged_files": 640, "purged_bytes": <bytes of those 640>, "failed_files": 644}
And the 644 rows are kept, so a second call can retry them
Scenario: Retrying removes the rest
Given a partial purge answered 200
When image.Storage recovers and the admin sends the same request again
Then "purged_files" is 644 and "failed_files" is 0
Scenario: A failure to list the rows answers 500
Given the file repository fails to list the rows to purge
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response status is 500 and the plain text body is "Internal server error"
Scenario: Rows are processed in bounded batches
When an admin sends POST /v1/image-variants/cache/purge with {"scope": "all"} over 5000 rows
Then the rows were read in batches of at most 500 and never all loaded at once
Rule: Each purge scope is 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 domain audit gains the actions media.variant_purge_all and media.variant_purge_unused, and reuses media.variant_purge and media.variant_purge_image
Scenario Outline: A purge that removes files is recorded
When the owner sends POST /v1/image-variants/cache/purge with <body>
Then one event exists with action "<action>", target <target>, outcome "success" and params <params>
Examples:
| body | action | target | params |
| {"scope": "all"} | media.variant_purge_all | none | {"purged_files": 1284, "purged_bytes": 222298112} |
| {"scope": "unused"} | media.variant_purge_unused | none | {"purged_files": 312, "purged_bytes": 42991616, "older_than_days": 30} |
| {"scope": "variant", "variant": "hero"}| media.variant_purge | {"type": "image_variant", "id": "hero", "name": "hero"} | {"purged_files": 244, "purged_bytes": 88080384} |
| {"scope": "image", "image_id": 1} | media.variant_purge_image | {"type": "image", "id": "1", "name": "north-wall.jpg"} | {"purged_files": 6, "purged_bytes": 6082765} |
Scenario: A partial failure is a success that names the failed count
Given a purge removes 640 files and fails on 644
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then one event exists with action "media.variant_purge_all", outcome "success" and params {"purged_files": 640, "purged_bytes": <bytes>, "failed_files": 644}
Scenario: Every file failing is a failed event with a plain reason
Given image.Storage fails to delete every blob
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response is 200 with "purged_files" 0 and "failed_files" 1284
And one event exists with action "media.variant_purge_all", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario Outline: Nothing removed, nothing recorded
When <request>
Then no event is recorded
Examples:
| request |
| the owner purges all on an empty cache |
| the owner purges unused when nothing is unused |
| the owner purges the variant "hero" and it has no file |
| the owner purges image 1 and it has no file |
| the owner sends an unknown scope |
| the owner purges an unknown variant |
| the owner purges an unknown image |
| the owner sends GET /v1/image-variants/cache |
Scenario: A failing audit write never changes the response
Given the audit repository fails on Append
When the owner sends POST /v1/image-variants/cache/purge with {"scope": "all"}
Then the response is 200 with the same body and the files are gone
Scenario: The events read back through the existing activity API
Given one event of each of the four scopes was recorded
When the owner sends GET /v1/activity?category=media
Then the four events are listed with their action, target and params
Scenario Outline: The Activity screen words each scope
Given an activity item with action "<action>", outcome "<outcome>" and the params above
Then the sentence reads "<sentence>" and the detail line reads "<detail>"
Examples:
| action | outcome | sentence | detail |
| media.variant_purge_all | success | Purged all variants | Removed 1,284 files (212 MB) |
| media.variant_purge_all | failed | Failed to purge all variants | |
| media.variant_purge_unused | success | Purged unused variants | Removed 312 files (41 MB) not requested for 30 days |
| media.variant_purge_unused | failed | Failed to purge unused variants | |
| media.variant_purge | success | Purged the cache of variant “hero” | Removed 244 files (84 MB) |
| media.variant_purge | failed | Failed to purge the cache of variant “hero” | |
| media.variant_purge_image | success | Purged the variants of north-wall.jpg | Removed 6 files (5.8 MB) |
Scenario: A partial purge adds the failed count to the detail line
Given an item "media.variant_purge_all" with params {"purged_files": 640, "purged_bytes": 111149056, "failed_files": 644}
Then the detail line reads "Removed 640 files (106 MB) · 644 failed"
Rule: Cache screen, access and header
Background:
Given the session user has role "owner" or "admin" from GET /v1/auth/me unless a scenario says otherwise
And the page uses Nuxt UI components only and design tokens, with no raw hex value and no arbitrary Tailwind colour
And every new English string has a French entry in web/src/i18n/messages.ts
And the stats come from GET /v1/image-variants/cache once per navigation, and every size is shown with the formatter of the Variants page (1 MB = 1048576), numbers with thousands separators
And plain-text API errors are mapped by status, never shown raw
And the desktop breadcrumb is "Settings › Media › Cache", the mobile top bar title is "Cache", and the sidebar item "Settings" is current
Scenario Outline: A session whose role is not owner or admin gets the Forbidden state
Given the session has <session>
When the user opens /settings/media/cache
Then the page shows the empty block with a lock icon, the title "You don't have access" and the text "Only admins can manage image variants. Ask an admin if you need one changed."
And it offers one button "Back to Home" with an arrow-left icon, which routes to /home
And no header, no tabs, no tile and no list is drawn
And <request>
Examples:
| session | request |
| no role | no request to GET /v1/image-variants/cache is sent |
| role "owner" but the API answers 403 | the request was sent and its 403 is handled here |
Scenario: The header and the tabs
When the Default fixture is loaded
Then the title is "Cache" and the description is "Generated variant files. Purged files are generated again on the next request."
And two buttons are shown: "Purge unused" with a clock icon and "Purge all variants" with a trash-2 icon in the danger tone
And two tabs follow: "Variants" (icon layers, a link to /settings/media/variants) and "Cache" (icon database, active, medium weight, 2 px primary underline)
And activating "Variants" routes to /settings/media/variants, where its own "Cache" tab routes back here
Rule: Cache screen, loaded
Scenario: The four stat tiles
When the Default fixture is loaded
Then four tiles show, as label, value and sub text:
| label | value | sub text |
| Generated files | 1,284 | Across 6 variants |
| Disk used | 212 MB | Originals not included |
| Variants defined | 6 | 5 enabled, 1 disabled |
| Rejected requests | 37 | Last 7 days |
And the tiles form four columns on desktop and two on mobile
Scenario: The By variant list
When the Default fixture is loaded
Then the card "By variant" lists in the API order:
| name | stats | badge | bar width of the largest |
| hero | 244 files · 84 MB | | 100 % |
| card | 318 files · 58 MB | | 69 % |
| print | 74 files · 21 MB | Disabled | 25 % |
| thumb | 412 files · 21 MB | | 25 % |
| square | 140 files · 19 MB | | 23 % |
| og | 96 files · 9 MB | | 11 % |
And the bar width is the bytes of the row over the largest bytes, 0 when the largest is 0
And a disabled variant draws its bar in the border-accented colour and the others in the primary colour
And the names are IBM Plex Mono medium and the singular reads "1 file"
Scenario: The Top 10 list on desktop
When the Default fixture is loaded
Then the card "Top 10 images by variant disk usage" lists ten rows numbered 1 to 10, each with a 32 x 24 thumbnail of the original, the filename, "N variants", the size and a "Purge" button with an eraser icon
And the first row reads north-wall.jpg, 6 variants, 5.8 MB and the last chimney-stack.jpg, 3 variants, 2.1 MB
And the singular reads "1 variant"
Scenario: The Top 10 list on mobile
Given the viewport is below 640 px
When the Default fixture is loaded
Then the tiles are two columns, the two cards stack, each Top 10 row is 60 px high with the size after the count ("6 variants · 5.8 MB") and the Purge button is icon only, 44 px square, with aria-label "Purge north-wall.jpg" and a tooltip
Scenario: The layout is two columns on desktop
Given the viewport is 640 px wide or more
Then By variant is 460 px wide and Top 10 takes the rest
Rule: Cache screen, loading, empty and error
Scenario: Loading
Given GET /v1/image-variants/cache is unresolved
Then at 149 ms nothing is drawn and at 150 ms the region has aria-busy and the label "Loading cache"
And the four tiles show skeletons for the value and the sub text, By variant shows 5 skeleton rows, Top 10 shows 6 skeleton rows on desktop and is hidden on mobile
And a response at 100 ms never draws skeletons
Scenario: Empty cache
Given the stats hold zero files and variants_defined 6
Then the tiles read "0 · Nothing cached" for Generated files, "0 MB · Nothing cached" for Disk used, "6 · 5 enabled, 1 disabled" and "37 · Last 7 days"
And an empty block with a database icon shows the title "Nothing generated yet" and the text "Variants are generated on the first request with ?v=<name>, or from an image's Variants tab."
And "Purge unused" and "Purge all variants" are disabled and no By variant or Top 10 card is drawn
Scenario Outline: Error loading
Given the GET answers <failure>
Then an error alert shows the icon circle-x, the title "Couldn't load cache stats", the text "Check your connection and try again." and the button "Try again"
And no tile, list or table is drawn and the alert has no close button
When the user activates "Try again"
Then GET /v1/image-variants/cache is sent again and the Default fixture renders
Examples:
| failure |
| a 500 |
| a network error |
Scenario: Purge unused is disabled when nothing is unused
Given the stats hold "unused" {"files": 0, "bytes": 0, "days": 30} and 1284 files
Then "Purge unused" is disabled and "Purge all variants" is enabled
Rule: Cache screen, purge all with a typed word
Scenario: The dialog opens with the button disabled
When the user activates "Purge all variants"
Then a dialog titled "Purge all variants?" shows "This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request."
And a field labelled "Type purge to confirm" in IBM Plex Mono is focused, "Cancel" is offered and "Purge all variants" in the danger tone is disabled
And no request is sent
Scenario Outline: The word unlocks the button
Given the dialog is open
When the user types "<typed>"
Then the confirm button is <state>
Examples:
| typed | state |
| pur | disabled |
| Purge | disabled |
| purge | enabled |
| purge | enabled |
| purged | disabled |
Scenario: Cancel and Escape send nothing
When the user activates Cancel, or presses Escape
Then the dialog closes, focus returns to "Purge all variants" and no request is sent
Scenario: Confirming sends the purge
Given the typed word is "purge"
When the user activates "Purge all variants"
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "all"} and the dialog closes
Rule: Cache screen, purge unused
Scenario: The dialog states the count from the stats
When the user activates "Purge unused"
Then a dialog titled "Purge unused variants?" shows "This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request."
And it offers "Cancel" and "Purge 312 files", with no typed word
Scenario: Confirming sends the purge
When the user activates "Purge 312 files"
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "unused"}
Rule: Cache screen, purging, done and failed
Scenario: Purging
Given a purge request is in flight and the stats hold 1284 files
Then the page shows an info banner with a loader icon and "Purging 1,284 files" with an indeterminate bar, announced with aria-live="polite"
And the tiles and lists are dimmed to 60 % opacity, "Purge unused", "Purge all variants" and every row Purge button are disabled, and the tabs stay active
Scenario: Purge done after purge all
Given the purge answered {"scope": "all", "purged_files": 1284, "purged_bytes": 222298112, "failed_files": 0}
Then a success toast reads "Purged 1,284 files, freed 212 MB"
And the stats are fetched again once and the empty cache state shows
Scenario: Purge done after purge unused
Given the purge answered {"scope": "unused", "purged_files": 312, "purged_bytes": 42991616, "failed_files": 0}
Then a success toast reads "Purged 312 files, freed 41 MB" and the stats are fetched again once
Scenario: Purge failed
Given the purge answered {"scope": "all", "purged_files": 640, "purged_bytes": 111149056, "failed_files": 644}
Then an error toast shows the title "Purge didn't finish", the text "640 of 1,284 files were removed. Try again." and the button "Try again"
And the stats are fetched again once
When the user activates "Try again"
Then the same request is sent again, without asking for the word again
Scenario Outline: A purge request that errors
Given the POST answers <failure>
Then the banner is removed, the content is restored and a toast shows <title> and <text>
Examples:
| failure | title | text |
| a 400 | Check the request | A value in the request isn't valid. Reload the page and try again. |
| a 403 | You don't have access| Only admins can manage image variants. |
| a 500 | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
| a network error | Something went wrong | The server couldn't finish. Try again. If it keeps happening, check the server log. |
Scenario: A 401 sends the user to sign in
Given the POST answers 401
Then the user is redirected to sign in and a toast reads "Session expired" with "Sign in again to continue."
Rule: Cache screen, purging the variants of one image
Scenario: A row Purge sends the image scope
When the user activates "Purge" on the north-wall.jpg row
Then POST /v1/image-variants/cache/purge is sent once with {"scope": "image", "image_id": 1} and no confirm dialog is shown
And while it runs the row shows a loader on its button and the other Purge buttons stay enabled
Scenario: Done
Given the purge answered {"scope": "image", "purged_files": 6, "purged_bytes": 6082765, "failed_files": 0}
Then a success toast reads "Purged 6 files (5.8 MB) for north-wall.jpg" and the stats are fetched again once
Scenario: The image no longer exists
Given the POST answers 404
Then a toast shows "Not found" and "This variant or image no longer exists. Refresh the page." and the stats are fetched again
Scenario: Failed
Given the purge answered {"scope": "image", "purged_files": 4, "purged_bytes": 1000, "failed_files": 2}
Then the "Purge didn't finish" toast reads "4 of 6 files were removed. Try again." with "Try again"
Rule: Accessibility and vocabulary
Scenario: Roles and announcements
Then the tabs have role tablist and tab, the dialog traps focus and returns it to its opener, the icon-only buttons have an aria-label and a tooltip, touch targets on mobile are at least 44 px, and every status is an icon plus text
And no rendered text contains "thumbnail preset", "version" or "rendition"
Rule: Layering and hygiene
Scenario: The dependency rule holds
Then internal/domain/image and internal/domain/audit import only the stdlib and other internal/domain packages
And the tracker port, the usage repository port and the cache port are declared in internal/domain/image
And the new use cases (cache stats, purge cache) and the tracker import no persistence, mailer, models, gorm, net/http, chi or api package
And the handler imports no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs the tracker, starts its flush loop, flushes it on shutdown and wires the use cases into imagevariant.NewHandler and the serve path of the public handler
And a repository method with a single-row Find or Save keeps its current signature
And the models are listed in internal/infrastructure/persistence/database/migrate.go
And no comment is added to any file, except tool directives