Files
RenewCMS/features/seo_data_backend.qa.md

12 KiB

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

  1. GET one, PATCH, publish and unpublish carry the twelve fields with stored values; POST returns the generated slug and the other defaults.
  2. 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

  1. Generation table (accents, punctuation runs, !!! gives article, 100-character cap with no trailing hyphen).
  2. 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.
  3. Same slug on two sites via PATCH is 200. Editable when online. Own slug re-sent is 200.
  4. 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.
  5. 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.
  6. 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.
  7. Delete frees the slug. Publish and unpublish keep it.

D. Site POST and PATCH

  1. 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.
  2. POST with the three fields: 201 and stored; creation validation table equals the PATCH tables.
  3. 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.
  4. A refused PATCH leaves the other fields unchanged. default_social_image_id and default_robots still work.
  5. 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

  1. 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.
  2. Idempotence: a second run changes nothing; an edited slug survives; a partially backfilled table completes without touching existing slugs.
  3. 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.
  4. 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

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

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

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