feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -0,0 +1,778 @@
|
||||
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
|
||||
@@ -0,0 +1,74 @@
|
||||
# QA procedure: image-variants
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
|
||||
2. New hand-written in-memory fakes: `mockVariantRepo` (CRUD, stores creation order, unique-name check returning `image.ErrVariantExists`), `mockVariantFileRepo` (index rows with variant, source stored name, cached stored name, content type, size, fingerprint, with `Delete` calls recorded and a per-row failure switch), `mockVariantStorage` (an in-memory `image.Storage` that names blobs `gen-<n>.<ext>`, counts `Put`, `Open` and `Delete` calls, can fail `Delete` for one name, and can block `Put`/render on a channel), `countingRenderer` (wraps the real stdlib-based renderer, counts calls, can fail the first call, can block on a channel, `EncodableFormats()` configurable for the webp-on or webp-off cases). Reuse `mockImageRepo`, the user fake, the audit fakes from `test/activity_support_test.go` (`mockAuditRepo`, `fakeAuditClock`).
|
||||
3. Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie with a lower `token_version`. A valid API key in the key fake for the "API key refused" row.
|
||||
4. Admin router: a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier`, `middleware.AuditContext(getUser, nil)`, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, `RequireOwnerOrAdmin`, mounted at `/v1/image-variants`, with no `apiKeyAuth`.
|
||||
5. Public router: mount the real `staticimages.NewHandler(...)` (with the new variant dependency) at `/static/uploadedImages` on the same kind of router, over `mockVariantStorage` holding the originals. The router wraps `chimiddleware.GetHead` like `InitRoutes`.
|
||||
6. Images are generated in the test with `image/png`, `image/jpeg` and `image/draw` (solid or half-and-half colours so crops and padding can be asserted pixel by pixel). Decode responses with `image.Decode`. An animated GIF is built with `image/gif` and two frames. A real SVG string is stored as `e5f6.svg`. Use `x/image/webp` to decode webp fixtures.
|
||||
7. Set the cap by constructing the use case with the value; do not touch the process environment except in the config test.
|
||||
|
||||
## A. Guards (feature "Only owners and admins")
|
||||
8. Table: each method, path and credential gives the status and the exact body constant; assert no variant, index, storage or audit fake was touched. API key gets 401. Owner and admin both get 200.
|
||||
|
||||
## B. List and create
|
||||
9. Empty list: raw JSON has `"items":[]`. Seeded list: order by name, `width`/`height` as JSON `null` when absent (assert on the raw body), `quality` null for png, counts and sizes from the file index.
|
||||
10. Create: full body, defaults body, boundary table (name 2 and 32 chars, `width` 1 and 8000, quality 1 and 100). Invalid table gives 422 with each exact sentence and verifies nothing stored and no audit event. Malformed JSON rows give 400 with `apperr.BodyErrorMessage`. Duplicate gives 409 with the exact sentence and leaves the stored variant unchanged.
|
||||
11. When `EncodableFormats()` lacks webp, the format rejection sentence is `Format must be original, jpeg or png.` and `webp` is refused with 422; run the format rows in both configurations.
|
||||
|
||||
## C. Update, toggle, delete, purge
|
||||
12. PUT table over `width`, `height`, `fit`, `format`, `quality` (purges 244 seeded index rows: assert the blob `Delete` calls equal the row set, the index is empty for the variant, the other variant's rows untouched, `purged_files` equals 244). `generate_on_upload` and `enabled` rows purge nothing. Identical PUT leaves `updated_at` as it was and records no event. A png variant ignores a quality edit. The body `name` is ignored.
|
||||
13. Invalid PUT rows leave variant and rows unchanged. Unknown name gives 404 with `Variant not found`.
|
||||
14. PATCH: both directions keep the index rows and call no storage `Delete`; 400/422/404 rows.
|
||||
15. DELETE: 204 with empty body, variant gone, its blobs and rows gone, others untouched. 404 row.
|
||||
16. Purge: 200 with `purged_files` and `purged_bytes`, variant intact, list shows zero. Empty cache returns zeros. Storage `Delete` failing on the second of three blobs gives 500, two rows removed, one row kept. A blob already absent (storage `Delete` returns nil per `LocalStorage` contract) removes the row.
|
||||
17. Source delete and merge: through the real delete and merge media use cases, assert the generated blobs and rows of the removed image(s) are deleted and those of other images are kept. Assert the 204 status and the existing library behaviour are unchanged.
|
||||
|
||||
## D. Public serving
|
||||
18. First request: 200, content type, decoded size, exactly one `Put` and one index row equal to the body size, original blob untouched. Second request: byte-identical body, renderer count 1, `Put` count 1.
|
||||
19. No `?v=`: the response equals the pre-slice response (headers and bytes) and the renderer count is 0. Other parameters such as `t=123` are ignored.
|
||||
20. ETag: present, stable, `If-None-Match` gives 304 with an empty body and no renderer call on a cached file. Edit table (quality, width, height, fit, format change the ETag; `generate_on_upload` does not). A stale `If-None-Match` after an edit gets 200 with a new ETag. After an edit, the next request puts one new blob and the old blob was deleted.
|
||||
21. Vanished blob: index row present, storage `Open` returns `ErrBlobNotFound`: regenerated, row repointed.
|
||||
22. Concurrency (run with `-race`): 20 goroutines released by a barrier channel against a renderer blocking on a channel; all 200 with identical bytes, renderer count 1, `Put` count 1. Four distinct (variant, image) pairs with blocking renderers: assert all four renders start before any is released (use a `sync.WaitGroup` rendezvous in the fake, no sleeping). A failing first render gives 500 with no row and a successful retry.
|
||||
23. 400 table: every URL of the outline, with the exact JSON body (decode and compare `status`, `error`, `message`, `detail`), `Content-Type` starting with `application/json`, renderer count 0, storage `Put` count 0. Precedence: `unsupported_parameter` beats `unknown_variant`; 400 beats 404 for a missing source.
|
||||
24. Disabled then enabled: 400, then 200 served from the kept row (`Put` count unchanged). Deleted variant: 400.
|
||||
25. 404 table: missing name, `..` traversal, nested path. 500 for undecodable bytes with nothing stored.
|
||||
26. Cap: boundary table 1000 bytes (200) and 1001 bytes (413), the 413 plain text body, the renderer count 0, the original served without `?v=`.
|
||||
27. SVG and GIF: unchanged bytes, content types `image/svg+xml` (with the existing CSP header) and `image/gif`, renderer count 0, nothing stored, unknown variant still 400, SVG larger than the cap still 200. A one-frame GIF is rendered.
|
||||
|
||||
## E. Geometry and format (unit-level, no HTTP, same files)
|
||||
28. Table (feature "Output geometry"): decode each output and assert width and height. Build the source with a solid colour for the size rows.
|
||||
29. Pixel assertions: cover crops around the centre of a 40 x 20 source with red columns 0-9, green 10-29, blue 30-39 and every pixel of the 20 x 20 output is green; contain pads transparent on png (alpha 0 on the padded rows, assert RGBA) and white on jpeg (within a tolerance of 8 per channel); inside has no transparent pixel; a fully transparent PNG source flattened to jpeg is white.
|
||||
30. Format table: signature bytes (`\x89PNG`, `\xff\xd8`, `RIFF....WEBP`) and content types for original, jpeg, png. Lossy size: quality 20 smaller than quality 95 on a noisy fixture. PNG variants stored with quality 30 and 95 give byte-identical outputs.
|
||||
31. WebP, conditional: with an encoder, assert `RIFF/WEBP`, dimensions via `webp.DecodeConfig`, lower quality gives a smaller file, and a webp source with `original` stays webp. Without one, a webp source rendered to jpeg works and `original` falls back to png. Record in the test name which build is under test.
|
||||
|
||||
## F. Config
|
||||
32. Settings load: unset gives 10485760, `2048` gives 2048, `0`, `-5` and `many` give an error naming `IMAGE_VARIANT_MAX_SOURCE_BYTES`. Assert the var is present in `envDefaults` (and absent from `requiredEnvVars`), in `.env.example` and in `docs/configuration.md` (grep-style test on the files).
|
||||
|
||||
## G. Audit
|
||||
33. Create, update (diff plus `purged_files`), delete (`purged_files`, `purged_bytes`), enable, disable and purge events (purge: counts on success, nothing for an empty cache or an unknown name, a failed event with the generic reason when storage fails) with the exact action, category `media`, target `image_variant`, actor and source from the origin. No-op PUT and PATCH record nothing; switch-only PUT records nothing for the rendering diff. Duplicate create records a failed event with its reason; validation errors, 404s, list and public generation record nothing. A failing audit repository leaves the response and the change untouched. Update the allowlist assertion of `test/activity_log_producers*_test.go` to the new field set.
|
||||
|
||||
## H. Mutation guard
|
||||
34. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the 1 and 8000 and 2 and 32 boundaries (just inside and just outside), the "at least one dimension" rule, every rendering-setting equality in the purge decision with equal and differing values, the cap comparison (`>` not `>=`, at 1000 and 1001), the never-upscale `min`, the singleflight key (variant and image both), the ETag input set, the check order (parameters, variant, source, as-is types, cap, ETag, cache), and the audit no-op guards. Bare-bool conditions produce no mutant, so cover both branches anyway.
|
||||
|
||||
## I. Layering and hygiene
|
||||
35. Guard test (`go list -deps` or grep): `internal/domain/image` imports no GORM, chi, http or infrastructure; the variant use cases import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the handlers import no `persistence`, `mailer`, `models` or `gorm`.
|
||||
36. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds, and `go list -deps` shows no package importing `"C"`.
|
||||
37. No comments in new or edited Go files, tests and docs (the hook enforces it). Docs: `docs/api.md` lists the routes and response shapes, `docs/architecture.md` the ports and the cache, `docs/configuration.md` the env var.
|
||||
|
||||
## J. Hardening follow-up
|
||||
44. Pixel cap: a PNG whose IHDR declares 30000 x 30000 (a few hundred bytes of real data) answers 413 with the plain text body and the renderer fake records zero calls; boundary 100 x 100 passes and 101 x 100 fails at a cap of 10000; the original is still served without `?v=`; an unreadable header gives 500. Settings tests for IMAGE_VARIANT_MAX_SOURCE_PIXELS (unset 50000000, 2048, 0, -5, many) and presence in envDefaults, `.env.example`, `docs/configuration.md`.
|
||||
45. Render slots: a renderer fake that blocks on a channel proves at most N renders run at once, the rest wait and then complete with 200; a cached hit does not wait; a cancelled request context stops waiting and renders nothing; identical concurrent requests share one slot. Settings tests for IMAGE_VARIANT_MAX_CONCURRENT_RENDERS (unset 4, 1, 0, -1, many) and the three-place documentation check. Run with `-race`.
|
||||
46. Known source: a blob without an image record (generated cache file or orphan) answers 404 for `?v=` with no storage `Open` call; parameter checks (400) still come first; the original of that blob is still served without `?v=`.
|
||||
47. Contain geometry: 300 x 200 in 400 x 300 gives a 400 x 300 canvas with the image centred and unscaled (transparent padding on png); 300 x 200 in 1200 x 1200 gives 1200 x 1200; single-dimension contain keeps no padding.
|
||||
|
||||
## Manual (not automated)
|
||||
38. Real boot with `task dev:api` over a scratch SQLite file and `UPLOAD_DIR`: create `thumb`, request `/static/uploadedImages/<name>?v=thumb` in a browser, check the file lands in `UPLOAD_DIR`, a second request is served without re-rendering, edit the quality and check the old file disappears. Check `AutoMigrate` created the new tables.
|
||||
39. S3 driver (`STORAGE_DRIVER=s3`, MinIO): generated blobs land in the bucket, purge removes them, serving works without Range or seek support.
|
||||
40. Visual quality of a real photograph in jpeg and, if the spike succeeded, webp; check the contain padding looks right against a white and a dark page.
|
||||
41. WebP spike: record the evaluated encoders, the build result with `CGO_ENABLED=0`, speed and size on a 4032x3024 photo, and the decision. Report to the user if none works.
|
||||
42. Memory and time on a source near `IMAGE_VARIANT_MAX_SOURCE_BYTES` with very large pixel dimensions.
|
||||
43. Read the activity rows in the real Activity screen against section 8 copy.
|
||||
Reference in new issue
Block a user