Files
RenewCMS/features/image-variants.feature
T
2026-10-08 02:33:32 +02:00

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