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 "-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 Then the status is 200 And 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 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 Then the status is And the body is "" 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 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 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 Then the status is 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 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": "", "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