628 lines
40 KiB
Gherkin
628 lines
40 KiB
Gherkin
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
|