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,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