# QA procedure: SEO output backend (slice 2 of 4) Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. Success bodies are JSON. Assert exact key sets by decoding into `map[string]any` and comparing sorted key lists. ## Divergences from docs/design `docs/design` is read-only; slices 3 and 4 align to the code. - **published_at:** `created_at` for online articles, `null` for drafts; `modified_at` is `updated_at`. No new column, no migration (user decision). - **Social image name:** the change-list fallback chain says `og_image_id`; the article field is `social_image_id`. The code name is used. - **Sitemap envelope:** the sitemap answers a JSON array with the total in `X-Total-Count`, as integration.md says. Articles and media list routes answer an envelope without that header. The two conventions coexist. `X-Total-Count` is added to the CORS `ExposedHeaders` (alongside `Link`) and documented in docs/api.md. - **Sitemap sizes:** 25, 50, 100, 500 with default 100 (integration.md), unlike the 5, 25, 50, 100 of the other paged routes. Order is id ascending. - **Unknown site key:** the body is the existing token `site_not_found` (`apperr.SiteNotFoundErrorMessage`), not the copy "Site not found" of integration.md. - **Check body:** accepts the 12 SEO fields and an optional `body`; `site_id` and `author_ids` answer 400. `{}` checks the saved state. - **Thresholds:** `no_h` needs at least 1000 characters of plain text and no h2-h6; `short` is under 300 characters. - **Status precedence:** noindex gives `hidden` regardless of suggestions; `seo_suggestions` still counts non-dismissed ones. - **`%site%` with an empty `site_name`:** replaced by an empty string, then the title is trimmed (whitespace only). - **same_as:** social link URLs only; the website is in `url` only; JSON-LD `sameAs` equals `same_as`. ## Setup - New files `test/seo_output_*_test.go`. Fakes have unique names: `mockSeoOutArticleRepo` (articles with per-site slugs, author credits, list filters q, status, site, sort, seo with paging, a call counter, failure injection), `mockSeoOutSiteRepo` (sites with key lookup), `mockSeoOutAuthorRepo`, `mockSeoOutImageRepo` and the fake audit repository (reuse, with a counter that asserts no Append). - Routers mirror `api/router.go`: the public `/{id}/seo` route and the authenticated `/{id}/seo/check` route on the article router, the public `/{key}/sitemap` route on the site router. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Signed `jwt` cookies with the `user_id` claim for: owner, admin, a verified account with another role, an unverified account, plus a malformed cookie. API-key requests use the key authentication path. - Fixtures: the background sample site 1 and article 12, builders for variants (seo_title lengths, descriptions, covers, authors with website and links, bodies with headings and images). Fixed `created_at`/`updated_at` times; no clock reads, so no clock-sensitive items. - Assertions read the fakes' call counters and stored state, never only the JSON. ## A. Resolver 1. Shape: exact key sets of the root, `og`, `twitter`, `image`, `fallbacks`; no `body`, `id` or `seo_dismissed`; raw response has no body text. 2. Bare article: every output through the fallbacks with the exact values of the background; `fallbacks` values; `published_at` and `modified_at`. 3. Tables: title chain and template variants; empty `site_name` with each template (trimmed); description chain including first-sentence extraction (tags stripped, entities, terminator or none, heading plus paragraph) and the empty chain; image chain (social, cover with alt, site, none) with `image`, `og.image`, `twitter.image`, `twitter.card`; canonical and robots (override, site default `index`/`noindex`, `noindex: true`). 4. Social overrides apply to og and twitter only; social title and description fall back independently. 5. Authors: ordered, `url` from website or `""`, `same_as` from links in order and never the website; no authors gives `[]` and no `author` key in json_ld. 6. JSON-LD: compare the full decoded object to the expected one; headline uses the meta title without the template; dates in RFC 3339; image omitted when none; `datePublished` omitted for drafts. 7. `?format=html|json|pdf` returns the identical body as the bare call; second site uses its own base_url, template and robots. ## B. Visibility 8. Credentials table for a draft: owner, admin, other verified role, API key give 200; none, unverified, malformed cookie give 404 `Article not found`. An online article is 200 for everyone. 9. Draft has `published_at` null and no `datePublished`. Unpublish makes the object 404 for anonymous; missing article 404; `abc` gives `apperr.IdUint32ErrorMessage`; read performs no write and no audit Append; repository failure gives 500. ## C. Check endpoint 10. Shape: exact keys of root, suggestion, `slug`. 11. Code table: one article per code with the exact `field` and `params` and the thresholds above. 12. Edge tables: title 60/61, description 70/69 and 160/161, slug 75/76, `no_h` 999/1000 with h2, h6, h1 only, `short` 299/300, rune counting with 2-byte characters, title measured before the template, text length ignoring markup. 13. Fully prepared article gives `ready` and `[]`; overrides replace saved values (set, clear, null image id, body); the saved article, its `updated_at`, the site, the media usage and the activity log are unchanged after every call. 14. Dismissed: saved codes filtered; override `seo_dismissed` filters; all 11 dismissed on a noindex article gives `hidden` with `[]`; fixed code order (title_long first, no_au before short). 15. Status table: ready, suggestions, hidden (noindex with and without suggestions); a `noindex` override gives `hidden` without saving. 16. Slug table: own slug, free, other site's, taken with `-3` suggestion where `-2` is also taken, empty string regeneration (free and taken), 100-character suggestion, taken slug is 200 not 409. 17. Validation table identical to PATCH (status and token); malformed and null bodies (including `body: null`) give 400 `apperr.BodyErrorMessage`; `{}` gives 200; `site_id` and `author_ids` give 400. 18. Credentials: owner, admin, other verified role, API key give 200; unverified 403; none 401; draft 200; missing 404; `abc` `IdUint32ErrorMessage`. 19. Publishing a draft with suggestions still returns 200. ## D. List 20. `seo_status` and `seo_suggestions` per item for ready, suggestions, hidden and a partly dismissed article; no `seo_dismissed` key on items; existing fields unchanged. 21. Agreement test: for each fixture article, list values equal the check result of the same use case (guards the one-rule-set requirement). 22. Filter table `ready`, `suggestions`, `hidden` (ids and totals); composition with `q`, `status`, `site`, `sort` and `page`/`size`; pages disjoint and stable; counts unchanged; filter applied before paging; anonymous caller sees online items only; dismissed codes not counted. 23. Invalid table (`maybe`, empty, `READY`, `ready,hidden`, `1`) gives 400 `apperr.BodyErrorMessage` and the repository counter stays 0; `format=pdf` is reported first; no-filter behaviour unchanged. ## E. Sitemap 24. Only online articles of the key; `X-Total-Count` equals the filtered total; exact item key set; `noindex` flag included; url from slug and base_url; drafts excluded even with a verified cookie; unpublish removes. 25. Public: no credentials gives 200; empty site gives `[]` and `0`. 26. Paging table (250 articles: default 100, size 25, page 3, size 500, out of range gives empty array and the real total); 60 articles partition into 25, 25, 10 in id order with no overlap. 27. Invalid table (`page=0`, `-1`, `abc`, empty, `size=0`, `10`, `1000`, `abc`) gives 400 `apperr.BodyErrorMessage` and the repository counter stays 0. 28. Unknown key gives 404 `site_not_found`; an id as the key gives 404; PATCH `/v1/site/{id}` still owner only; repository failure gives 500. 29. CORS: with the CORS middleware enabled, `Access-Control-Expose-Headers` lists `X-Total-Count` and `Link`. ## F. Quality gates 30. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; domain packages import no GORM or HTTP; handlers import no persistence; `api/dependecyInjection.go` builds the new use cases and `api/router.go` wires them; validation uses validate tags through `validator.Validate.Struct`; no comments added. 31. Scoped mutation run through `task mutate` only, on the new use cases (resolver, suggestion rules, check, list status, sitemap), e.g. `MUTATE_FILES= MUTATE_RUN='TestSeoOutput' MUTATE_WORKERS=2 task mutate -- `. Report LIVED and NOT COVERED mutants and re-check filtered suspects without `MUTATE_RUN`. Boundary mutants (60/61, 70/69, 160/161, 75/76, 999/1000, 299/300) and the fallback-chain order are the expected high-value targets. Also `task lint:go` and `task fmt`. ## Non-behavioural deliverables (checked by reading) - `docs/api.md`: the three new endpoints, the `seo` list parameter, the `seo_status` and `seo_suggestions` fields, the `X-Total-Count` header on the sitemap and its CORS exposure. - `docs/api/articles.md`: the resolved object, fallback chain, codes and soft ranges, draft visibility. - `docs/release-notes.md`: announcement. - `web/src/data/apiCatalogue.ts`: entries for `GET /v1/article/{id}/seo`, `POST /v1/article/{id}/seo/check`, `GET /v1/site/{key}/sitemap`, plus the `seo` list parameter and the two list fields; its spec and `web/src/views/IntegrationView.spec.ts` counts updated. Run under `task test:web`; report NOT RUN if the runner is not installed. ## Manual - Real boot against a database with slice 1 data: `GET /v1/article/{id}/seo` for a published article, a draft with and without a session, the sitemap for the default site and a second site. - Cross-origin check that `X-Total-Count` is readable when CORS is configured. - Paste a resolved `json_ld` into a schema.org / Rich Results validator. - Not covered here: the SPA (slices 3 and 4).