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

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

  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")

  1. 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

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. Invalid PUT rows leave variant and rows unchanged. Unknown name gives 404 with Variant not found.
  3. PATCH: both directions keep the index rows and call no storage Delete; 400/422/404 rows.
  4. DELETE: 204 with empty body, variant gone, its blobs and rows gone, others untouched. 404 row.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. Vanished blob: index row present, storage Open returns ErrBlobNotFound: regenerated, row repointed.
  5. 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.
  6. 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.
  7. Disabled then enabled: 400, then 200 served from the kept row (Put count unchanged). Deleted variant: 400.
  8. 404 table: missing name, .. traversal, nested path. 500 for undecodable bytes with nothing stored.
  9. Cap: boundary table 1000 bytes (200) and 1001 bytes (413), the 413 plain text body, the renderer count 0, the original served without ?v=.
  10. 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)

  1. Table (feature "Output geometry"): decode each output and assert width and height. Build the source with a solid colour for the size rows.
  2. 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.
  3. 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.
  4. 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

  1. 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

  1. 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

  1. 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

  1. 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.
  2. CGO_ENABLED=0 go build -tags nofrontend ./... succeeds, and go list -deps shows no package importing "C".
  3. 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

  1. 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.
  2. 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.
  3. 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=.
  4. 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)

  1. 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.
  2. S3 driver (STORAGE_DRIVER=s3, MinIO): generated blobs land in the bucket, purge removes them, serving works without Range or seek support.
  3. 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.
  4. 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.
  5. Memory and time on a source near IMAGE_VARIANT_MAX_SOURCE_BYTES with very large pixel dimensions.
  6. Read the activity rows in the real Activity screen against section 8 copy.