13 KiB
13 KiB
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
validator.InitValidator()once.jwtauth.New("HS256", []byte("test-secret"), nil)stands in forJWT_SECRET.- New hand-written in-memory fakes:
mockVariantRepo(CRUD, stores creation order, unique-name check returningimage.ErrVariantExists),mockVariantFileRepo(index rows with variant, source stored name, cached stored name, content type, size, fingerprint, withDeletecalls recorded and a per-row failure switch),mockVariantStorage(an in-memoryimage.Storagethat names blobsgen-<n>.<ext>, countsPut,OpenandDeletecalls, can failDeletefor one name, and can blockPut/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). ReusemockImageRepo, the user fake, the audit fakes fromtest/activity_support_test.go(mockAuditRepo,fakeAuditClock). - Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed
jwtcookies withuser_idandtoken_version; a stale cookie with a lowertoken_version. A valid API key in the key fake for the "API key refused" row. - Admin router: a fresh
chi.NewRouter()mirroringapi/router.go:jwtauth.Verifier,middleware.AuditContext(getUser, nil),jwtauth.Authenticator,RequireCurrentToken,RequireVerifiedUser,RequireOwnerOrAdmin, mounted at/v1/image-variants, with noapiKeyAuth. - Public router: mount the real
staticimages.NewHandler(...)(with the new variant dependency) at/static/uploadedImageson the same kind of router, overmockVariantStorageholding the originals. The router wrapschimiddleware.GetHeadlikeInitRoutes. - Images are generated in the test with
image/png,image/jpegandimage/draw(solid or half-and-half colours so crops and padding can be asserted pixel by pixel). Decode responses withimage.Decode. An animated GIF is built withimage/gifand two frames. A real SVG string is stored ase5f6.svg. Usex/image/webpto decode webp fixtures. - 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")
- 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
- Empty list: raw JSON has
"items":[]. Seeded list: order by name,width/heightas JSONnullwhen absent (assert on the raw body),qualitynull for png, counts and sizes from the file index. - Create: full body, defaults body, boundary table (name 2 and 32 chars,
width1 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 withapperr.BodyErrorMessage. Duplicate gives 409 with the exact sentence and leaves the stored variant unchanged. - When
EncodableFormats()lacks webp, the format rejection sentence isFormat must be original, jpeg or png.andwebpis refused with 422; run the format rows in both configurations.
C. Update, toggle, delete, purge
- PUT table over
width,height,fit,format,quality(purges 244 seeded index rows: assert the blobDeletecalls equal the row set, the index is empty for the variant, the other variant's rows untouched,purged_filesequals 244).generate_on_uploadandenabledrows purge nothing. Identical PUT leavesupdated_atas it was and records no event. A png variant ignores a quality edit. The bodynameis ignored. - Invalid PUT rows leave variant and rows unchanged. Unknown name gives 404 with
Variant not found. - PATCH: both directions keep the index rows and call no storage
Delete; 400/422/404 rows. - DELETE: 204 with empty body, variant gone, its blobs and rows gone, others untouched. 404 row.
- Purge: 200 with
purged_filesandpurged_bytes, variant intact, list shows zero. Empty cache returns zeros. StorageDeletefailing on the second of three blobs gives 500, two rows removed, one row kept. A blob already absent (storageDeletereturns nil perLocalStoragecontract) removes the row. - 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
- First request: 200, content type, decoded size, exactly one
Putand one index row equal to the body size, original blob untouched. Second request: byte-identical body, renderer count 1,Putcount 1. - No
?v=: the response equals the pre-slice response (headers and bytes) and the renderer count is 0. Other parameters such ast=123are ignored. - ETag: present, stable,
If-None-Matchgives 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_uploaddoes not). A staleIf-None-Matchafter an edit gets 200 with a new ETag. After an edit, the next request puts one new blob and the old blob was deleted. - Vanished blob: index row present, storage
OpenreturnsErrBlobNotFound: regenerated, row repointed. - 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,Putcount 1. Four distinct (variant, image) pairs with blocking renderers: assert all four renders start before any is released (use async.WaitGrouprendezvous in the fake, no sleeping). A failing first render gives 500 with no row and a successful retry. - 400 table: every URL of the outline, with the exact JSON body (decode and compare
status,error,message,detail),Content-Typestarting withapplication/json, renderer count 0, storagePutcount 0. Precedence:unsupported_parameterbeatsunknown_variant; 400 beats 404 for a missing source. - Disabled then enabled: 400, then 200 served from the kept row (
Putcount unchanged). Deleted variant: 400. - 404 table: missing name,
..traversal, nested path. 500 for undecodable bytes with nothing stored. - Cap: boundary table 1000 bytes (200) and 1001 bytes (413), the 413 plain text body, the renderer count 0, the original served without
?v=. - SVG and GIF: unchanged bytes, content types
image/svg+xml(with the existing CSP header) andimage/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)
- Table (feature "Output geometry"): decode each output and assert width and height. Build the source with a solid colour for the size rows.
- 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.
- 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. - WebP, conditional: with an encoder, assert
RIFF/WEBP, dimensions viawebp.DecodeConfig, lower quality gives a smaller file, and a webp source withoriginalstays webp. Without one, a webp source rendered to jpeg works andoriginalfalls back to png. Record in the test name which build is under test.
F. Config
- Settings load: unset gives 10485760,
2048gives 2048,0,-5andmanygive an error namingIMAGE_VARIANT_MAX_SOURCE_BYTES. Assert the var is present inenvDefaults(and absent fromrequiredEnvVars), in.env.exampleand indocs/configuration.md(grep-style test on the files).
G. Audit
- 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, categorymedia, targetimage_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 oftest/activity_log_producers*_test.goto the new field set.
H. Mutation guard
task mutatescope is./internal/infrastructure/useCases, scoped withMUTATE_FILESto the new use case files andMUTATE_RUNto 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-upscalemin, 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
- Guard test (
go list -depsor grep):internal/domain/imageimports no GORM, chi, http or infrastructure; the variant use cases import nopersistence,mailer,models,gorm,net/http, chi orapi; the handlers import nopersistence,mailer,modelsorgorm. CGO_ENABLED=0 go build -tags nofrontend ./...succeeds, andgo list -depsshows no package importing"C".- No comments in new or edited Go files, tests and docs (the hook enforces it). Docs:
docs/api.mdlists the routes and response shapes,docs/architecture.mdthe ports and the cache,docs/configuration.mdthe env var.
J. Hardening follow-up
- 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. - 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. - Known source: a blob without an image record (generated cache file or orphan) answers 404 for
?v=with no storageOpencall; parameter checks (400) still come first; the original of that blob is still served without?v=. - 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)
- Real boot with
task dev:apiover a scratch SQLite file andUPLOAD_DIR: createthumb, request/static/uploadedImages/<name>?v=thumbin a browser, check the file lands inUPLOAD_DIR, a second request is served without re-rendering, edit the quality and check the old file disappears. CheckAutoMigratecreated the new tables. - S3 driver (
STORAGE_DRIVER=s3, MinIO): generated blobs land in the bucket, purge removes them, serving works without Range or seek support. - 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.
- 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. - Memory and time on a source near
IMAGE_VARIANT_MAX_SOURCE_BYTESwith very large pixel dimensions. - Read the activity rows in the real Activity screen against section 8 copy.