92 lines
12 KiB
Markdown
92 lines
12 KiB
Markdown
# QA procedure: SEO data backend (slice 1 of 4)
|
|
|
|
Spec: `features/seo_data_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. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header.
|
|
|
|
## Divergences from docs/design
|
|
|
|
`docs/design` is read-only; the SPA slices (3 and 4) align to the code, not to these design words.
|
|
|
|
- **Site image and robots names:** the API uses `default_social_image_id` and `default_robots` (existing site contract). The design writes `social_image_id` for the site and, in places, `robots`. The SPA uses the code names.
|
|
- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.OwnerRequiredErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist.
|
|
- **Empty-slug fallback:** one rule everywhere. A title with no letter or digit becomes `article`, then `article-2`, `article-3` on conflict, in the API and in the migration. `docs/design/seo-slices.md` (decision 5) says `article-<id>` for the migration; that wording is not followed.
|
|
- **Slug conflict transport:** the conflict follows the authors slug conflict (plain text `slug_taken`, 409); the suggestion travels in the `X-Slug-Suggestion` response header, not in a JSON body. The `slug.suggestion` of the slice 2 check endpoint is separate.
|
|
- **Empty slug:** `slug: ""` regenerates the slug from the title (the SPA "Reset to match the title" sends `""`); 409 only if the regenerated slug belongs to another article on the site.
|
|
- **Status codes:** `invalid_url` is 422 (authors); `invalid_slug`, `invalid_title_template` and `image_not_found` are 400.
|
|
- **Usage response:** `GET /media/{id}/usage` stays an array; items gain `type` and `fields`; authors and sites are listed; non-article items have no `published`.
|
|
|
|
## Setup
|
|
|
|
- New files `test/seo_data_*_test.go`. Fakes have unique names: `mockSeoArticleRepo` (articles with per-site unique slug, unique-violation injection on (site_id, slug), `FindByBodyContaining`, cover and social references, repoint helpers), `mockSeoSiteRepo`, `mockSeoAuthorRepo`, `mockSeoImageRepo` (images, delete, merge and purge repointing, failure injection), and a fake audit repository with fail and panic switches (reuse).
|
|
- Routers mirror `api/router.go`: article, site, media (including `/{id}/usage`) and activity routers. 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 whose role is `"editor"`, an unverified account. An API-key request uses the key authentication path.
|
|
- Helpers create articles, sites, images, authors and references directly in the fakes. Assertions read the fakes, never only the JSON.
|
|
|
|
## A. Article PATCH, SEO fields
|
|
|
|
1. Set all twelve fields: 200, body and stored values equal, unrelated fields unchanged.
|
|
2. Absent fields untouched; clear table (`""`, `false`, `[]`, `null` on the two image ids).
|
|
3. Boundary table: 300, 120, 320, 200, 120, 320, slug 100, canonical 2048; ASCII and multi-byte counted in runes; a canonical URL with query and fragment.
|
|
4. Validation table with exact status and token: over-limit fields give 400 validator text; slug cases (`Spring`, `spring_2026`, `-spring`, `spring--2026`, 101 chars) give 400 `invalid_slug`; canonical non-https, relative, no host give 422 `invalid_url`; unknown dismissed code gives 400 validator text; unknown image ids give 400 `image_not_found`; image id `0` gives the validator text. Article unchanged each time.
|
|
5. Null and wrong-type table (`{}`, unknown, `null` on slug, excerpt, noindex and `seo_dismissed`, wrong types) give 400 `apperr.BodyErrorMessage`; `null` on the two image ids gives 200.
|
|
6. Duplicate dismissed codes collapse, order kept; all 11 codes accepted. A refused update changes no other field. Combined PATCH of body, site, authors and SEO.
|
|
7. Callers: owner, admin, "editor" and API key give 200; unverified gives 403; none gives 401. Missing article gives 404 "Article not found"; `abc` gives `apperr.IdUint32ErrorMessage`.
|
|
8. `noindex` leaves `is_online` unchanged. `cover_image_alt` is accepted without a cover.
|
|
|
|
## B. Article reads
|
|
|
|
9. GET one, PATCH, publish and unpublish carry the twelve fields with stored values; POST returns the generated slug and the other defaults.
|
|
10. List items carry `excerpt` and `slug` and none of the other ten new fields (key absence check); `total`, `page`, `size` and `counts` unchanged. Anonymous and unverified readers get online articles only, and an offline article is 404 "Article not found".
|
|
|
|
## C. Slug
|
|
|
|
11. Generation table (accents, punctuation runs, `!!!` gives `article`, 100-character cap with no trailing hyphen).
|
|
12. Creation de-duplication: `spring-release` and `-2` taken gives `-3`; `article` taken gives `article-2`; suffix fits 100; same title on another site keeps the plain slug; default-site uniqueness.
|
|
13. Same slug on two sites via PATCH is 200. Editable when online. Own slug re-sent is 200.
|
|
14. 409 table: `spring`, `spring-2`, `spring-3` taken give header suggestions `spring-2`, `-3`, `-4`; body is exactly `slug_taken`; 100-character suggestion fits; the suggestion ignores the article itself; repository unique-violation injection gives the same 409; the article is unchanged.
|
|
15. Empty slug: regenerates from the title (200); follows the creation rule (`!!!` gives `article`); already equal is a 200 with no event; collision gives 409 `slug_taken` with the suggestion header and leaves the slug unchanged (never silently suffixed); combined with a move uses the target site.
|
|
16. Move table: collision on the target gives 409 with suggestion and leaves site and slug unchanged; free slug moves with the slug kept; move plus new slug checks the new slug on the target; a refused move changes no other field.
|
|
17. Delete frees the slug. Publish and unpublish keep it.
|
|
|
|
## D. Site POST and PATCH
|
|
|
|
18. Owner sets the three fields on PATCH (200, others unchanged); absent fields untouched; `GET /v1/site/` and POST responses carry them; defaults `""`, `"%title% | %site%"`, `""` when omitted on POST.
|
|
19. POST with the three fields: 201 and stored; creation validation table equals the PATCH tables.
|
|
20. Template table: valid templates give 200; `%site%`, `just text`, `%TITLE%`, empty give 400 `invalid_title_template`; 201 characters gives the validator text. Description 320 and `""` give 200, 321 gives the validator text; site_name 100 gives 200, 101 gives the validator text, `""` gives 200.
|
|
21. A refused PATCH leaves the other fields unchanged. `default_social_image_id` and `default_robots` still work.
|
|
22. Refusal table: admin and "editor" get 403 "Only the owner can do this." on PATCH and POST; API key gets 403 "This action needs a real session."; none gives 401; malformed JSON from an admin still gets the 403. A no-op edit is 200 with no event.
|
|
|
|
## E. Migration
|
|
|
|
23. Repository-contract harness (temp-dir SQLite; MySQL and Postgres opt-in through the existing env switches). `database.Migrate` backfill table: distinct titles; collisions in id order (`news`, `news-2`, `news-3`); suffix skipping existing slugs; per-site scope; titles `!!!`, `???`, `...` on one site give `article`, `article-2`, `article-3`; long titles stay within 100 and are distinct.
|
|
24. Idempotence: a second run changes nothing; an edited slug survives; a partially backfilled table completes without touching existing slugs.
|
|
25. New site columns default (`""`, `"%title% | %site%"`, `""`, no backfill of `site_name`); neutral article defaults; the unique index on (site_id, slug) exists and a duplicate insert is refused on every driver. `test/sqlite_schema_unchanged_test.go` is updated for the new columns and index.
|
|
26. Contract suites for the article and site repositories cover every new column, round-trip of `seo_dismissed`, null image ids and the per-site uniqueness.
|
|
|
|
## F. Media usage
|
|
|
|
27. Usage lookup: the item table for body, cover, social, avatar and site image, each with its `type`, `id`, `title` and `fields`; one item per article with merged `fields`; `published` present for articles (true or false) and absent for authors and sites; the article item keeps `id`, `title` and `published` with their previous values and the only new keys are `type` and `fields` (additive check); empty array when unused; unknown image gives the existing 404.
|
|
28. `used_in` and the `unused` filter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.
|
|
29. Delete: never refused; references are nulled, `cover_image_alt` and article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference.
|
|
30. Merge: articles' cover and social ids repointed; `articles_updated` counts each article once even if body and cover both changed; `updated_article_ids` lists them; `authors_updated` and `sites_updated` still correct. Purge does the same, a failed group leaves every reference on an existing image, and the preview treats cover and social as usage.
|
|
|
|
## G. Activity
|
|
|
|
31. Short-value table: slug (including a regenerated one), canonical_url, noindex, cover_image and social_image give one `article.edit` with `kind: "text"` and `before`/`after`, recorded after the change.
|
|
32. Free-text table (excerpt, seo_title, seo_description, social_title, cover_image_alt, and social_description) gives `kind: "body"` and `delta` in characters; the stored event and the `/v1/activity/` response contain none of the old or new text (substring check), including the same-length edit. Body plus SEO edit records both with no body text.
|
|
33. `seo_dismissed`-only and no-op edits record nothing. A taken slug records a failed event with the plain reason. No event for 404, malformed id, validation refusals, 401. A failing and a panicking audit repository change neither response nor data.
|
|
34. Site edits record `site.edit` with before and after for `site_name`, `title_template` and `default_description`. `GET /v1/activity/?category=articles` returns the events. `activity-log-producers.feature` carries the matching rule.
|
|
|
|
## H. Quality gates
|
|
|
|
35. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the article and site domain packages import no GORM or HTTP; handlers import no persistence; `api/dependecyInjection.go` wires the widened `GetMediaUsageUseCase`; validation goes through validate tags and `validator.Validate.Struct`; no comments added.
|
|
36. Scoped mutation run through `task mutate` only, on the new and changed use cases (article create and update, site create and update, media usage, delete, merge, purge and list, and the slug helper). Example: `MUTATE_FILES=<those file names> MUTATE_RUN='TestSeoData' MUTATE_WORKERS=2 task mutate -- <use case package path>`. Report LIVED and NOT COVERED mutants and re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
|
37. Web: `web/src/data/__tests__/apiCatalogue.spec.ts` is updated for the new article and site request fields and runs under `task test:web`. Report NOT RUN if the runner is not installed.
|
|
38. Docs, checked by reading: `docs/api.md`, `docs/api/articles.md`, `docs/architecture.md`, `docs/release-notes.md` as listed in the feature.
|
|
|
|
## Manual
|
|
|
|
- MySQL and Postgres: run the contract suites with the opt-in env vars against real servers.
|
|
- Real boot on a copy of a pre-slice database: all articles get distinct slugs per site, nothing else changes, the second boot changes nothing.
|
|
- Docker with the `/data` volume: same boot check.
|
|
- Read the activity log screen and the CSV export: SEO events show field names and no free text.
|
|
- Not covered here: the SPA (slices 3 and 4) and the resolver, check endpoint and sitemap (slice 2).
|