# QA procedure: Sites backend Spec: `features/sites_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. Every error assertion compares the plain-text body to the exact code token. ## Setup - New files `test/site_*_test.go`. Fakes have unique names: `mockSiteRepo` (sites, default flag, article counts, transactional reassign-and-delete as one operation, failure injection), `mockSiteImageRepo` (images and usage), and an extended `mockArticleRepo` (site_id, site filter, move). A fake audit repository with fail and panic switches is reused. - Routers mirror `api/router.go`: a new `NewSiteRouter`, plus the article and media routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Requests carry real signed `jwt` cookies carrying the `user_id` claim for an owner, an admin and an unverified account (roles are only owner and admin); an API key request uses the key authentication path. ## A. Reading (Rule: Reading sites) 1. GET /v1/site/ with three sites: 200, order default first then name, `article_count` on each. 2. GET /v1/site/ credentials table: owner, admin, API key give 200; unverified gives 403; none gives 401. 3. A site without image and locale returns `default_social_image_id` null and `locale` null. ## B. Creating 4. POST creates a site: 201, full body, stored, `is_default` false, `article_count` 0. 5. Optional fields default to `default_robots` "index", locale null, image null. 6. POST with `is_default` true: the new site is the only default. 7. Base URL normalisation table (trailing slash, host case, loopback http). 8. Base URL rule table (all rows, `invalid_site_url`). 9. Key format table (valid and invalid rows) and `site_key_taken` 409 with nothing stored. 10. Other fields table: name, robots, locale (`invalid_locale`), unknown image (`image_not_found`), malformed JSON gives 400 and nothing stored. 11. Locale table: valid values stored, empty and null stored as null. ## C. Editing 12. PATCH edits name, base_url, robots, locale and returns the site with the unchanged key. 13. Absent fields stay untouched. 14. Image set to 5 then cleared with null. 15. Key table: same key 200, different key 400 `key_immutable`, key unchanged. 16. A rejected key change leaves the name unchanged. 17. `is_default` in the body: 400 `use_set_default`, default unchanged. 18. Validation table (base_url, locale, image, name, robots), site unchanged. 19. Missing site 404 `site_not_found`; malformed id gives the uint32 format error. 20. No-op edit: 200 and no audit event. ## D. Default site 21. PATCH /{id}/default swaps the default; exactly one default remains. 22. Current default: 200, no event. Missing site: 404. 23. Fault injected on the second step of the swap: the previous default is still the only default (the fake mimics the transaction rollback). 24. A subsequently created article gets the new default's id. ## E. Deleting 25. Empty site: 204, gone. 26. Site with articles and no `reassign_to`: 409 `site_has_articles`, unchanged. 27. With a valid `reassign_to`: 204, articles moved, site gone, via a single repository operation (assert the fake records one call). 28. Failure injected in that operation: articles and site unchanged. 29. `reassign_to` table: 999 gives 404 `site_not_found`; the site's own id and a non-numeric value give 400 `invalid_reassign`. 30. Default site: 403 `default_site`, also when it has articles and no `reassign_to`. Missing site: 404. Unused `reassign_to` on an empty site: 204, destination untouched. ## F. Owner-only writes 31. Role table for POST, PATCH, PATCH default, DELETE: admin gives 403 "Only the owner can do this."; API key gives 403 "This action needs a real session."; nothing changes. 32. Admin with a malformed body still gets 403 "Only the owner can do this.". ## G. Articles 33. New article is offline and on the default site; reads (GET one, GET list, publish, PATCH) carry `site_id`. 34. `GET /v1/article/?site=shop` filters; composes with status (online, draft), q, sort (for example `title:asc`), size and page; `?site=nope` gives 200 empty with global `counts`; `?site=` gives 400 `apperr.BodyErrorMessage`. 35. Without a verified account, `GET /v1/article/?site=shop` returns online articles only (there is no separate public route); `GET /v1/article/{offline id}` is 404 "Article not found". 36. PATCH `site_id` moves the article for owner, admin and an API key; title plus `site_id` change together; omission keeps the site; `is_online` unaffected; rename of the site leaves the article readable. 37. Unusable `site_id` table, one case each: 999 gives 404 `site_not_found`; 0 gives 400 with the validator error text; -1 and "a" give 400 `apperr.BodyErrorMessage` (decode failure); null gives 400 `apperr.BodyErrorMessage`; `{}` and an unknown-only object give 400 `apperr.BodyErrorMessage`; in every case the article is unchanged and no event is recorded. A same-site move returns 200 and records nothing. ## H. Media 38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused". 39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`. 40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image. ## I. Activity log 41. Success table: one event per write with action, category "sites", target, actor, source, recorded after the change. 42. Failed table with the plain reasons; unknown ids, 403, 401, malformed requests record nothing; reads record nothing. 43. Failing and panicking audit repository leave the response and the change as with a healthy one. 44. After recording site events, a verified session GET /v1/activity/?category=sites returns the create, edit, set default and delete events with category "sites" and no events of other categories (an API key gets the route's existing session refusal). Failure reasons use the style of `auditFailures.go`: the four site sentinels map to the plain sentences of the feature and any other error gives `GenericFailureReason`. The change fields are limited to name, base_url, default_robots, locale, default_social_image, site and never "key"; article.edit with a site change holds `{"field":"site","from":"Main","to":"Shop"}`. ## J. Persistence and migration 45. Repository-contract harness (temp-dir SQLite, MySQL and Postgres opt-in through the existing env switches): site repository contract, transactional default swap, reassign-and-delete, backfill. `database.Migrate` is called with PUBLIC_URL at every caller, including `test/sqlite_schema_unchanged_test.go`, which is updated for the new table and column. Seed table (path dropped, http rewritten to https, loopback kept), unparseable value returns an error naming PUBLIC_URL and creates no site, backfill of existing articles, second run changes nothing, owner-edited base URL survives a restart. ## K. Quality gates 46. Layering guard: the use cases import no persistence, mailer, models, gorm, net/http or chi; the domain site package imports no GORM or HTTP; handlers import no persistence; no comments added. 47. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES= MUTATE_RUN='TestSite' MUTATE_WORKERS=2 task mutate -- `; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`. 48. Docs (Rule: Documentation), checked by reading the files: docs/api.md documents the five /v1/site routes with auth, error tokens, article `site_id`, the `?site=` filter, `sites_updated` on media merge and purge; docs/configuration.md says PUBLIC_URL seeds the first site once at migration; docs/architecture.md names the site aggregate, repository and use cases. No comment is added to any file. ## Manual - MySQL and Postgres: run the contract suite with the opt-in env vars against real servers. - Real boot with an old database copy and PUBLIC_URL set: one "Default" site appears, articles backfilled, second boot changes nothing. - Docker with the `/data` volume: boot an existing database and confirm the same. - Read the activity log screen and the CSV export: site events show names and failure reasons, and no key change. - Not covered here: the SPA (a later slice).