feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

+2 -1
View File
@@ -84,7 +84,8 @@ Feature: Every real action of the back-office is recorded in the activity log fr
Scenario: A title change is recorded as a text diff when a title edit exists
Given a use case that changes an article's title from "Spring release" to "Spring release 2"
Then its event has action "article.edit" and changes [{"field": "title", "kind": "text", "before": "Spring release", "after": "Spring release 2"}]
And only the fields title, online and body can ever appear in an article event's changes
And only the fields title, online, body and, since features/seo_data_backend.feature, site, authors and the SEO fields can ever appear in an article event's changes
And SEO free-text fields (excerpt, seo_title, seo_description, social_title, social_description, cover_image_alt) are recorded as a size delta in characters with kind "body" and never as text, while short values (slug, canonical_url, noindex, cover_image, social_image) are recorded as text before and after
Scenario: Publishing is recorded with the publish state
Given the article 8 "Spring release" is offline
+3 -2
View File
@@ -757,9 +757,10 @@ Feature: Authors backend, public bylines that articles credit in order
Given the purge of a group fails midway
Then every author avatar_id refers to an image that still exists
Scenario: The usage lookup array shape is unchanged
Scenario: The usage lookup stays an array and later gains additive keys
When a verified account looks up the usage of an image
Then the response keeps the same array shape as before this slice
Then the response is a JSON array whose article items keep id, title and published
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
Rule: Every author write is recorded in the activity log
+1 -1
View File
@@ -79,7 +79,7 @@ Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `te
## I. Media
46. Usage lookup counts an avatar, shape unchanged, image not "Unused". Media list `used_in` sums article, site and author usage.
46. Usage lookup counts an avatar, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused". Media list `used_in` sums article, site and author usage.
47. Deleting the image (204) nulls `avatar_id`.
48. Merge repoints authors and reports `authors_updated`; purge does the same; a failed purge group leaves every `avatar_id` pointing at an existing image.
+784
View File
@@ -0,0 +1,784 @@
Feature: SEO data backend, article and site search fields, unique slugs and media usage
As an editor or the owner of a RenewCMS installation
I want to store search and sharing data on articles and sites, with a slug that is unique per site
So that a frontend can later resolve titles, descriptions, images and addresses from RenewCMS
Background:
Given the spec of record is docs/design/seo-slices.md (slice 1), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/sites.md and docs/design/states.md
And this slice is backend only: no SPA, no change to docs/design, and no resolver, check endpoint, sitemap or list seo filter
And an article gains these snake_case fields: slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed
And in article JSON the text fields are "" when empty, cover_image_id and social_image_id are null when unset, noindex is false by default and seo_dismissed is [] by default
And every article read (GET /v1/article/{id}, POST /v1/article, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish) carries all twelve fields
And each item of GET /v1/article/ carries excerpt and slug, and none of the other ten new fields
And the SEO fields join body, site_id and author_ids as the known fields of PATCH /v1/article/{id}
And a PATCH with no known field, an unknown field only, or a null value on any field other than cover_image_id and social_image_id answers 400 with apperr.BodyErrorMessage, as before
And limits are counted in runes: excerpt 300, seo_title 120, seo_description 320, cover_image_alt 200, social_title 120, social_description 320, canonical_url 2048
And the slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters, the rule of authors
And canonical_url is empty or an absolute https URL with a host
And seo_dismissed is a list of the 11 suggestion codes title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short; an unknown code answers 400; duplicates are collapsed with the order kept
And a slug is unique per site (site_id, slug), never globally
And one slug rule applies at creation, in PATCH regeneration and in the migration: lowercase, accents folded, every run of other characters becomes one hyphen, hyphens trimmed, cut to 100 characters with no trailing hyphen, and a result with no letter or digit becomes "article"
And when that slug is taken on the site, creation and the migration add -2, -3 and so on, truncating the base so the result fits 100 characters
And a slug conflict on PATCH follows the authors slug conflict: status 409 with the plain-text body "slug_taken" from http.Error, and additionally the response header X-Slug-Suggestion holds the first free "<slug>-N" from 2 on the article's target site, fitting 100 characters
And other error responses are plain text from http.Error whose body is the existing token or the validator error text: invalid_slug (400, as for authors), invalid_url (422, as for authors), image_not_found (400, as for authors and sites), site_not_found (404), and the new invalid_title_template (400)
And a missing article answers 404 "Article not found" and an offline article still reads as not found publicly
And a site gains site_name, title_template and default_description; site JSON keeps default_social_image_id and default_robots
And POST /v1/site/ and PATCH /v1/site/{id} stay owner only through RequireOwner: a non-owner session gets 403 "Only the owner can do this." and an API key gets 403 "This action needs a real session."
And request validation goes through validate tags and validator.Validate.Struct
And handlers reach articles, sites and media only through use cases and domain interfaces, and no comment is added anywhere
Rule: Article SEO fields are edited through PATCH /v1/article/{id}
Scenario: All SEO fields are set at once
Given the article 8 "Spring release" is on the site 1 and the images 5 and 6 exist
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}
Then the status is 200
And the body carries those values and the unchanged title, body, site_id and authors
And the article is stored with those values
Scenario: Absent fields are untouched
Given the article 8 has an excerpt, a cover image and a canonical_url
When the user sends PATCH /v1/article/8 with {"seo_title": "New title"}
Then only seo_title changed
Scenario Outline: Text fields and the flag are cleared with empty values
Given the article 8 has every SEO field set
When the user sends PATCH /v1/article/8 with <body>
Then the status is 200
And <result>
Examples:
| body | result |
| {"excerpt": ""} | excerpt is "" |
| {"seo_title": ""} | seo_title is "" |
| {"seo_description": ""} | seo_description is "" |
| {"cover_image_alt": ""} | cover_image_alt is "" |
| {"social_title": ""} | social_title is "" |
| {"social_description": ""} | social_description is "" |
| {"canonical_url": ""} | canonical_url is "" |
| {"noindex": false} | noindex is false |
| {"seo_dismissed": []} | seo_dismissed is [] |
| {"cover_image_id": null} | cover_image_id is null |
| {"social_image_id": null} | social_image_id is null |
Scenario Outline: Boundaries are accepted
When the user sends PATCH /v1/article/8 with <field> <value>
Then the status is 200
Examples:
| field | value |
| excerpt | 300 characters |
| excerpt | 300 multi-byte characters |
| seo_title | 120 characters |
| seo_description | 320 characters |
| cover_image_alt | 200 characters |
| social_title | 120 characters |
| social_description | 320 characters |
| slug | 100 characters |
| canonical_url | "https://example.com/a?b=1#c" |
| canonical_url | 2048 characters, https |
Scenario Outline: Fields are validated
When the user sends PATCH /v1/article/8 with <field> <value>
Then the status is <status>
And the body is "<body>"
And the article is unchanged
Examples:
| field | value | status | body |
| excerpt | 301 characters | 400 | the validator error text |
| seo_title | 121 characters | 400 | the validator error text |
| seo_description | 321 characters | 400 | the validator error text |
| cover_image_alt | 201 characters | 400 | the validator error text |
| social_title | 121 characters | 400 | the validator error text |
| social_description | 321 characters | 400 | the validator error text |
| canonical_url | 2049 characters, https | 400 | the validator error text |
| slug | "Spring" | 400 | invalid_slug |
| slug | "spring_2026" | 400 | invalid_slug |
| slug | "-spring" | 400 | invalid_slug |
| slug | "spring--2026" | 400 | invalid_slug |
| slug | 101 characters | 400 | invalid_slug |
| canonical_url | "http://example.com/a" | 422 | invalid_url |
| canonical_url | "/relative" | 422 | invalid_url |
| canonical_url | "example.com" | 422 | invalid_url |
| canonical_url | "https://" | 422 | invalid_url |
| seo_dismissed | ["nope"] | 400 | the validator error text |
| cover_image_id | 999 (no such image) | 400 | image_not_found |
| social_image_id | 999 (no such image) | 400 | image_not_found |
| cover_image_id | 0 | 400 | the validator error text |
Scenario Outline: A wrong type or null on other fields is refused
When the user sends PATCH /v1/article/8 with <body>
Then the status is 400
And the body is apperr.BodyErrorMessage
And the article is unchanged
Examples:
| body |
| {} |
| {"colour": "x"} |
| {"slug": null} |
| {"excerpt": null} |
| {"noindex": null} |
| {"seo_dismissed": null} |
| {"noindex": "yes"} |
| {"excerpt": 5} |
| {"cover_image_id": "a"} |
| {"seo_dismissed": "x"} |
Scenario: Duplicate dismissed codes are collapsed
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short", "cover_alt", "short"]}
Then seo_dismissed is ["short", "cover_alt"]
Scenario: Every known dismissed code is accepted
When the user sends PATCH /v1/article/8 with all 11 codes in seo_dismissed
Then the status is 200
Scenario: A refused update changes nothing else
When the user sends PATCH /v1/article/8 with {"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}
Then the status is 422 with body "invalid_url"
And the body and the excerpt of the article 8 are unchanged
Scenario: SEO fields and the other fields change together
When the user sends PATCH /v1/article/8 with {"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}
Then the body, the site_id, the authors and the excerpt are all updated
Scenario: An update without SEO fields keeps them
Given the article 8 has every SEO field set
When the user sends PATCH /v1/article/8 with {"body": "New"}
Then every SEO field is unchanged
Scenario Outline: Any account that can edit the article edits its SEO fields
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
Then the status is 200
Examples:
| credentials |
| the owner's jwt cookie |
| a verified admin's jwt cookie |
| a verified account with another role |
| a valid API key |
Scenario Outline: Without a verified account the article is not edited
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
Then the status is <status>
And the article is unchanged
Examples:
| credentials | status |
| an unverified account's jwt cookie | 403 |
| no credentials | 401 |
Scenario: A missing article is not found
When the user sends PATCH /v1/article/999 with {"excerpt": "x"}
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When the user sends PATCH /v1/article/abc with {"excerpt": "x"}
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: noindex is only a flag
Given the article 8 is online
When the user sends PATCH /v1/article/8 with {"noindex": true}
Then the article is still online
And an anonymous GET /v1/article/8 still returns it with "noindex": true
Scenario: The cover alt is independent of the cover
When the user sends PATCH /v1/article/8 with {"cover_image_alt": "A field"} on an article without cover
Then the status is 200
And cover_image_id is null and cover_image_alt is "A field"
Rule: Article reads carry the SEO fields
Scenario Outline: Single-article reads carry all twelve fields
Given the article 8 has every SEO field set
When <request> is sent
Then the article in the response has slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed with the stored values
Examples:
| request |
| GET /v1/article/8 |
| PATCH /v1/article/8 |
| PATCH /v1/article/8/publish |
| PATCH /v1/article/8/unpublish |
Scenario: A new article has the defaults
When the user sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
Then the status is 201
And the article has slug "spring-release", excerpt "", seo_title "", seo_description "", cover_image_id null, cover_image_alt "", social_title "", social_description "", social_image_id null, canonical_url "", noindex false and seo_dismissed []
Scenario: The list carries the excerpt and the slug
Given the article 8 has the excerpt "Short teaser." and the slug "spring-release"
When a verified account sends GET /v1/article/
Then each item of items has "excerpt" and "slug"
And the article 8 item has excerpt "Short teaser." and slug "spring-release"
And no item has seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex or seo_dismissed
And the status, total, page, size and counts are as before this slice
Scenario: Anonymous readers see the SEO fields of online articles
Given the article 9 is online with SEO fields set
When a caller without credentials sends GET /v1/article/9
Then the SEO fields are returned
Scenario: An offline article is still not found publicly
When a caller without a verified account sends GET /v1/article/{offline id}
Then the status is 404 with body "Article not found"
Scenario: The list for anonymous callers shows online articles only
Given one online and one offline article
When a caller without a verified account sends GET /v1/article/
Then only the online article is listed, with its excerpt and slug
Rule: The slug is unique per site and generated at creation
Scenario Outline: The slug is generated from the title at creation
When the user sends POST /v1/article with {"title": "<title>", "body": "x"}
Then the slug is "<slug>"
Examples:
| title | slug |
| Spring release | spring-release |
| Été à Paris | ete-a-paris |
| Dr. O'Neil Smith | dr-o-neil-smith |
| !!! | article |
Scenario: A long title gives a slug of at most 100 characters
When the user sends POST /v1/article with a title whose slug would exceed 100 characters
Then the slug has at most 100 characters and does not end with "-"
Scenario: A generated slug that is taken on the site is de-duplicated silently
Given the site 1 has the articles with slugs "spring-release" and "spring-release-2"
When the user sends POST /v1/article with {"title": "Spring release", "body": "x"}
Then the status is 201
And the slug is "spring-release-3"
Scenario: A title without letters or digits is de-duplicated too
Given the site 1 has an article with slug "article"
When the user sends POST /v1/article with {"title": "!!!", "body": "x"}
Then the slug is "article-2"
Scenario: The suffix still fits the slug limit at creation
Given a title whose slug is 100 characters and that slug is taken on the site
When the user creates the article
Then the slug is at most 100 characters and ends with "-2"
Scenario: The same title on another site keeps the plain slug
Given the default site 1 has an article with slug "spring-release"
And the site 2 has no such slug
When the article is created on the site 2
Then the slug is "spring-release"
Scenario: A new article is created on the default site, so uniqueness uses that site
Given the default site 2 has an article with slug "spring-release"
When the user creates "Spring release" without a site
Then the slug is "spring-release-2"
Scenario: The same slug is allowed on two sites
Given the article 8 on the site 1 has slug "spring"
And the article 9 is on the site 2
When the user sends PATCH /v1/article/9 with {"slug": "spring"}
Then the status is 200
Scenario: The slug is editable at any time
Given the article 8 is online with slug "spring-release"
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026"}
Then the status is 200
And the slug is "spring-2026" and the article is still online
Scenario: Keeping the own slug is not a collision
When the user sends PATCH /v1/article/8 with {"slug": "spring-release", "excerpt": "x"} and the slug is already that article's
Then the status is 200
Scenario Outline: A taken slug is refused with a suggestion
Given the site 1 holds the articles 8 and 9 and the slugs <taken> are used by other articles
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 409
And the body is "slug_taken"
And the header X-Slug-Suggestion is "<suggestion>"
And the article 8 is unchanged
Examples:
| taken | suggestion |
| "spring" | spring-2 |
| "spring", "spring-2" | spring-3 |
| "spring", "spring-2", "spring-3" | spring-4 |
Scenario: The suggestion fits the slug limit
Given another article has a 100-character slug and the article 8 asks for it
Then the 409 suggestion is at most 100 characters and ends with "-2"
Scenario: The suggestion ignores the article itself
Given the article 8 has slug "spring-2" and the article 9 has "spring"
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 409 with suggestion "spring-2"
Scenario: The repository can report a race
Given the repository reports a unique violation on (site_id, slug)
When the user sends PATCH /v1/article/8 with a free-looking slug
Then the status is 409 with body "slug_taken" and a suggestion header "<slug>-2"
And the article is unchanged
Scenario: An empty slug regenerates it from the title
Given the article 8 "Spring release" has slug "custom-address"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 200
And the slug is "spring-release"
Scenario: Regeneration follows the creation rule
Given the article 8 titled "!!!" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the slug is "article"
Scenario: Regenerating to the slug it already has is a no-op
Given the article 8 "Spring release" already has slug "spring-release"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 200 and no activity event is recorded
Scenario: Regeneration that collides is refused, never silently suffixed
Given the article 9 on the same site has slug "spring-release" and the article 8 "Spring release" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then the status is 409
And the body is "slug_taken"
And the header X-Slug-Suggestion is "spring-release-2"
And the slug of the article 8 is still "custom"
Scenario: Regeneration combined with a move uses the target site
Given the article 8 "Spring release" is on the site 1 and the site 2 holds another article with slug "spring-release"
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": ""}
Then the status is 409 with suggestion "spring-release-2"
Scenario: Moving to another site re-checks the slug on the target
Given the article 8 on the site 1 has slug "spring" and the site 2 has an article with slug "spring"
When the user sends PATCH /v1/article/8 with {"site_id": 2}
Then the status is 409
And the body is "slug_taken" with the header X-Slug-Suggestion "spring-2"
And the article 8 is still on the site 1 with slug "spring"
Scenario: Moving to a site where the slug is free keeps the slug
When the user sends PATCH /v1/article/8 with {"site_id": 2} and the slug is free on the site 2
Then the status is 200
And the slug is unchanged
Scenario: Moving and changing the slug together checks the new slug on the target
Given the site 2 has an article with slug "spring" and none with "autumn"
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": "autumn"}
Then the status is 200
When the user sends PATCH /v1/article/9 with {"site_id": 2, "slug": "spring"} on another article
Then the status is 409 with suggestion "spring-2"
Scenario: A refused move changes no SEO field
When the user sends PATCH /v1/article/8 with {"site_id": 2, "excerpt": "new"} and the slug collides on the site 2
Then the status is 409
And the excerpt, the site_id and every other field of the article 8 are unchanged
Scenario: A freed slug can be reused
Given the article 9 has slug "spring" and is deleted
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
Then the status is 200
Scenario: Publishing and unpublishing never change the slug
When the article 8 is published then unpublished
Then its slug is unchanged
Scenario: Slug uniqueness is enforced by the database
When the repository contract suite runs
Then a second article with the same (site_id, slug) is refused by every driver
Rule: Site SEO fields are edited by the owner only
Scenario: The owner sets the site SEO fields
Given the site 2 "Shop"
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
Then the status is 200
And the body is the site with those three values and its other fields unchanged
Scenario: A site created without the new fields has the defaults
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
Then the site has site_name "", title_template "%title% | %site%" and default_description ""
Scenario: A site is created with the new fields
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
Then the status is 201
And the body carries those three values
And the site is stored with them
Scenario Outline: Creation validates the new fields like editing
When the owner sends POST /v1/site/ with a valid site and <field> <value>
Then the status is <status>
And the body is "<body>"
And no site is added when the status is not 201
Examples:
| field | value | status | body |
| title_template | "%title%" | 201 | |
| title_template | "just text" | 400 | invalid_title_template |
| title_template | "%site%" | 400 | invalid_title_template |
| title_template | "" | 400 | invalid_title_template |
| title_template | 201 characters | 400 | the validator error text |
| default_description | 320 characters | 201 | |
| default_description | 321 characters | 400 | the validator error text |
| site_name | 100 characters | 201 | |
| site_name | 101 characters | 400 | the validator error text |
Scenario: The list carries the new fields
When a verified account sends GET /v1/site/
Then every site has site_name, title_template and default_description
Scenario: Absent fields are untouched
Given the site 2 has a custom title_template and default_description
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew"}
Then only site_name changed
Scenario Outline: The template must contain %title%
When the owner sends PATCH /v1/site/2 with {"title_template": <template>}
Then the status is <status>
And the body is "<body>"
Examples:
| template | status | body |
| "%title%" | 200 | |
| "%title% \| %site%" | 200 | |
| "%site% - %title%" | 200 | |
| "News: %title%" | 200 | |
| "%site%" | 400 | invalid_title_template |
| "just text" | 400 | invalid_title_template |
| "%TITLE%" | 400 | invalid_title_template |
| "" | 400 | invalid_title_template |
| 201 characters | 400 | the validator error text |
Scenario Outline: Other site SEO fields are validated
When the owner sends PATCH /v1/site/2 with <field> <value>
Then the status is <status>
And the body is "<body>"
Examples:
| field | value | status | body |
| default_description | 321 characters | 400 | the validator error text |
| default_description | 320 characters | 200 | |
| default_description | "" | 200 | |
| site_name | 101 characters | 400 | the validator error text |
| site_name | "" | 200 | |
Scenario: A refused site update changes no other field
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "title_template": "nope"}
Then the status is 400 with body "invalid_title_template"
And the name of the site 2 is unchanged
Scenario: The existing site fields keep their names
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5, "default_robots": "noindex"}
Then the status is 200 and the site carries default_social_image_id 5 and default_robots "noindex"
Scenario Outline: Other roles and keys are refused
When <request> is sent with <credentials>
Then the status is 403
And the body is "<body>"
And no site changed
Examples:
| request | credentials | body |
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
| PATCH /v1/site/2 | a verified account with another role | Only the owner can do this. |
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
| POST /v1/site/ | a valid API key | This action needs a real session. |
Scenario: No credentials is unauthorised
When PATCH /v1/site/2 is sent with no credentials
Then the status is 401
Scenario: The role is checked before the body
When an admin sends PATCH /v1/site/2 with the body "{not json"
Then the status is 403 with body "Only the owner can do this."
Scenario: A no-op site edit records nothing
When the owner sends PATCH /v1/site/2 with the SEO values it already has
Then the status is 200
And no activity event is recorded
Rule: Existing articles are backfilled with a unique slug
Scenario: Every existing article gets a slug from its title
Given a database from before this slice holding the articles "Spring release" (id 1) and "Autumn notes" (id 2) on the site 1
When database.Migrate runs
Then the articles have slugs "spring-release" and "autumn-notes"
Scenario: Conflicts get suffixes in id order within a site
Given the site 1 holds the articles 1, 2 and 3 all titled "News"
When database.Migrate runs
Then their slugs are "news", "news-2" and "news-3" in id order
Scenario: A suffix skips slugs that exist
Given the site 1 holds the articles 1 "News", 2 "News 2" and 3 "News"
When database.Migrate runs
Then every slug of the site 1 is distinct and the article 1 has "news"
Scenario: Slugs are unique per site, not globally
Given the article 1 on the site 1 and the article 2 on the site 2 are both titled "News"
When database.Migrate runs
Then both slugs are "news"
Scenario Outline: A title with no letter or digit becomes article, suffixed on conflict
Given the site 1 holds the articles 5 titled "<first>", 6 titled "<second>" and 7 titled "<third>"
When database.Migrate runs
Then their slugs are "article", "article-2" and "article-3" in id order
And no slug is of the form "article-<id>" unless it comes from the suffix rule
Examples:
| first | second | third |
| !!! | ??? | ... |
Scenario: Long titles stay within 100 characters
Given two articles on the same site whose titles give a slug longer than 100 characters
When database.Migrate runs
Then both slugs have at most 100 characters, are distinct and match the slug rule
Scenario: The migration is idempotent
Given the migration already ran
When database.Migrate runs again
Then no slug changes
Scenario: An edited slug survives a restart
Given the owner changed the slug of the article 1 to "custom"
When database.Migrate runs again
Then the article 1 keeps "custom"
Scenario: New site columns get defaults
Given a database holding the sites from before this slice
When database.Migrate runs
Then every site has site_name "", title_template "%title% | %site%" and default_description ""
Scenario: Existing articles get neutral SEO defaults
When database.Migrate runs on articles from before this slice
Then excerpt, seo_title, seo_description, cover_image_alt, social_title, social_description and canonical_url are "", the image ids are null, noindex is false and seo_dismissed is []
Scenario: A database with a partially backfilled table completes
Given some articles have a slug and some are empty
When database.Migrate runs
Then the empty ones get unique slugs and the existing ones are unchanged
Scenario: The migration creates the per-site unique index
When database.Migrate runs
Then a unique index on (site_id, slug) exists on articles
Scenario: It works on every database driver
When the repository contract suite runs
Then the article and site repositories behave the same on SQLite, MySQL and PostgreSQL
And the SQLite schema test accounts for the new columns and the index
Rule: Cover, social and site images count as media usages
Scenario: A cover or social image counts as used
Given the image 5 is the cover of the article 8 and appears in no article body, author or site
When a verified account looks up the usage of the image 5
Then the usage lists the article 8 and the image is not "Unused"
Scenario Outline: Each reference kind counts
Given the image 5 is referenced only as <reference>
When a verified account sends GET /v1/media/?filter=unused
Then the image 5 is <listed>
Examples:
| reference | listed |
| the cover of an article | not in the list |
| the social image of an article | not in the list |
| the default social image of a site | not in the list |
| the avatar of an author | not in the list |
| a file name in an article body | not in the list |
| nothing | in the list |
Scenario: An article that uses the image twice counts once
Given the image 5 is both in the body and the cover of the article 8
Then used_in of the image 5 is 1 for that article
Scenario: The media list sums every kind
Given the image 5 is the cover of an article, the social image of another, in a third article body, the default social image of a site and the avatar of an author
When a verified account sends GET /v1/media/
Then the used_in of the image 5 is 5
Scenario: Usage lists articles, authors and sites
Given the image 5 is in the body of the article 3, the cover of the article 8, the avatar of the author 2 and the default social image of the site 1
When a verified account sends GET /v1/media/5/usage
Then the status is 200
And the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"]}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"]}, {"type": "author", "id": 2, "title": "<author name>", "fields": ["avatar"]} and {"type": "site", "id": 1, "title": "<site name>", "fields": ["default_social_image"]}
And author and site items carry no published key
Scenario: The change is additive for existing consumers
Given the image 5 is in the body of the article 3
When a verified account sends GET /v1/media/5/usage
Then the item still has id, title and published with the same values as before this slice
And the only new keys are type and fields
Scenario: An article using an image in several places is one item with all fields
Given the image 5 is the cover, the social image and in the body of the article 8
When a verified account sends GET /v1/media/5/usage
Then there is one item for the article 8 with fields ["body", "cover", "social"]
Scenario: An unused image has an empty usage array
When a verified account sends GET /v1/media/9/usage on an unused image
Then the status is 200 and the body is []
Scenario: An unknown image is not found
When a verified account sends GET /v1/media/999/usage
Then the status is 404 with the existing image-not-found body
Scenario: Offline articles are listed with published false
Given the image 5 is the cover of an offline article
When a verified account looks up the usage of the image 5
Then the item has "published": false
Scenario: Deleting an image in use clears the references and is never refused
Given the image 5 is the cover of the article 8 and the social image of the article 9, with cover_image_alt "A field"
When the owner deletes the image 5
Then the status is 204
And the article 8 has cover_image_id null and the article 9 has social_image_id null
And the cover_image_alt of the article 8 is unchanged
And the article bodies are untouched
And the avatar and site references to the image 5 are cleared as before
Scenario: A failed delete leaves every reference in place
Given the repository fails while removing the image 5
When the owner deletes the image 5
Then the cover, social, avatar and site references to the image 5 are all unchanged
Scenario: Merging copies repoints article cover and social images
Given the image 6 is a copy of the image 5 and the article 8 has the cover 6 and the article 9 the social image 6
When the copies are merged into the image 5
Then the article 8 has cover_image_id 5 and the article 9 has social_image_id 5
And the merge response counts both articles in articles_updated
And updated_article_ids lists both
Scenario: An article rewritten in body and cover counts once
Given the article 8 has the copy 6 in its body and as its cover
When the copies are merged into the image 5
Then articles_updated is 1 and the article 8 has the image 5 in both places
Scenario: Purge repoints articles the same way
Given a purge group whose kept image is 5 and whose duplicate 6 is the cover of the article 8
When the purge runs
Then the article 8 has cover_image_id 5
And the purge result counts it in articles_updated
Scenario: A failed purge group leaves covers pointing at existing images
Given the purge of a group fails midway
Then every article cover_image_id and social_image_id refers to an image that still exists
Scenario: Authors and sites keep being repointed
Given the duplicate 6 is used by the author 2 and the site 1
When the copies are merged into the image 5
Then authors_updated is 1 and sites_updated is 1
Scenario: Cover and social usage counts in the purge preview
Given a duplicate group whose only usage is as article covers
When the purge preview runs
Then the kept image is the one in use, and used_in includes cover and social usages
Rule: SEO edits are recorded in the activity log without leaking the body
Scenario Outline: A short-value SEO edit is recorded once after the change
Given the article 8 "Spring release" has <before>
When the user sends PATCH /v1/article/8 with <body>
Then one event exists with action "article.edit", category "articles", target {"type": "article", "id": "8", "name": "Spring release"}, outcome "success" and source "web"
And its changes are [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
And the event was recorded after the change was applied
Examples:
| before | body | field | from | to |
| slug "spring" | {"slug": "spring-2026"} | slug | spring | spring-2026 |
| canonical_url "" | {"canonical_url": "https://www.example.com/a"} | canonical_url | (empty) | https://www.example.com/a |
| noindex false | {"noindex": true} | noindex | false | true |
| cover_image_id null | {"cover_image_id": 5} | cover_image | (empty) | the file name of the image 5 |
| social_image_id 5 | {"social_image_id": null} | social_image | the file name of the image 5 | (empty) |
Scenario: A regenerated slug is recorded like an edited one
Given the article 8 "Spring release" has slug "custom"
When the user sends PATCH /v1/article/8 with {"slug": ""}
Then one event "article.edit" exists with changes [{"field": "slug", "kind": "text", "before": "custom", "after": "spring-release"}]
Scenario Outline: Free-text SEO fields are recorded as a size change, never as text
Given the article 8 has <field> of <before> characters
When the user sends PATCH /v1/article/8 with <field> of <after> characters
Then one event "article.edit" exists with changes [{"field": "<field>", "kind": "body", "delta": <delta>}]
And no stored field and no /v1/activity response contains any text of either value
Examples:
| field | before | after | delta |
| excerpt | 0 | 120 | 120 |
| seo_title | 40 | 55 | 15 |
| seo_description | 160 | 100 | -60 |
| social_title | 0 | 30 | 30 |
| cover_image_alt | 0 | 16 | 16 |
Scenario: Same-length free text with different content is still a change
Given the article 8 has seo_title "AAAAA"
When the user sends PATCH /v1/article/8 with {"seo_title": "BBBBB"}
Then one event "article.edit" exists with the change on seo_title and delta 0
And no event or response contains "AAAAA" or "BBBBB"
Scenario: A body and SEO edit together record both
When the user sends PATCH /v1/article/8 with {"body": "New body text", "slug": "new"}
Then one event "article.edit" exists with changes for body (kind "body", delta) and slug (kind "text")
And the event holds no text of the body
Scenario: Dismissing suggestions is not an edit
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short"]}
Then the status is 200
And no activity event is recorded
Scenario: A no-op SEO edit records nothing
When the user sends PATCH /v1/article/8 with the SEO values it already has
Then the status is 200
And no activity event is recorded
Scenario: A refused slug is recorded as failed with a plain reason
When the user sends PATCH /v1/article/8 with a taken slug
Then one event exists with action "article.edit", outcome "failed" and failure_reason "That slug is already used by another article on this site."
Scenario Outline: Nothing is recorded when there is nothing to attribute
When <request> is sent
Then no activity event is recorded
Examples:
| request |
| PATCH /v1/article/999 with an SEO field |
| PATCH /v1/article/abc with an SEO field |
| PATCH /v1/article/8 with {"slug": "Bad Slug"} |
| PATCH /v1/article/8 with a 301-character excerpt |
| PATCH /v1/article/8 with an unknown cover image |
| PATCH /v1/article/8 with no credentials (401) |
Scenario: A failing audit repository never changes the response
Given the audit repository fails on Append
When the user sends PATCH /v1/article/8 with {"slug": "new"}
Then the response equals that of a healthy repository
And the slug was changed
Scenario Outline: A site SEO edit is recorded
When the owner sends PATCH /v1/site/2 with <body>
Then one event "site.edit" exists with category "sites" and changes [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
Examples:
| body | field | from | to |
| {"site_name": "Renew"} | site_name | (empty) | Renew |
| {"title_template": "%title% - %site%"} | title_template | %title% \| %site% | %title% - %site% |
| {"default_description": "A shop."} | default_description | (empty) | A shop. |
Scenario: The activity API lists the SEO events
Given article and site SEO events were recorded
When a verified account sends GET /v1/activity/?category=articles
Then the article.edit events are returned
Rule: Layering and documentation
Scenario: Layering is respected
Then use cases import no persistence, mailer, models, gorm, net/http or chi
And the article and site domain packages import no GORM or HTTP
And handlers import no persistence
And only api/dependecyInjection.go builds the repositories and use cases, including the widened GetMediaUsageUseCase
Scenario: The docs describe the new surface
Then docs/api.md documents the new article and site fields, the 409 slug_taken response with its X-Slug-Suggestion header, the empty-slug regeneration, the new error tokens, the widened GET /v1/media/{id}/usage items and the new used_in sources
And docs/api/articles.md documents the twelve article fields, their limits and the per-site slug rule
And docs/architecture.md mentions the per-site slug uniqueness, the backfill and the media usage sources
And docs/release-notes.md announces the change, including the additive usage response keys
And web/src/data/apiCatalogue.ts lists the new PATCH request fields and its spec checks them
+91
View File
@@ -0,0 +1,91 @@
# 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).
+3 -2
View File
@@ -562,9 +562,10 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa
Given the purge of a group fails midway
Then every site default_social_image_id refers to an image that still exists
Scenario: The usage lookup array shape is unchanged
Scenario: The usage lookup stays an array and later gains additive keys
When a verified account looks up the usage of an image
Then the response keeps the same array shape as before this slice
Then the response is a JSON array whose article items keep id, title and published
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
Rule: Every site write is recorded in the activity log
+1 -1
View File
@@ -67,7 +67,7 @@ Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test
## H. Media
38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused".
38. Usage lookup counts a site's default social image, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), 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.