779 lines
45 KiB
Gherkin
779 lines
45 KiB
Gherkin
Feature: Image variants are generated on first request, cached, and managed by admins
|
|
|
|
As an admin or owner of a RenewCMS site
|
|
I want to define named variants of the original images and have them generated and cached on demand
|
|
So that articles can ask for /static/uploadedImages/<storedName>?v=<name> and get a right-sized file,
|
|
the original is never changed, and nothing but a defined, enabled variant can be requested
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/screens/image-variants.md sections 1, 2 and 7, its "Back-office API errors" table and section 8 for the activity rows
|
|
And this slice is API only: no SPA code, no change to docs/design
|
|
And it excludes the design's cache screen routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge), the per-image routes (/v1/media/{id}/variants...) and the media library variants filter and sort
|
|
And internal/domain/image gains the entity Variant with the JSON fields name, width, height, fit, format, quality, generate_on_upload, enabled, created_at and updated_at in snake_case
|
|
And Variant.Name is a slug of 2 to 32 characters among a-z, 0-9 and "-", unique and immutable after creation
|
|
And Variant.Width and Variant.Height are 1 to 8000 pixels or absent (JSON null, "auto" in the design), and at least one is present
|
|
And Variant.Fit is one of cover, contain, inside, and defaults to cover
|
|
And Variant.Format is one of original, jpeg, png, webp, and defaults to original
|
|
And Variant.Quality is 1 to 100 and defaults to 82; for format png it is not stored and is returned as null, and a quality sent with png is ignored
|
|
And generate_on_upload defaults to false and enabled defaults to true
|
|
And internal/domain/image defines the sentinels ErrVariantNotFound, ErrVariantExists, ErrVariantInvalid, ErrUnknownVariant, ErrUnsupportedParameter and ErrSourceTooLarge, asserted with errors.Is
|
|
And internal/domain/image defines the port VariantRepository (variants CRUD) and the port VariantFileRepository, an index of generated files holding variant name, source stored name, cached stored name, content type, size in bytes and the settings fingerprint, because Storage.Put chooses the stored name itself
|
|
And internal/domain/image defines the port VariantRenderer, implemented in internal/infrastructure and kept free of GORM and HTTP, which decodes a source and encodes a variant, and reports which output formats it can encode
|
|
And generated bytes are stored only through image.Storage, are named by Storage.Put like any stored image, and are never reachable as library images
|
|
And the use cases (create, update, set enabled, delete, purge, list variants, serve variant) depend only on domain interfaces
|
|
And the variant routes are mounted at /v1/image-variants behind jwtauth.Verifier, middleware.AuditContext, jwtauth.Authenticator, middleware.RequireCurrentToken, middleware.RequireVerifiedUser and middleware.RequireOwnerOrAdmin, with no API key authentication
|
|
And request bodies are validated with validate tags and validator.Validate.Struct, never by hand
|
|
And successful responses are JSON with snake_case fields, and error responses on /v1 are plain text from http.Error, except the public 400 described below
|
|
And a variant is returned as {"name", "width", "height", "fit", "format", "quality", "generate_on_upload", "enabled", "generated_files", "cache_size", "created_at", "updated_at"}, with cache_size in bytes
|
|
And the variant list is ordered by name ascending
|
|
And the contain padding colour is decided here: transparent for png and webp outputs, and white (#FFFFFF) for jpeg outputs, which carry no alpha
|
|
And a jpeg output flattens any transparency of the source onto white (#FFFFFF)
|
|
And a new env var IMAGE_VARIANT_MAX_SOURCE_BYTES, default 10485760, is listed in envDefaults in cmd/server/main.go (not in requiredEnvVars), in .env.example and in docs/configuration.md
|
|
And a new env var IMAGE_VARIANT_MAX_SOURCE_PIXELS, default 50000000, caps the declared width x height of a source, read from the image header before any pixel buffer is allocated, and is listed in the same three places
|
|
And a new env var IMAGE_VARIANT_MAX_CONCURRENT_RENDERS, default 4, bounds the renders running at once, with waiting requests queued until a slot frees or their context ends, and is listed in the same three places
|
|
And contain always pads to the box when both width and height are given: the image is scaled to fit inside the box, never enlarged, and centred on a canvas of exactly width x height; with a single dimension there is no box and no padding
|
|
And the first implementation task is a spike of a CGO-free lossy WebP encoder with CGO_ENABLED=0
|
|
And the public serving path stays /static/uploadedImages/<storedName>, mounted in api/router.go, and is outside /v1 and outside any auth
|
|
|
|
Rule: Only owners and admins reach the variant routes
|
|
|
|
Scenario Outline: Every variant route is guarded
|
|
When the client sends <method> <path> with <credentials>
|
|
Then the response status is <status>
|
|
And the plain text body is <body>
|
|
And no variant, generated file or activity event is read or changed
|
|
|
|
Examples:
|
|
| method | path | credentials | status | body |
|
|
| GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
|
| GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
|
| GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
|
|
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
|
| GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
|
|
|
Scenario Outline: Owners and admins are both allowed
|
|
When <role> sends GET /v1/image-variants with a valid cookie
|
|
Then the response status is 200
|
|
|
|
Examples:
|
|
| role |
|
|
| the owner |
|
|
| an admin |
|
|
|
|
Rule: Listing
|
|
|
|
Scenario: An empty list is an empty array
|
|
Given no variant exists
|
|
When an admin sends GET /v1/image-variants
|
|
Then the response status is 200
|
|
And the body is {"items": []}, with items an empty array and not null in the raw JSON
|
|
|
|
Scenario: The list carries the generated count and cache size per variant
|
|
Given the variants "hero" (cover 1200x630 webp 82, enabled) and "card" (inside 800 x auto jpeg 80, disabled)
|
|
And the cache index holds 3 generated files of 1000, 2000 and 3000 bytes for "hero" and none for "card"
|
|
When an admin sends GET /v1/image-variants
|
|
Then "items" is ordered "card", "hero"
|
|
And "card" has width 800, height null, fit "inside", format "jpeg", quality 80, enabled false, generated_files 0 and cache_size 0
|
|
And "hero" has width 1200, height 630, fit "cover", format "webp", quality 82, enabled true, generated_files 3 and cache_size 6000
|
|
|
|
Scenario: A png variant lists a null quality
|
|
Given the variant "logo" with format png
|
|
When an admin sends GET /v1/image-variants
|
|
Then "logo" has quality null
|
|
|
|
Rule: Creating a variant
|
|
|
|
Scenario: Creating a variant with every field
|
|
When an admin sends POST /v1/image-variants with {"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}
|
|
Then the response status is 201
|
|
And the body is the variant with generated_files 0 and cache_size 0
|
|
And exactly one variant "hero" is stored
|
|
|
|
Scenario: Omitted optional fields take their defaults
|
|
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 400}
|
|
Then the response status is 201
|
|
And the body has width 400, height null, fit "cover", format "original", quality 82, generate_on_upload false and enabled true
|
|
|
|
Scenario Outline: A valid boundary value is accepted
|
|
When an admin sends POST /v1/image-variants with <body>
|
|
Then the response status is 201
|
|
|
|
Examples:
|
|
| body |
|
|
| {"name": "ab", "width": 1} |
|
|
| {"name": "a-b-c", "height": 8000} |
|
|
| {"name": "<32 characters a-z0-9->", "width": 8000, "height": 8000} |
|
|
| {"name": "q1", "width": 10, "quality": 1} |
|
|
| {"name": "q100", "width": 10, "quality": 100} |
|
|
|
|
Scenario Outline: An invalid field answers 422 with the field's sentence and stores nothing
|
|
When an admin sends POST /v1/image-variants with <body>
|
|
Then the response status is 422
|
|
And the plain text body is "<message>"
|
|
And no variant is stored and no activity event is recorded
|
|
|
|
Examples:
|
|
| body | message |
|
|
| {"name": "a", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
|
| {"name": "<33 characters>", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
|
| {"name": "Hero", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
|
| {"name": "he ro", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
|
| {"name": "hero"} | Width and height must be between 1 and 8000, and one of them is required. |
|
|
| {"name": "hero", "width": 0} | Width and height must be between 1 and 8000, and one of them is required. |
|
|
| {"name": "hero", "width": 8001} | Width and height must be between 1 and 8000, and one of them is required. |
|
|
| {"name": "hero", "height": -1} | Width and height must be between 1 and 8000, and one of them is required. |
|
|
| {"name": "hero", "width": 10, "fit": "stretch"} | Fit must be cover, contain or inside. |
|
|
| {"name": "hero", "width": 10, "format": "avif"} | Format must be original, jpeg, png or webp. |
|
|
| {"name": "hero", "width": 10, "quality": 0} | Quality must be between 1 and 100. |
|
|
| {"name": "hero", "width": 10, "quality": 101} | Quality must be between 1 and 100. |
|
|
|
|
Scenario Outline: A malformed request answers 400
|
|
When an admin sends POST /v1/image-variants with <body>
|
|
Then the response status is 400
|
|
And the plain text body is apperr.BodyErrorMessage
|
|
|
|
Examples:
|
|
| body |
|
|
| not json |
|
|
| {"name": "hero", "width": "wide"} |
|
|
| an empty body |
|
|
|
|
Scenario: A duplicate name answers 409 and keeps the existing variant
|
|
Given the variant "thumb" 400 x 300 cover jpeg quality 80
|
|
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 800, "format": "png"}
|
|
Then the response status is 409
|
|
And the plain text body is "A variant named “thumb” already exists. Choose another name."
|
|
And the stored variant "thumb" is unchanged
|
|
|
|
Rule: Updating a variant
|
|
|
|
Background:
|
|
Given the variant "hero" is 1200x630 cover webp quality 82, enabled, with generate_on_upload false
|
|
And the cache index holds 244 generated files for "hero" (84 MB) and 3 generated files for "thumb"
|
|
|
|
Scenario: Changing quality updates the variant and purges its generated files
|
|
When an admin sends PUT /v1/image-variants/hero with {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true}
|
|
Then the response status is 200
|
|
And the body is the variant with quality 70, generated_files 0, cache_size 0 and "purged_files": 244
|
|
And the 244 cached blobs of "hero" are deleted from image.Storage and their index rows are gone
|
|
And the files of "thumb" are untouched
|
|
|
|
Scenario Outline: A change to a rendering setting purges
|
|
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
|
|
Then the response status is 200
|
|
And "purged_files" is 244
|
|
|
|
Examples:
|
|
| setting | value |
|
|
| width | 1000 |
|
|
| height | null |
|
|
| fit | inside |
|
|
| format | jpeg |
|
|
| quality | 60 |
|
|
|
|
Scenario Outline: A change to a switch does not purge
|
|
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
|
|
Then the response status is 200
|
|
And "purged_files" is 0 and generated_files is still 244
|
|
And no blob is deleted
|
|
|
|
Examples:
|
|
| setting | value |
|
|
| generate_on_upload | true |
|
|
| enabled | false |
|
|
|
|
Scenario: A PUT identical to the stored variant changes nothing
|
|
When an admin sends PUT /v1/image-variants/hero with the stored values
|
|
Then the response status is 200 and "purged_files" is 0
|
|
And updated_at is unchanged and no activity event is recorded
|
|
|
|
Scenario: Switching the format to png drops the quality and purges
|
|
When an admin sends PUT /v1/image-variants/hero with format "png" and quality 40
|
|
Then the response status is 200 and quality is null and "purged_files" is 244
|
|
|
|
Scenario: Changing the quality of a png variant is a no-op
|
|
Given the variant "logo" with format png
|
|
When an admin sends PUT /v1/image-variants/logo with the same values and quality 30
|
|
Then "purged_files" is 0 and quality is still null
|
|
|
|
Scenario: The name is immutable
|
|
When an admin sends PUT /v1/image-variants/hero with {"name": "banner", "width": 1200, "height": 630}
|
|
Then the stored variant is still named "hero" and no variant "banner" exists
|
|
|
|
Scenario Outline: An invalid PUT answers like a create and changes nothing
|
|
When an admin sends PUT /v1/image-variants/hero with <body>
|
|
Then the response status is 422 and the stored variant and its 244 generated files are unchanged
|
|
|
|
Examples:
|
|
| body |
|
|
| {"quality": 101} |
|
|
| {"width": null, "height": null} |
|
|
| {"width": 9000} |
|
|
| {"fit": "stretch"} |
|
|
|
|
Scenario: An unknown variant answers 404
|
|
When an admin sends PUT /v1/image-variants/missing with a valid body
|
|
Then the response status is 404 and the plain text body is "Variant not found"
|
|
|
|
Rule: Enabling and disabling
|
|
|
|
Scenario Outline: PATCH toggles enabled and keeps the cache
|
|
Given the variant "hero" has enabled <before> and 244 generated files
|
|
When an admin sends PATCH /v1/image-variants/hero with {"enabled": <after>}
|
|
Then the response status is 200 and the body has enabled <after> and generated_files 244
|
|
And no blob is deleted
|
|
|
|
Examples:
|
|
| before | after |
|
|
| true | false |
|
|
| false | true |
|
|
|
|
Scenario Outline: A PATCH that is not valid or not found
|
|
When an admin sends PATCH /v1/image-variants/<name> with <body>
|
|
Then the response status is <status>
|
|
|
|
Examples:
|
|
| name | body | status |
|
|
| hero | not json | 400 |
|
|
| hero | {} | 422 |
|
|
| hero | {"enabled": "no"} | 400 |
|
|
| missing | {"enabled": true} | 404 |
|
|
|
|
Rule: Deleting a variant and purging its cache
|
|
|
|
Scenario: Deleting removes the definition and its generated files
|
|
Given the variant "hero" with 244 generated files and the variant "thumb" with 3
|
|
When an admin sends DELETE /v1/image-variants/hero
|
|
Then the response status is 204 with an empty body
|
|
And "hero" is gone, its 244 blobs are deleted from image.Storage and its index rows are gone
|
|
And "thumb" and its 3 files are untouched
|
|
|
|
Scenario: Purging removes only the generated files
|
|
Given the variant "hero" with 244 generated files of 84 MB in total
|
|
When an admin sends POST /v1/image-variants/hero/purge
|
|
Then the response status is 200 and the body is {"purged_files": 244, "purged_bytes": <total bytes>}
|
|
And the variant "hero" still exists unchanged and GET /v1/image-variants shows generated_files 0 and cache_size 0
|
|
|
|
Scenario: Purging an empty cache answers zero
|
|
When an admin sends POST /v1/image-variants/hero/purge and "hero" has no generated file
|
|
Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0}
|
|
|
|
Scenario Outline: An unknown variant answers 404
|
|
When an admin sends <method> /v1/image-variants/missing<suffix>
|
|
Then the response status is 404 and the plain text body is "Variant not found"
|
|
|
|
Examples:
|
|
| method | suffix |
|
|
| DELETE | |
|
|
| POST | /purge |
|
|
|
|
Scenario: A blob that cannot be deleted keeps its index row and the call fails
|
|
Given the variant "hero" with 3 generated files and image.Storage failing to delete the second one
|
|
When an admin sends POST /v1/image-variants/hero/purge
|
|
Then the response status is 500 with the body "Internal server error"
|
|
And the 2 other files are deleted and the failing file keeps its index row so that a retry can finish
|
|
|
|
Scenario: A blob already missing from storage does not stop a purge
|
|
Given the variant "hero" with an index row whose blob no longer exists
|
|
When an admin sends POST /v1/image-variants/hero/purge
|
|
Then the response status is 200 and the row is removed
|
|
|
|
Scenario: Deleting a source image purges its generated files
|
|
Given the image "north-wall.jpg" with generated files for "hero" and "thumb"
|
|
When an admin sends DELETE /v1/media/<its id>
|
|
Then the response status is 204 and the generated blobs and index rows of that image are gone
|
|
And the generated files of other images are untouched
|
|
|
|
Scenario: Merging copies purges the generated files of the removed copies
|
|
Given the images 5, 6 and 7 share one hash and 6 and 7 each have generated files
|
|
When an admin sends POST /v1/media/merge keeping 5 and merging [6, 7]
|
|
Then the generated blobs and rows of images 6 and 7 are gone
|
|
|
|
Rule: Public serving of ?v=<name>
|
|
|
|
Background:
|
|
Given the variant "thumb" is cover 400x300 jpeg quality 80 and enabled
|
|
And the image "photo.png" is stored as "a1b2.png", a 1000 x 800 PNG
|
|
And no generated file exists yet
|
|
|
|
Scenario: The first request generates and caches
|
|
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
|
|
Then the response status is 200 with the content type "image/jpeg"
|
|
And the body decodes to a 400 x 300 JPEG
|
|
And exactly one blob was put in image.Storage for the variant and one index row exists with the same size as the body
|
|
And the original blob "a1b2.png" is unchanged
|
|
|
|
Scenario: The second request serves the cached file without rendering
|
|
Given GET /static/uploadedImages/a1b2.png?v=thumb was served once
|
|
When the public client sends the same request again
|
|
Then the body is byte-identical to the first one
|
|
And the renderer was called once in total and image.Storage Put was called once in total
|
|
|
|
Scenario: A request without ?v= is the unchanged original
|
|
When the public client sends GET /static/uploadedImages/a1b2.png
|
|
Then the response is the stored PNG with the same headers as before this slice
|
|
And the renderer is not called
|
|
|
|
Scenario: The ETag derives from the variant settings
|
|
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
|
|
Then the response has an ETag that is a quoted opaque value
|
|
And requesting it again returns the same ETag
|
|
And sending If-None-Match with that ETag answers 304 with an empty body
|
|
|
|
Scenario Outline: The ETag changes when a setting changes and stays when a switch changes
|
|
Given the ETag E1 of GET /static/uploadedImages/a1b2.png?v=thumb
|
|
When an admin changes <setting> of "thumb" to <value>
|
|
And the public client requests the same URL again
|
|
Then the new ETag is different from E1: <changes>
|
|
|
|
Examples:
|
|
| setting | value | changes |
|
|
| quality | 70 | yes |
|
|
| width | 500 | yes |
|
|
| height | 200 | yes |
|
|
| fit | inside | yes |
|
|
| format | png | yes |
|
|
| generate_on_upload | true | no |
|
|
|
|
Scenario: A stale If-None-Match after an edit gets a fresh 200
|
|
Given the client holds E1 and the admin changed the quality of "thumb"
|
|
When the client sends If-None-Match E1
|
|
Then the response is 200 with the regenerated file and a new ETag
|
|
|
|
Scenario: Editing a variant regenerates on the next request
|
|
Given GET ...?v=thumb was served once
|
|
When an admin changes the quality of "thumb" from 80 to 70
|
|
And the public client requests the same URL
|
|
Then a new blob is generated and the previous blob was deleted from image.Storage
|
|
|
|
Scenario: A cached file that vanished from storage is generated again
|
|
Given the index row for "thumb" and "a1b2.png" exists but image.Storage reports ErrBlobNotFound for it
|
|
When the public client requests GET ...?v=thumb
|
|
Then the response is 200 with a freshly generated file and the index row points to the new blob
|
|
|
|
Scenario: Concurrent first requests generate once
|
|
When 20 concurrent clients send GET /static/uploadedImages/a1b2.png?v=thumb
|
|
Then every response is 200 with identical bytes
|
|
And the renderer was called once and image.Storage Put was called once (singleflight keyed by variant and image)
|
|
|
|
Scenario: Different variants and different images are not serialised together
|
|
Given the variant "card" and the image "c3d4.png"
|
|
When the four (variant, image) pairs are requested concurrently while each render blocks
|
|
Then all four renders are in flight at the same time
|
|
|
|
Scenario: A failed generation is not cached and is retried
|
|
Given the renderer fails for the first call
|
|
When the public client requests GET ...?v=thumb twice
|
|
Then the first response is 500 and no blob or row exists
|
|
And the second response is 200
|
|
|
|
Rule: Unknown, disabled and raw parameters
|
|
|
|
Scenario Outline: A request outside the contract answers 400 with the JSON error body
|
|
When the public client sends GET <url>
|
|
Then the response status is 400 with the content type "application/json"
|
|
And the body is {"status": 400, "error": "<error>", "message": "<message>", "detail": "<detail>"}
|
|
And the renderer is not called and nothing is stored
|
|
|
|
Examples:
|
|
| url | error | message | detail |
|
|
| /static/uploadedImages/a1b2.png?v=banner | unknown_variant | Unknown variant | banner |
|
|
| /static/uploadedImages/a1b2.png?v=Thumb | unknown_variant | Unknown variant | Thumb |
|
|
| /static/uploadedImages/a1b2.png?v= | unknown_variant | Unknown variant | |
|
|
| /static/uploadedImages/a1b2.png?v=hero (disabled) | unknown_variant | Unknown variant | hero |
|
|
| /static/uploadedImages/a1b2.png?width=400 | unsupported_parameter | Unsupported parameter | width |
|
|
| /static/uploadedImages/a1b2.png?height=300 | unsupported_parameter | Unsupported parameter | height |
|
|
| /static/uploadedImages/a1b2.png?format=webp | unsupported_parameter | Unsupported parameter | format |
|
|
| /static/uploadedImages/a1b2.png?quality=50 | unsupported_parameter | Unsupported parameter | quality |
|
|
| /static/uploadedImages/a1b2.png?v=thumb&width=400 | unsupported_parameter | Unsupported parameter | width |
|
|
| /static/uploadedImages/a1b2.png?v=banner&quality=50 | unsupported_parameter | Unsupported parameter | quality |
|
|
|
|
Scenario: Other query parameters are ignored
|
|
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb&t=123
|
|
Then the response status is 200 with the thumb variant
|
|
|
|
Scenario: A disabled variant serves again once enabled, from its kept cache
|
|
Given the variant "thumb" was served once, then disabled
|
|
When the public client requests ?v=thumb
|
|
Then the response is 400 unknown_variant
|
|
When an admin enables "thumb" and the client requests again
|
|
Then the response is 200 and image.Storage Put was called once in total
|
|
|
|
Scenario: A deleted variant answers 400
|
|
Given the variant "thumb" is deleted
|
|
When the public client requests ?v=thumb
|
|
Then the response is 400 unknown_variant
|
|
|
|
Rule: Missing and unreadable sources
|
|
|
|
Scenario Outline: A missing source answers 404
|
|
When the public client sends GET <url>
|
|
Then the response status is 404 and nothing is generated
|
|
|
|
Examples:
|
|
| url |
|
|
| /static/uploadedImages/missing.png?v=thumb |
|
|
| /static/uploadedImages/../secret.png?v=thumb |
|
|
| /static/uploadedImages/nested/a1b2.png?v=thumb|
|
|
|
|
Scenario: Parameter errors come before the missing source
|
|
When the public client sends GET /static/uploadedImages/missing.png?v=banner
|
|
Then the response status is 400 unknown_variant
|
|
|
|
Scenario: A source that cannot be decoded answers 500 and caches nothing
|
|
Given the stored blob "bad.png" holds bytes that are not an image
|
|
When the public client requests /static/uploadedImages/bad.png?v=thumb
|
|
Then the response status is 500 and nothing is stored
|
|
|
|
Scenario Outline: Only a source that is a known image record can be a variant source
|
|
Given the blob "<name>" exists in image.Storage
|
|
And <record>
|
|
When the public client requests /static/uploadedImages/<name>?v=thumb
|
|
Then the response status is <status>
|
|
And nothing is generated and nothing is stored
|
|
|
|
Examples:
|
|
| name | record | status |
|
|
| a1b2.png | an image record with that stored name exists | 200 |
|
|
| gen-1.jpg | the blob is a generated variant file, with no image record | 404 |
|
|
| orphan.png | the blob has no image record | 404 |
|
|
|
|
Scenario: The record lookup comes after the parameter checks and before any storage read
|
|
When the public client requests /static/uploadedImages/orphan.png?v=banner
|
|
Then the response status is 400 unknown_variant
|
|
And when requested with ?v=thumb the response is 404 and image.Storage Open is not called
|
|
|
|
Scenario: The original of any stored blob is still served without ?v=
|
|
When the public client requests /static/uploadedImages/gen-1.jpg
|
|
Then the response is the stored bytes as before this slice
|
|
|
|
Rule: Source size cap
|
|
|
|
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413
|
|
Given IMAGE_VARIANT_MAX_SOURCE_BYTES is 1000
|
|
And the stored source "big.png" is <size> bytes
|
|
When the public client requests /static/uploadedImages/big.png?v=thumb
|
|
Then the response status is <status>
|
|
|
|
Examples:
|
|
| size | status |
|
|
| 1000 | 200 |
|
|
| 1001 | 413 |
|
|
|
|
Scenario: The 413 body is plain text and nothing is generated
|
|
Given a source of 1001 bytes with the cap at 1000
|
|
Then the body is "The source image is too large to generate this variant." and the renderer is not called
|
|
|
|
Scenario: The cap never affects the original
|
|
Given a source of 1001 bytes with the cap at 1000
|
|
When the public client requests it without ?v=
|
|
Then the response is 200 with the stored bytes
|
|
|
|
Scenario Outline: The env var rejects invalid values at boot
|
|
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_BYTES=<value>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| value | result |
|
|
| (unset) | 10485760 |
|
|
| 2048 | 2048 |
|
|
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
|
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
|
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
|
|
|
Rule: Source pixel cap, checked before decoding
|
|
|
|
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it
|
|
Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is 10000
|
|
And the stored source "wide.png" is a small file whose header declares <w> x <h> pixels
|
|
When the public client requests /static/uploadedImages/wide.png?v=thumb
|
|
Then the response status is <status>
|
|
And the renderer never allocates the pixel buffer for a refused source
|
|
|
|
Examples:
|
|
| w | h | status |
|
|
| 100 | 100 | 200 |
|
|
| 101 | 100 | 413 |
|
|
| 30000 | 30000 | 413 |
|
|
|
|
Scenario: The pixel 413 body is plain text and nothing is generated
|
|
Given a source declaring more pixels than the cap
|
|
Then the body is "The source image is too large to generate this variant." and the renderer is not called and nothing is stored
|
|
|
|
Scenario: The pixel cap never affects the original or SVG
|
|
Given a source declaring more pixels than the cap
|
|
When the public client requests it without ?v=
|
|
Then the response is 200 with the stored bytes
|
|
|
|
Scenario: A header that cannot be read answers 500 and caches nothing
|
|
Given the stored blob "bad.png" holds bytes that are not an image
|
|
When the public client requests /static/uploadedImages/bad.png?v=thumb
|
|
Then the response status is 500 and nothing is stored
|
|
|
|
Scenario Outline: The pixel cap env var
|
|
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_PIXELS=<value>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| value | result |
|
|
| (unset) | 50000000 |
|
|
| 2048 | 2048 |
|
|
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
|
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
|
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
|
|
|
Rule: Concurrent renders are bounded
|
|
|
|
Scenario: At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once
|
|
Given IMAGE_VARIANT_MAX_CONCURRENT_RENDERS is 2 and the renderer blocks
|
|
When 5 requests for 5 distinct uncached (variant, image) pairs arrive
|
|
Then exactly 2 renders are in flight and the other 3 wait for a slot
|
|
And once a render is released, a waiting one starts, and every request ends with 200
|
|
|
|
Scenario: A cached file never takes a render slot
|
|
Given every render slot is held
|
|
When the public client requests a variant file that is already cached
|
|
Then the response is 200 immediately
|
|
|
|
Scenario: A waiting request whose client has gone does not render
|
|
Given every render slot is held and a request waits for a slot
|
|
When its context is cancelled
|
|
Then it stops waiting, no render starts for it and nothing is stored
|
|
|
|
Scenario: Identical concurrent requests still share one render and one slot
|
|
When 20 concurrent clients request the same uncached (variant, image) pair
|
|
Then the renderer is called once and only one slot is taken
|
|
|
|
Scenario Outline: The concurrency env var
|
|
When the settings are loaded with IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=<value>
|
|
Then the result is <result>
|
|
|
|
Examples:
|
|
| value | result |
|
|
| (unset) | 4 |
|
|
| 1 | 1 |
|
|
| 0 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
|
| -1 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
|
| many | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
|
|
|
Rule: SVG and animated GIF are served as they are
|
|
|
|
Scenario: An SVG with a valid variant is served unchanged
|
|
Given the image "logo.svg" is stored as "e5f6.svg"
|
|
When the public client requests /static/uploadedImages/e5f6.svg?v=thumb
|
|
Then the response is 200 with the stored bytes, the content type "image/svg+xml" and the existing Content-Security-Policy
|
|
And the renderer is not called and no blob or row is created
|
|
|
|
Scenario: An SVG with an unknown variant is still a 400
|
|
When the public client requests /static/uploadedImages/e5f6.svg?v=banner
|
|
Then the response is 400 unknown_variant
|
|
|
|
Scenario: An SVG larger than the cap is still served
|
|
Given the cap is lower than the SVG size
|
|
Then the response is 200 with the stored bytes
|
|
|
|
Scenario: An animated GIF is served unchanged
|
|
Given a stored blob "anim.gif" holding an animated GIF
|
|
When the public client requests /static/uploadedImages/anim.gif?v=thumb
|
|
Then the response is 200 with the stored bytes, the content type "image/gif" and no generated file
|
|
|
|
Scenario: A single-frame GIF is treated like any raster source
|
|
Given a stored blob "still.gif" holding a one-frame GIF
|
|
When the public client requests it with ?v=thumb
|
|
Then the response is a generated variant
|
|
|
|
Rule: Output geometry never upscales
|
|
|
|
Scenario Outline: The output size follows the fit and never exceeds the source
|
|
Given a variant with width <w>, height <h>, fit <fit> and format png
|
|
And a source of <sw> x <sh> pixels
|
|
When the variant is requested
|
|
Then the decoded output is <ow> x <oh>
|
|
|
|
Examples:
|
|
| sw | sh | w | h | fit | ow | oh |
|
|
| 4032 | 3024 | 400 | 300 | cover | 400 | 300 |
|
|
| 4032 | 3024 | 800 | | cover | 800 | 600 |
|
|
| 4032 | 3024 | | 600 | contain | 800 | 600 |
|
|
| 4032 | 3024 | 1000 | 1000 | inside | 1000 | 750 |
|
|
| 4032 | 3024 | 1000 | 1000 | cover | 1000 | 1000 |
|
|
| 4032 | 3024 | 1000 | 1000 | contain | 1000 | 1000 |
|
|
| 300 | 200 | 800 | | cover | 300 | 200 |
|
|
| 300 | 200 | | 800 | inside | 300 | 200 |
|
|
| 300 | 200 | 400 | 300 | cover | 267 | 200 |
|
|
| 300 | 200 | 400 | 300 | contain | 400 | 300 |
|
|
| 300 | 200 | 1200 | 1200 | contain | 1200 | 1200 |
|
|
| 300 | 200 | 1200 | 1200 | inside | 300 | 200 |
|
|
|
|
Scenario: Cover crops around the centre
|
|
Given a 40 x 20 source whose columns 0 to 9 are red, 10 to 29 are green and 30 to 39 are blue
|
|
When the variant cover 20 x 20 png is requested
|
|
Then the output is 20 x 20 and every pixel is green
|
|
|
|
Scenario: Contain pads transparent on png and webp
|
|
Given a 40 x 20 opaque red source and the variant contain 40 x 40 png
|
|
When it is requested
|
|
Then the output is 40 x 40, the top 10 rows and bottom 10 rows are fully transparent, and the middle 20 rows are red
|
|
|
|
Scenario: Contain pads white on jpeg
|
|
Given the same source and the variant contain 40 x 40 jpeg
|
|
Then the top 10 rows and bottom 10 rows are white (#FFFFFF) within JPEG tolerance
|
|
|
|
Scenario: Inside adds no padding
|
|
Given the same source and the variant inside 40 x 40 png
|
|
Then the output is 40 x 20 with no transparent pixel
|
|
|
|
Scenario: A transparent PNG source flattened to JPEG becomes white
|
|
Given a fully transparent 10 x 10 PNG and the variant 10 x 10 jpeg
|
|
Then every pixel of the output is white within JPEG tolerance
|
|
|
|
Rule: Output format
|
|
|
|
Scenario Outline: The output format and content type
|
|
Given the source "<source>" and the variant format <format>
|
|
Then the response content type is <type> and the body signature is <signature>
|
|
|
|
Examples:
|
|
| source | format | type | signature |
|
|
| png | original | image/png | PNG |
|
|
| jpeg | original | image/jpeg | JPEG |
|
|
| png | jpeg | image/jpeg | JPEG |
|
|
| jpeg | png | image/png | PNG |
|
|
|
|
Scenario: Quality changes the size of a lossy output
|
|
Given a photographic 600 x 600 source and the variants "lo" quality 20 and "hi" quality 95, both jpeg
|
|
Then the "lo" output is smaller than the "hi" output
|
|
|
|
Scenario: A png output ignores quality
|
|
Given two png variants stored with quality 30 and 95 submitted at creation
|
|
Then both outputs are byte-identical
|
|
|
|
Rule: WebP is conditional on the spike
|
|
|
|
Scenario: When a CGO-free lossy WebP encoder exists, webp is offered
|
|
Given the spike found an encoder that builds with CGO_ENABLED=0
|
|
When an admin creates a variant with format "webp"
|
|
Then the response is 201
|
|
And requesting it returns "image/webp" with a RIFF/WEBP signature, the right dimensions, and a lower quality giving a smaller file
|
|
And a webp source with format "original" is served as webp
|
|
|
|
Scenario: When no encoder is viable, webp is refused
|
|
Given the spike found no usable encoder and the renderer reports png, jpeg as encodable
|
|
When an admin sends POST /v1/image-variants with {"name": "wp", "width": 10, "format": "webp"}
|
|
Then the response status is 422 and the plain text body is "Format must be original, jpeg or png."
|
|
And creating with original, jpeg and png still works
|
|
|
|
Scenario: A stored GIF is served with the image/gif content type
|
|
Given a stored blob "anim.gif"
|
|
When the public client requests /static/uploadedImages/anim.gif
|
|
Then the response content type is "image/gif"
|
|
|
|
Scenario: A webp source still decodes without an encoder
|
|
Given a webp source and a jpeg variant
|
|
Then the response is 200 with a JPEG
|
|
|
|
Scenario: Without an encoder, a webp source with format original falls back to png
|
|
Given no webp encoder and a webp source with a variant of format original
|
|
Then the response content type is image/png
|
|
|
|
Scenario: The build stays CGO-free
|
|
When the module is built with CGO_ENABLED=0 and -tags nofrontend
|
|
Then it succeeds and go list -deps ./... lists no package that imports "C"
|
|
|
|
Rule: Every variant change 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 target is {"type": "image_variant", "id": "<name>", "name": "<name>"}
|
|
And the actions are media.variant_create, media.variant_update, media.variant_delete, media.variant_enable, media.variant_disable and media.variant_purge
|
|
And the change fields allowed in the log are extended with width, height, fit, format and quality, all of kind "text"
|
|
|
|
Scenario: Creating a variant is recorded
|
|
When the owner creates "hero" 1200x630 cover webp quality 82
|
|
Then one event exists with action "media.variant_create", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82}
|
|
|
|
Scenario: Updating a variant is recorded with the diff and the purge count
|
|
Given "hero" has quality 82 and 244 generated files
|
|
When the owner changes its quality to 70
|
|
Then one event exists with action "media.variant_update", changes [{"field": "quality", "kind": "text", "before": "82", "after": "70"}] and params {"purged_files": 244}
|
|
|
|
Scenario: A switch-only update does not claim a purge
|
|
When the owner sets generate_on_upload to true on "hero"
|
|
Then no "media.variant_update" event with a rendering change is recorded, and no event exists for that switch
|
|
|
|
Scenario: Deleting a variant is recorded with the removed files
|
|
Given "hero" has 244 generated files totalling 84 MB
|
|
When the owner deletes it
|
|
Then one event exists with action "media.variant_delete", target "hero" and params {"purged_files": 244, "purged_bytes": <total>}
|
|
|
|
Scenario Outline: Enabling and disabling are recorded
|
|
Given "hero" has enabled <before>
|
|
When the owner sends PATCH /v1/image-variants/hero with {"enabled": <after>}
|
|
Then one event exists with action "<action>" and target "hero"
|
|
|
|
Examples:
|
|
| before | after | action |
|
|
| false | true | media.variant_enable |
|
|
| true | false | media.variant_disable |
|
|
|
|
Scenario: A PATCH to the current value records nothing
|
|
When the owner sets enabled to its current value
|
|
Then the response is 200 and no event is recorded
|
|
|
|
Scenario: A refused create is recorded as failed
|
|
Given "thumb" exists
|
|
When the owner creates "thumb" again
|
|
Then one event exists with action "media.variant_create", outcome "failed", failure_reason "A variant with that name already exists." and target "thumb"
|
|
|
|
Scenario Outline: Requests that fail validation or find nothing record nothing
|
|
When <request>
|
|
Then no event is recorded
|
|
|
|
Examples:
|
|
| request |
|
|
| POST /v1/image-variants with width 9000 |
|
|
| PUT /v1/image-variants/missing with a valid body |
|
|
| DELETE /v1/image-variants/missing |
|
|
| PATCH /v1/image-variants/missing with {"enabled": true} |
|
|
| POST /v1/image-variants/missing/purge |
|
|
| GET /v1/image-variants |
|
|
| a public GET ...?v=thumb that generates a file |
|
|
|
|
Scenario: Purging a variant's cache is recorded
|
|
Given "hero" has 244 generated files totalling 84 MB
|
|
When the owner sends POST /v1/image-variants/hero/purge
|
|
Then one event exists with action "media.variant_purge", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"purged_files": 244, "purged_bytes": <total>}
|
|
|
|
Scenario: Purging an empty cache records nothing
|
|
Given "hero" has no generated file
|
|
When the owner sends POST /v1/image-variants/hero/purge
|
|
Then the response is 200 with {"purged_files": 0, "purged_bytes": 0} and no event is recorded
|
|
|
|
Scenario: A purge that fails on storage is recorded as failed
|
|
Given "hero" has 3 generated files and image.Storage fails to delete the second
|
|
When the owner sends POST /v1/image-variants/hero/purge
|
|
Then the response is 500 and one event exists with action "media.variant_purge", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
|
|
|
|
Scenario: A failing audit write never changes the response
|
|
Given the audit repository fails on Append
|
|
When the owner creates "hero"
|
|
Then the response is 201 and the variant exists
|
|
|
|
Scenario: The existing allowlist scenario is updated
|
|
Then the only change fields any producer emits are title, online, body, filename, display_name, width, height, fit, format and quality
|
|
|
|
Rule: Layering and hygiene
|
|
|
|
Scenario: The dependency rule holds
|
|
Then internal/domain/image imports only the stdlib and other internal/domain packages
|
|
And the variant use cases import no persistence, mailer, models, gorm, net/http, chi or api package
|
|
And the handler for /v1/image-variants and the public handler import no persistence, mailer, models or gorm package
|
|
And only api/dependecyInjection.go constructs the variant repositories, the renderer and the use cases
|
|
And the new GORM models are registered in internal/infrastructure/persistence/database/migrate.go
|
|
And no comment is added to any Go file, test or doc, except tool directives
|