feat: image variants cache panel
This commit is contained in:
1 parent
951fef18ba
commit
d768668014
53 files changed
+5809
-46
No files matched your search
@@ -0,0 +1,627 @@
|
||||
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
|
||||
@@ -0,0 +1,100 @@
|
||||
# QA procedure: image_variants_cache
|
||||
|
||||
Two parts: Go for the API and tracking, Vitest for the Cache screen. No live server, no real SQLite file, no real SMTP, no `time.Sleep` (use an injected clock and manual flushes).
|
||||
|
||||
## Go (package `test`, black-box)
|
||||
|
||||
New files: `test/image_variants_cache_http_test.go`, `test/image_variants_cache_tracking_test.go`, `test/image_variants_cache_purge_test.go`, `test/image_variants_cache_audit_test.go`. Extend `test/image_variants_support_test.go`. Fake type names must stay unique in the package.
|
||||
|
||||
### Setup shared by every case
|
||||
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
|
||||
2. Reuse `mockVariantRepo`, `mockVariantFileRepo`, `mockVariantStorage`, `countingRenderer`, `mockImageRepo`, the user fake, the API-key fake and the audit fakes of `test/activity_support_test.go`. Add to `mockVariantFileRepo`: `last_requested_at` per row, the aggregated totals, unused totals (cutoff, enabled variants only), top-images query (limit 10, bytes desc, id asc), batched listing for purge (batch size recorded), the batch update of requested times, with a call counter per method. Add fakes for the usage repository (`mockVariantUsageRepo`: day rows, batch writes counted, switchable failure) and a controllable clock.
|
||||
3. Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie; a valid API key.
|
||||
4. Router: a fresh `chi.NewRouter()` mirroring `NewImageVariantRouter` in `api/router.go` (`jwtauth.Verifier`, `AuditContext`, `RequireSignedIn`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, `RequireOwnerOrAdmin`) with `Get("/cache")` and `Post("/cache/purge")` beside the existing variant routes, mounted at `/v1/image-variants`. Mount the real public handler `staticimages.NewHandler(...)` at `/static/uploadedImages` over the same storage, variant use cases and the real tracker.
|
||||
5. Seed the Default fixture of the feature (six variants, the per-variant bytes, 312 unused files with the 29/31/30 day boundary rows, the ten top images, 37 rejections over the window). Images are generated in the test with `image/png`, `image/jpeg`, `image/draw`.
|
||||
|
||||
### A. Guards
|
||||
6. Table of the feature: each method, path and credential gives the status and exact body constant; assert no file, storage, usage or audit fake was touched. API key gets 401. Owner and admin get 200.
|
||||
|
||||
### B. Statistics
|
||||
7. Default fixture: decode the body and assert every field and value, `by_variant` order and content, `top_images` order, `url`. Sum invariants. Zero-file variant listed; disabled variant in totals but not in `unused`; orphan index rows counted but absent from `top_images`; tie at the tenth place; empty cache raw body `"top_images":[]`; no variant raw `"by_variant":[]`.
|
||||
8. `unused` boundaries: 29 days (in use), exactly the cutoff (in use), 31 days (unused), `created_at` fallback, disabled variant excluded.
|
||||
9. Aggregation: the file fake saw one totals call, one unused call and one top-images call, regardless of the number of images. A pending-tracking fixture asserts one batch write before the read, and that the 3 pending rejections are in `rejected_requests`. A failing totals call gives 500 `Internal server error`. GET records no audit event and opens no blob.
|
||||
|
||||
### C. Tracking
|
||||
10. Hits: for each row of the "what counts" table drive the real public handler and assert the pending key set and zero usage writes. 1000 hits on one file: zero writes, then one flush gives one update with the last time. 40 files: one batch of 40. Nothing pending: repository not called. 500 distinct keys trigger a flush without the tick (use the injected trigger, not a timer). Failed batch then recovery: nothing lost, nothing counted twice, latest time wins. A failing repository never fails or slows the public response. The stored time never moves back. A late update for a purged file creates no row.
|
||||
11. Rejections: for each row of the table drive the public handler and assert the counter and the status. 1000 rejections: zero writes, one batch adds 1000. Window: clock at 2026-10-08 23:59 UTC, rows of 2026-10-02 counted, 2026-10-01 not. Day boundary at 23:59:59 and 00:00:00 UTC gives two rows. The flush prunes rows older than 7 days. Failed flush keeps the pending counts.
|
||||
12. Race (run with `-race`): 50 goroutines record hits and rejections while another flushes; totals written equal totals recorded. Shutdown: closing the container flushes once.
|
||||
|
||||
### D. Purge
|
||||
13. One case per scope with the numbers of the feature (all 1284 and 222298112, unused 312 and 42991616, variant hero 244 and 88080384, image 1 6 and 6082765): assert the body, each deleted blob name, each deleted row, and that originals and definitions are unchanged. Disabled variant: scope all and scope variant include it; scope unused excludes it. A following public GET renders again.
|
||||
14. Empty cache returns zeros. A blob missing from storage still removes its row and counts as purged.
|
||||
15. Refusals table: unknown scope, case change, empty body, missing or empty variant, missing, zero and negative `image_id`, unknown variant (404 `Variant not found`), unknown image (404 `Image not found`), invalid JSON (400 `apperr.BodyErrorMessage`). Assert no storage or row change and no audit event. Extra target fields for `all` and `unused` are ignored.
|
||||
16. Partial failure: storage fails on 644 rows; 200 with `purged_files` 640, `failed_files` 644, failing rows kept; retry after recovery purges the rest. Listing failure gives 500. Batch size at most 500 over 5000 rows (assert the largest batch the fake returned).
|
||||
|
||||
### E. Audit
|
||||
17. One case per scope row: action, target (none for all and unused, `image_variant` for variant, `image` for image), outcome, params including `older_than_days` 30 for unused. Partial failure is a success carrying `failed_files`. All-failed is a `failed` event with the generic reason. The no-op rows record nothing. A failing audit repository leaves the response and files unchanged. `GET /v1/activity?category=media` lists the four events. Extend the `failureReasons` mapping test for the two new actions.
|
||||
|
||||
### F. Mutation guard
|
||||
18. `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 unused cutoff comparison (below, at and above), the enabled filter of unused, the `purged_files > 0` guard, the success versus failed outcome (`purged > 0` versus `failed > 0`), the top-10 limit and its id tie-break, the window start (7 days) and the never-moving-back time guard. Bare-bool conditions produce no mutant, so cover both branches anyway.
|
||||
|
||||
### G. Layering and hygiene
|
||||
19. Guard test (`go list -deps` or grep): the domain imports only the stdlib and domain packages; the new use cases and the tracker import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the handler imports no `persistence`, `mailer`, `models` or `gorm`. Check `migrate.go` lists `ImageVariantRejectionDay`. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds. No comment in new or edited files. `docs/api.md` lists the two routes, the shapes, the scopes and the errors, and `docs/architecture.md` or the variants doc describes the tracker and its 30 second flush.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` per case (GET `/v1/image-variants/cache`, POST `/v1/image-variants/cache/purge`); no backend. Mount `CachePage` inside `DashboardShell` with a resolved verified session (id 1, username "marta", role "owner") and a test router at `/settings/media/cache`. Mock `matchMedia` for the 640 px breakpoint; run layout cases at desktop and below-640. Use fake timers for the 150 ms skeleton delay. No test freezes the system date (this screen shows no date).
|
||||
|
||||
Default fixture = the Background of the feature, with the six variants, the ten top images and the numbers above.
|
||||
|
||||
1. **Access, Examples-driven**: no role, role "owner", role "admin", 403 on the list. Forbidden copy and "Back to Home"; no request when the role is missing.
|
||||
2. **Header and tabs**: title, description, the two buttons with icons, the two tabs; "Variants" routes to `/settings/media/variants`; the Variants page's "Cache" tab now routes here (update the existing VariantsPage spec that asserted aria-disabled); breadcrumb and mobile title.
|
||||
3. **Tiles**: the four rows of the table; two columns on mobile, four on desktop.
|
||||
4. **By variant**: order, stats text, the Disabled badge, bar width percentages (100, 69, 25, 25, 23, 11), the colour classes for enabled and disabled, `1 file` singular, 0 width when the largest is 0.
|
||||
5. **Top 10**: ten rows, numbering, thumbnails, `6 variants`, `1 variant`, sizes; mobile 60 px rows with the size after the count and an icon-only 44 px Purge with aria-label and tooltip.
|
||||
6. **Loading**: nothing at 149 ms; at 150 ms `aria-busy`, label "Loading cache", skeleton counts (5 and 6; Top 10 hidden on mobile); a response at 100 ms never draws skeletons.
|
||||
7. **Empty**: tile subs "Nothing cached", empty block copy, both purge buttons disabled, no lists. **Purge unused** disabled when unused is 0.
|
||||
8. **Error, Examples-driven**: 500 and network error, alert copy, no close button, Try again re-sends and renders.
|
||||
9. **Purge all dialog**: copy, focus on the field, confirm disabled; the typed-word table (`pur`, `Purge`, `purge`, `purged`); Cancel and Escape send nothing and return focus; confirm sends `{"scope": "all"}` once.
|
||||
10. **Purge unused dialog**: copy with 312 and 41 MB, "Purge 312 files", sends `{"scope": "unused"}`.
|
||||
11. **Purging**: banner copy "Purging 1,284 files", `aria-live="polite"`, dimmed content, all purge controls disabled, tabs active.
|
||||
12. **Done**: toasts "Purged 1,284 files, freed 212 MB" and "Purged 312 files, freed 41 MB"; one refetch; the empty state after purge all.
|
||||
13. **Failed**: toast "Purge didn't finish", "640 of 1,284 files were removed. Try again.", one refetch, Try again re-sends the same body without the typed word.
|
||||
14. **Errors, Examples-driven**: 400, 403, 500, network, 401 (redirect then toast), each with the copy of the table; banner removed and content restored.
|
||||
15. **Row purge**: sends `{"scope": "image", "image_id": 1}` with no dialog; button loader; other rows enabled; toast "Purged 6 files (5.8 MB) for north-wall.jpg"; 404 toast and refetch; partial failure "4 of 6 files were removed. Try again.".
|
||||
16. **Activity sentences** (extend `activitySentence.spec.ts` and `ActivityVariants.spec.ts`): the seven rows of the sentence table, the partial detail line, and `SENTENCE_ACTIONS` including the three actions.
|
||||
17. **French**: `setLocale('fr')`; a catalogue test that every key used on the page and in the new sentences exists in `messages.ts`; the page renders without missing keys.
|
||||
18. **Vocabulary and accessibility**: no rendered text contains "thumbnail preset", "version" or "rendition"; tabs, labels, focus return.
|
||||
|
||||
## Visual (task visual:check -- image-variants, desktop and mobile, light and dark)
|
||||
|
||||
NOT RUN: the user forbade running task visual:check for this slice, so every item below is reported NOT RUN.
|
||||
|
||||
Reference: `docs/design/screens/image-variants.md` section 5 and the cache frame in `docs/design/source/VariantsFrame.dc.html` (lines 250-278, with `AppFrame.dc.html` for the shell). Add each state to `web/visual/states.ts` with the board's data. The gate: every non-skipped capture within 10 %, both viewports, both themes. None of these states depends on the date or time, so none is skipped.
|
||||
|
||||
Board sample data the mocks must reuse: variants thumb 412 files 21 MB, card 318 and 58 MB, hero 244 and 84 MB, square 140 and 19 MB, og 96 and 9 MB, print 74 and 21 MB (disabled); totals 1,284 files and 212 MB; 6 variants, 5 enabled and 1 disabled; 37 rejected requests; unused 312 files and 41 MB; the Top 10 of the frame (north-wall.jpg 6 variants 5.8 MB, lime-plaster.jpg 6 and 4.9 MB, kitchen-after.webp 5 and 4.4 MB, kitchen-before.webp 5 and 3.9 MB, floorboards.jpg 5 and 3.6 MB, IMG_4471.jpg 4 and 3.1 MB, stone-wall-detail.jpg 4 and 2.8 MB, bathroom-tiles.png 4 and 2.6 MB, roof-lantern.jpg 3 and 2.3 MB, chimney-stack.jpg 3 and 2.1 MB); session name "Marta Alves". The mock returns the By variant rows in the board's order (hero, card, then the ties).
|
||||
|
||||
- [ ] 5a Loaded
|
||||
- [ ] 5b Loading
|
||||
- [ ] 5c Empty cache
|
||||
- [ ] 5d Purging (the frame draws "640 of 1,284 · 106 MB freed" with a 50 % bar; the draft shows an indeterminate bar and no progress line, so this state is the one at risk against the budget; see open question 1)
|
||||
- [ ] 5e Purge done (empty cache; the toast)
|
||||
- [ ] 5f Purge failed (toast "Purge didn't finish")
|
||||
- [ ] 5g Error loading
|
||||
- [ ] 5h Purge all: confirm (typed "pur", button disabled)
|
||||
- [ ] 5i Purge all: confirm, typed (typed "purge")
|
||||
- [ ] 5j Purge unused: confirm
|
||||
- [ ] Layout from the frame, not `components.md`: tiles 4 columns and 16 px gap on desktop, 2 columns and 8 px on mobile; tile padding 16 px (12 px mobile), value 28 px (22 px mobile); two columns 460 px / rest; bars 8 px high; Top 10 rows 40 px (60 px mobile) with the Purge button 26 px high (44 px square icon-only on mobile).
|
||||
- [ ] Not drawn, no visual state: the error toasts for 400, 403, 500 and 404, the row-purge toast, the row loader, the disabled Purge unused with a non-empty cache; Vitest only.
|
||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches the buttons, the tabs, each Purge; the dialog traps focus and Escape returns it), hover and active states, motion (dialog, skeleton pulse stopped under reduced motion).
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real flush loop**: `task dev:api` over a scratch SQLite file. Request `/static/uploadedImages/<storedName>?v=thumb` repeatedly, wait 30 s, then open the Cache page and check "Rejected requests" after a few `?v=unknown` requests. Check in the file that `last_requested_at` is set once per file and the day row holds the count.
|
||||
- **Graceful shutdown**: record hits, stop the server with Ctrl+C within 30 s, restart and check the counts survived.
|
||||
- **Real purge**: generate real variants, then purge all, unused (age a row in the file), one image, and check the files disappear from `UPLOAD_DIR` and reappear on request.
|
||||
- **S3 driver** (`STORAGE_DRIVER=s3`, MinIO): purge all and by image.
|
||||
- **Large cache**: 20,000 index rows; check the Cache page loads in a reasonable time and the purge keeps memory flat.
|
||||
- **Other databases**: run the stats and purge against MariaDB and PostgreSQL if available (portable SQL).
|
||||
- **Activity screen**: read the four new events against section 8 of the design.
|
||||
- **French**: switch the language and read every state, the dialogs and the toasts.
|
||||
Reference in new issue
Block a user