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/?v= 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/, 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 with Then the response status is And the plain text body is 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 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 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 Then the response status is 422 And the plain text body is "" 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 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 to , 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 to , 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 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 and 244 generated files When an admin sends PATCH /v1/image-variants/hero with {"enabled": } Then the response status is 200 and the body has enabled 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/ with Then the response status is 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": } 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 /v1/image-variants/missing 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/ 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= 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 of "thumb" to And the public client requests the same URL again Then the new ETag is different from E1: 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 Then the response status is 400 with the content type "application/json" And the body is {"status": 400, "error": "", "message": "", "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 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 "" exists in image.Storage And When the public client requests /static/uploadedImages/?v=thumb Then the response status is 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 bytes When the public client requests /static/uploadedImages/big.png?v=thumb Then the response status is 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= Then the result is 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 x pixels When the public client requests /static/uploadedImages/wide.png?v=thumb Then the response status is 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= Then the result is 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= Then the result is 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 , height , fit and format png And a source of x pixels When the variant is requested Then the decoded output is x 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 "" and the variant format Then the response content type is and the body signature is 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": ""} 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": } Scenario Outline: Enabling and disabling are recorded Given "hero" has enabled When the owner sends PATCH /v1/image-variants/hero with {"enabled": } Then one event exists with 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 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": } 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