12 KiB
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_idanddefault_robots(existing site contract). The design writessocial_image_idfor 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 403owner_onlytoken, which does not exist. - Empty-slug fallback: one rule everywhere. A title with no letter or digit becomes
article, thenarticle-2,article-3on conflict, in the API and in the migration.docs/design/seo-slices.md(decision 5) saysarticle-<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 theX-Slug-Suggestionresponse header, not in a JSON body. Theslug.suggestionof 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_urlis 422 (authors);invalid_slug,invalid_title_templateandimage_not_foundare 400. - Usage response:
GET /media/{id}/usagestays an array; items gaintypeandfields; authors and sites are listed; non-article items have nopublished.
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 behindjwtauth.Verifier+jwtauth.Authenticatorwith a testjwtauth.New("HS256", ...). Signedjwtcookies with theuser_idclaim 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
- Set all twelve fields: 200, body and stored values equal, unrelated fields unchanged.
- Absent fields untouched; clear table (
"",false,[],nullon the two image ids). - 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.
- 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 400invalid_slug; canonical non-https, relative, no host give 422invalid_url; unknown dismissed code gives 400 validator text; unknown image ids give 400image_not_found; image id0gives the validator text. Article unchanged each time. - Null and wrong-type table (
{}, unknown,nullon slug, excerpt, noindex andseo_dismissed, wrong types) give 400apperr.BodyErrorMessage;nullon the two image ids gives 200. - 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.
- Callers: owner, admin, "editor" and API key give 200; unverified gives 403; none gives 401. Missing article gives 404 "Article not found";
abcgivesapperr.IdUint32ErrorMessage. noindexleavesis_onlineunchanged.cover_image_altis accepted without a cover.
B. Article reads
- GET one, PATCH, publish and unpublish carry the twelve fields with stored values; POST returns the generated slug and the other defaults.
- List items carry
excerptandslugand none of the other ten new fields (key absence check);total,page,sizeandcountsunchanged. Anonymous and unverified readers get online articles only, and an offline article is 404 "Article not found".
C. Slug
- Generation table (accents, punctuation runs,
!!!givesarticle, 100-character cap with no trailing hyphen). - Creation de-duplication:
spring-releaseand-2taken gives-3;articletaken givesarticle-2; suffix fits 100; same title on another site keeps the plain slug; default-site uniqueness. - Same slug on two sites via PATCH is 200. Editable when online. Own slug re-sent is 200.
- 409 table:
spring,spring-2,spring-3taken give header suggestionsspring-2,-3,-4; body is exactlyslug_taken; 100-character suggestion fits; the suggestion ignores the article itself; repository unique-violation injection gives the same 409; the article is unchanged. - Empty slug: regenerates from the title (200); follows the creation rule (
!!!givesarticle); already equal is a 200 with no event; collision gives 409slug_takenwith the suggestion header and leaves the slug unchanged (never silently suffixed); combined with a move uses the target site. - 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.
- Delete frees the slug. Publish and unpublish keep it.
D. Site POST and PATCH
- 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. - POST with the three fields: 201 and stored; creation validation table equals the PATCH tables.
- Template table: valid templates give 200;
%site%,just text,%TITLE%, empty give 400invalid_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. - A refused PATCH leaves the other fields unchanged.
default_social_image_idanddefault_robotsstill work. - 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
- Repository-contract harness (temp-dir SQLite; MySQL and Postgres opt-in through the existing env switches).
database.Migratebackfill table: distinct titles; collisions in id order (news,news-2,news-3); suffix skipping existing slugs; per-site scope; titles!!!,???,...on one site givearticle,article-2,article-3; long titles stay within 100 and are distinct. - Idempotence: a second run changes nothing; an edited slug survives; a partially backfilled table completes without touching existing slugs.
- New site columns default (
"","%title% | %site%","", no backfill ofsite_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.gois updated for the new columns and index. - 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
- Usage lookup: the item table for body, cover, social, avatar and site image, each with its
type,id,titleandfields; one item per article with mergedfields;publishedpresent for articles (true or false) and absent for authors and sites; the article item keepsid,titleandpublishedwith their previous values and the only new keys aretypeandfields(additive check); empty array when unused; unknown image gives the existing 404. used_inand theunusedfilter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.- Delete: never refused; references are nulled,
cover_image_altand article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference. - Merge: articles' cover and social ids repointed;
articles_updatedcounts each article once even if body and cover both changed;updated_article_idslists them;authors_updatedandsites_updatedstill 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
- Short-value table: slug (including a regenerated one), canonical_url, noindex, cover_image and social_image give one
article.editwithkind: "text"andbefore/after, recorded after the change. - Free-text table (excerpt, seo_title, seo_description, social_title, cover_image_alt, and social_description) gives
kind: "body"anddeltain 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. 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.- Site edits record
site.editwith before and after forsite_name,title_templateanddefault_description.GET /v1/activity/?category=articlesreturns the events.activity-log-producers.featurecarries the matching rule.
H. Quality gates
- 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.gowires the widenedGetMediaUsageUseCase; validation goes through validate tags andvalidator.Validate.Struct; no comments added. - Scoped mutation run through
task mutateonly, 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 withoutMUTATE_RUN. Also runtask lint:goandtask fmt. - Web:
web/src/data/__tests__/apiCatalogue.spec.tsis updated for the new article and site request fields and runs undertask test:web. Report NOT RUN if the runner is not installed. - Docs, checked by reading:
docs/api.md,docs/api/articles.md,docs/architecture.md,docs/release-notes.mdas 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
/datavolume: 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).