# QA procedure: Hardening after the Sites, Authors and SEO slices Spec: `features/tech_debt_hardening.feature`. The Go part is in `test/` (package `test`, `task test`); the web part is Vitest (`task test:web`). Constraints: no commits; no comments in code; scoped `task mutate` only for the Go files of items 3, 8 and 9 (and of items 5 and 6 per their own QA files); `task visual:check` only on `articles`, `sites` and `integration` to confirm no regression, with no new `approvedBy` exception and the waived screens untouched; `docs/design` read-only. ## Item 3: GET /v1/media/{id} (Go, `test/`, `task test`) Build `media.NewHandler(...)` over the real media use cases and the existing fake image repository and storage fakes used by the media tests (unique fake type names across files). Mount on a fresh `chi.NewRouter()` that mirrors `NewMediaRouter` in `api/router.go`, behind `jwtauth.Verifier`, the API key authentication path, `middleware.RequireSignedIn`, `jwtauth.Authenticator` and `RequireVerifiedUser`, with a test `jwtauth.New("HS256", ...)`. Requests carry signed `jwt` cookies with the `user_id` claim for an owner, an admin, a verified account with another role and an unverified account. Call `validator.InitValidator()`. 1. Credentials table on an existing image 5: owner, admin, other verified role and a valid API key give 200; an unverified account gives 403; no credentials give 401. 2. Shape: decode `GET /v1/media/5` and the matching element of `items` from `GET /v1/media/` into maps; assert the key sets and values are equal. 3. `GET /v1/media/999` gives 404 with the exact plain-text body "Image not found" (`apperr.ImageNotFoundErrorMessage`). 4. Malformed ids `abc`, `-1`, `4294967296`, `1.5` give 400 with `apperr.IdUint32ErrorMessage`. 5. Regression: `GET /{id}/usage`, `GET /{id}/variants`, `PATCH /{id}` and `DELETE /{id}` still answer as before (the existing tests stay green without edits), and the variant routes keep their `RejectAPIKey` and owner/admin rule. 6. No activity event is recorded by the read (the fake audit repository stays empty). 7. Docs, read by hand: `docs/api.md` documents `GET /v1/media/{id}` (auth, 200 shape, 404, 400); `web/src/data/apiCatalogue.ts` lists it in the Media group, and its spec and `IntegrationView.spec.ts` counts are updated; `docs/release-notes.md` line. 8. Scoped mutation through `task mutate` only, for the media files touched by the change: `MUTATE_FILES= MUTATE_RUN='TestMedia' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. Report LIVED and NOT COVERED; re-check filtered suspects without `MUTATE_RUN`. Then `task lint:go` and `task fmt`. ## Item 3: media names (Vitest, `web/`) Mock `useApi`'s `request` for `GET /v1/media/{id}` per case. 9. Open the SEO panel with cover id 150 and social id 151 (neither in any first page of the library). Mock `GET /v1/media/150` and `GET /v1/media/151` to return items. Assert the panel shows their names and thumbnails, and that no `GET /v1/media/?page=1&size=100&sort=newest` request is made. 10. Re-render with the same selection: assert no new request (the mocked call count stays). 11. `GET /v1/media/150` answers 404: the fallback is shown, no notice. A 500 and a network error: panel usable, fallback, no notice. 12. A library of 12 items: the same names as before. 13. The existing `useMediaNames` and SEO-panel specs pass; the assertions that depended on the list lookup are amended only where they mock the old request, and that diff is listed in the report. ## Item 4: SEO check cache (Vitest) 14. Same fields, different key order: one POST (assert the mocked call count). 15. Table, one case per result-affecting field (title, body, description, excerpt, slug, cover image, social image, noindex, site, canonical URL): a second POST is sent. 16. A field the check ignores changes: no second POST. 17. A 500 is not cached: the retry POSTs. 18. 200 distinct inputs: the cache size stays at its existing limit and the most recent input is a hit. 19. The existing editor SEO specs pass unchanged. ## Item 8: chunked lookups (Go, `test/`) 20. Temp-dir SQLite (never the real database file): seed 1,200 articles; load all ids; assert 1,200 rows, no duplicates, requested order. 21. Boundary table 0, 1, 499, 500, 501, 1000, 1001 ids. 22. 600 ids with 100 missing gives 500 rows. 700 articles with 3 ordered credits each come back in stored order across batches (including the `position ASC, id ASC` rule). Soft-deleted rows match the single-query path. 23. Name the MySQL and Postgres variants so the existing filters match them: `TestMySQLChunkedLookups` (run by `-run MySQL`) and `TestPostgresChunkedLookups` (run by `-run Postgres`). They skip when `RENEWCMS_TEST_MYSQL_DSN` or `RENEWCMS_TEST_POSTGRES_DSN` is unset, like the existing contract tests. ## Item 9: slug backfill (Go, `test/`, temp-dir SQLite) 24. Seed 5,000 articles with empty slugs across two sites; run `database.BackfillArticleSlugs`; assert unique non-empty slugs within each site; assert the writes ran in several bounded transactions (the batch size is a named constant chosen by the coder; assert through a counting hook or a GORM callback counter in the test, not a log). 25. Second boot: record every row's `slug` and `updated_at` and the DB write count (callback), run again, and assert the values are identical and the write count is 0. Also call `database.Migrate` twice. 26. Interrupted run: stop after the first batch (failure injection on the second batch), assert the first batch is committed, rerun, and assert the rest are filled and the first batch's slugs unchanged. 27. Suffix table (3 same-title articles in one site plus 1 in another), tombstones for soft-deleted rows written once, owner-set slugs kept, index `idx_articles_site_slug` present and a repeated `ensureArticleSlugIndex` returns nil. 28. Existing slug migration tests (`planSlugAssignments`) stay green. ## Quality gates 29. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the domain package imports no GORM or HTTP; handlers import no persistence; no comment added. 30. Scoped mutation for items 3, 8 and 9 only, through `task mutate`: `MUTATE_FILES=articleRepository.go,articleSlugBackfill.go, MUTATE_RUN='TestChunked|TestBackfill|TestMedia' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/persistence/repositories ./internal/infrastructure/persistence/database ./internal/infrastructure/useCases`. Report LIVED and NOT COVERED; re-check filtered suspects without `MUTATE_RUN`. Then `task lint:go`, `task fmt`, `task test` and `task test:web`. 31. `task visual:check -- articles`, `-- sites` and `-- integration`: all captures pass at 10 %; no exception added. ## Manual - MySQL and PostgreSQL (item 8, and the repository cases of items 5 and 6): run `task test:mysql` (mysql:8.4 then mariadb:11) and `task test:postgres` (postgres:16 then 17). Docker is required; the tasks start and remove the containers. Extra arguments go after `--`, for example `task test:postgres -- -run Postgres.*Chunked -v`. To use your own server instead: `RENEWCMS_TEST_MYSQL_DSN='user:pass@tcp(host:3306)/db' go test -tags nofrontend ./test/... -run MySQL -count=1` and `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run Postgres -count=1`. Use a scratch database. No CI exists, so this is run by hand. - Item 3 on a live app: with more than 100 media items, open an article whose cover is an old image; the SEO panel shows its name. `curl -i -b http://localhost:8080/v1/media/` returns the item, 404 for a missing id and 400 for `abc`. - Slug backfill on a real file: copy an old database, boot with `task dev`, confirm slugs and that a second boot changes no row (compare `updated_at` before and after). - Item 7 (the `?seo=` measurement) is a measurement with its result recorded in `docs/`, so it has no Gherkin.