"
Examples:
| canonical | noindex | robots | resolved | source | result |
| | false | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | index, follow |
| | false | noindex | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
| https://other.example/a | false | index | https://other.example/a | canonical_url | index, follow |
| | true | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
Scenario: Social overrides apply to og and twitter only
Given the article 12 has social_title "Shared title", social_description "Shared text", seo_title "Meta title" and seo_description "Meta text"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "Meta title | Renew" and description is "Meta text"
And og.title and twitter.title are "Shared title" and og.description and twitter.description are "Shared text"
Scenario: Social fields fall back to the resolved values independently
Given the article 12 has social_title "Shared title" and no social_description
When an anonymous caller sends GET /v1/article/12/seo
Then og.title is "Shared title" and og.description is the resolved description
Scenario: Authors carry the profile URL and only the social links in same_as
Given the article 12 credits the author 2 "Marta Alves" with website "https://marta.example" and links [{"type": "github", "url": "https://github.com/marta"}, {"type": "x", "url": "https://x.com/marta"}]
And the author 3 "Zoe Lambert" with no website and no links, credited after her
When an anonymous caller sends GET /v1/article/12/seo
Then authors is [{"name": "Marta Alves", "url": "https://marta.example", "same_as": ["https://github.com/marta", "https://x.com/marta"]}, {"name": "Zoe Lambert", "url": "", "same_as": []}]
And the website "https://marta.example" is not in any same_as
Scenario: An article without authors has an empty authors array
When an anonymous caller sends GET /v1/article/12/seo
Then authors is [] and json_ld has no author key
Scenario: json_ld is a ready Article object
Given the article 12 credits the author 2 "Marta Alves" as above and has the cover 5 with alt "A field"
When an anonymous caller sends GET /v1/article/12/seo
Then json_ld is {"@context": "https://schema.org", "@type": "Article", "headline": "How we price a bathroom renovation", "description": "We price by the square metre.", "image": ["https://www.renew.example/static/uploadedImages/5.png"], "author": [{"@type": "Person", "name": "Marta Alves", "url": "https://marta.example", "sameAs": ["https://github.com/marta", "https://x.com/marta"]}], "datePublished": "2026-09-01T08:00:00Z", "dateModified": "2026-09-10T09:30:00Z", "mainEntityOfPage": {"@type": "WebPage", "@id": "https://www.renew.example/how-we-price-a-bathroom-renovation"}}
Scenario: The headline uses the meta title without the site template
Given the article 12 has seo_title "Bathroom costs"
When an anonymous caller sends GET /v1/article/12/seo
Then json_ld.headline is "Bathroom costs" and title is "Bathroom costs | Renew"
Scenario: The object never carries the body
Given the body of the article 12 contains "square metre"
When an anonymous caller sends GET /v1/article/12/seo
Then no value of the response is the full body and the raw response contains no ""
Scenario Outline: No format parameter is honoured
When an anonymous caller sends GET /v1/article/12/seo
Then the status is 200
And the body equals the response without any query
Examples:
| query |
| ?format=html |
| ?format=json |
| ?format=pdf |
Scenario: Another site uses its own base URL, template and robots
Given the article 12 moves to the site 2 with base_url "https://journal.renew.example", title_template "%title%" and default_robots "noindex"
When an anonymous caller sends GET /v1/article/12/seo
Then canonical_url starts with "https://journal.renew.example/" and robots is "noindex, nofollow" and title has no site suffix
Rule: Drafts answer authenticated callers who can read the article
Scenario: An online article is public
When a caller without credentials sends GET /v1/article/12/seo
Then the status is 200
Scenario Outline: A draft is visible to verified callers only
Given the article 13 is a draft
When GET /v1/article/13/seo is sent with
Then the status is
And the body is
Examples:
| credentials | status | body |
| the owner's jwt cookie | 200 | the resolved object |
| a verified admin's jwt cookie | 200 | the resolved object |
| a verified account with another role | 200 | the resolved object |
| a valid API key | 200 | the resolved object |
| no credentials | 404 | "Article not found" |
| an unverified account's jwt cookie | 404 | "Article not found" |
| a malformed jwt cookie | 404 | "Article not found" |
Scenario: A draft has no publication date
When the owner sends GET /v1/article/13/seo on a draft
Then published_at is null and modified_at is the article's updated_at
And json_ld has no datePublished
Scenario: Unpublishing hides the object from the public again
Given the article 12 is online
When it is unpublished and an anonymous caller sends GET /v1/article/12/seo
Then the status is 404 with body "Article not found"
Scenario: A missing article is not found
When any caller sends GET /v1/article/999/seo
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When any caller sends GET /v1/article/abc/seo
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: The read is idempotent and writes nothing
When the same request is sent twice
Then both responses are identical and no repository write and no activity event happen
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When a caller sends GET /v1/article/12/seo
Then the status is 500
Rule: POST /v1/article/{id}/seo/check evaluates without saving
Scenario: The check answers the documented shape
Given the article 12 has the default state of the background
When the owner sends POST /v1/article/12/seo/check with {}
Then the status is 200
And the body has exactly the keys status, suggestions and slug
And every suggestion has exactly the keys code, field and params
And slug has exactly the keys available and suggestion
Scenario Outline: Each code fires on its condition
Given the article 12 is configured so that
When the owner sends POST /v1/article/12/seo/check with {}
Then suggestions contains the code "" with field "" and params
Examples:
| condition | code | field | params |
| the seo_title is 74 characters | title_long | seo_title | {"length": 74, "max": 60} |
| the resolved title is empty after trimming | title_empty | seo_title | {} |
| the resolved description is 41 characters | desc_short | seo_description | {"length": 41, "min": 70} |
| the resolved description is 170 characters | desc_long | seo_description | {"length": 170, "max": 160} |
| the cover is set and cover_image_alt is "" | cover_alt | cover_image_alt | {} |
| cover_image_id is null | cover_none | cover_image_id | {} |
| the slug is 76 characters | slug_long | slug | {"length": 76, "max": 75} |
| no author is credited | no_au | author_ids | {} |
| the body has 1000 characters of text and no h2 to h6 | no_h | body | {} |
| the body has 3 images and one has an empty alt | body_alt | body | {"count": 1} |
| the body has 40 characters of text | short | body | {"length": 40, "min": 300} |
Scenario Outline: Soft ranges are inclusive at the edges
When the owner sends POST /v1/article/12/seo/check with
Then the code is
Examples:
| body | code | present |
| seo_title of 60 characters | title_long | absent |
| seo_title of 61 characters | title_long | present |
| seo_description of 70 characters | desc_short | absent |
| seo_description of 69 characters | desc_short | present |
| seo_description of 160 characters | desc_long | absent |
| seo_description of 161 characters | desc_long | present |
| slug of 75 characters | slug_long | absent |
| slug of 76 characters | slug_long | present |
Scenario Outline: The body rules have inclusive edges
When the owner sends POST /v1/article/12/seo/check with a body of characters of text,
Then the code is
Examples:
| text | headings | code | present |
| 999 | no heading | no_h | absent |
| 1000 | no heading | no_h | present |
| 1000 | one h2 | no_h | absent |
| 1000 | one h6 | no_h | absent |
| 1000 | only an h1 | no_h | present |
| 300 | no heading | short | absent |
| 299 | no heading | short | present |
Scenario: Lengths are counted in runes
When the owner sends POST /v1/article/12/seo/check with a seo_title of 60 characters that are 2 bytes each
Then title_long is absent
Scenario: The title range is measured on the meta title, not on the template output
Given the site title_template adds 30 characters
When the owner sends POST /v1/article/12/seo/check with a seo_title of 40 characters
Then title_long is absent
Scenario: Text length ignores tags
When the owner sends POST /v1/article/12/seo/check with a body of 200 characters of text wrapped in 400 characters of markup
Then the code short is present
Scenario: A fully prepared article is ready
Given the article 12 has a 45-character meta title, a 100-character description, a cover with alt text, a short slug, one author and a body with a heading, an image with alt text and 400 characters
When the owner sends POST /v1/article/12/seo/check with {}
Then status is "ready" and suggestions is []
Scenario: Body overrides replace saved values for the check only
Given the article 12 has seo_title "Short" saved
When the owner sends POST /v1/article/12/seo/check with {"seo_title": "<74 characters>"}
Then suggestions contains title_long
And the article 12 still has seo_title "Short"
Scenario Outline: Overrides can also clear a saved value
Given the article 12 has
When the owner sends POST /v1/article/12/seo/check with
Then the code is
Examples:
| saved | body | code | present |
| cover_image_id 5, empty alt | {"cover_image_id": null} | cover_alt | absent |
| cover_image_id 5, empty alt | {"cover_image_alt": "A field"} | cover_alt | absent |
| cover_image_alt "A field", cover_image_id 5 | {"cover_image_alt": ""} | cover_alt | present |
| a 74-character seo_title and a 20-character title | {"seo_title": ""} | title_long | absent |
Scenario: A body override feeds the body rules and the description fallback
Given the article 12 has the saved body of the background and no seo_description and no excerpt
When the owner sends POST /v1/article/12/seo/check with {"body": "Sentence one of exactly seventy characters, padded here to reach the minimum. Two.
"}
Then the code desc_short is absent
And the code short is present
And the article 12 still has its saved body
Scenario: A body override with images and headings
When the owner sends POST /v1/article/12/seo/check with a body holding 1000 characters, no heading and an image without alt
Then the codes no_h and body_alt are present
Scenario: The check is non-persisting
Given a snapshot of the article 12, its site and the activity log
When the owner sends POST /v1/article/12/seo/check with {"slug": "other", "noindex": true, "excerpt": "x", "body": "x
", "seo_dismissed": ["short"]}
Then the article 12 is unchanged, updated_at included
And no repository write, no activity event and no media usage change happen
Scenario: Dismissed codes are filtered out
Given the article 12 has seo_dismissed ["cover_none", "no_au"] and would raise cover_none, no_au and short
When the owner sends POST /v1/article/12/seo/check with {}
Then suggestions has only the code "short"
Scenario: A dismissed override in the body filters the check
When the owner sends POST /v1/article/12/seo/check with {"seo_dismissed": ["short", "no_au", "cover_none", "no_h"]} on an article that would raise those four only
Then suggestions is [] and status is "ready"
Scenario: Dismissing everything leaves ready, noindex still hides
Given the article 12 has noindex true
When the owner sends POST /v1/article/12/seo/check with {"seo_dismissed": []}
Then status is "hidden" and suggestions is []
Scenario Outline: The status
Given the article 12 has
When the owner sends POST /v1/article/12/seo/check with {}
Then status is ""
Examples:
| state | status |
| no suggestion and noindex false | ready |
| two suggestions and noindex false | suggestions |
| no suggestion and noindex true | hidden |
| two suggestions and noindex true | hidden |
Scenario: A noindex override gives hidden without saving
When the owner sends POST /v1/article/12/seo/check with {"noindex": true}
Then status is "hidden" and the article 12 still has noindex false
Scenario: Suggestions come in a fixed order
Given the article would raise short, title_long and no_au
When the owner sends POST /v1/article/12/seo/check with {}
Then the codes are in the order title_long, no_au, short
Scenario Outline: Slug availability on the article's site
Given the site 1 has the articles 12 with slug "how-we-price-a-bathroom-renovation" and 14 with slug "spring" and 15 with slug "spring-2", and the site 2 has an article with slug "autumn"
When the owner sends POST /v1/article/12/seo/check with
Then slug is
Examples:
| body | slug |
| {} | {"available": true, "suggestion": null} |
| {"slug": "how-we-price-a-bathroom-renovation"} | {"available": true, "suggestion": null} |
| {"slug": "fresh"} | {"available": true, "suggestion": null} |
| {"slug": "autumn"} | {"available": true, "suggestion": null} |
| {"slug": "spring"} | {"available": false, "suggestion": "spring-3"} |
| {"slug": ""} | {"available": true, "suggestion": null} |
Scenario: A regenerated empty slug can be taken
Given another article of the site 1 already has the slug "how-we-price-a-bathroom-renovation" and the article 12 has slug "custom"
When the owner sends POST /v1/article/12/seo/check with {"slug": ""}
Then slug is {"available": false, "suggestion": "how-we-price-a-bathroom-renovation-2"}
Scenario: The suggestion fits the slug limit
Given another article of the site has a 100-character slug
When the owner sends POST /v1/article/12/seo/check with that slug
Then slug.suggestion is at most 100 characters and ends with "-2"
Scenario: A taken slug is not a refusal
When the owner sends POST /v1/article/12/seo/check with a taken slug
Then the status is 200 and not 409
Scenario Outline: The same validation as PATCH applies
When the owner sends POST /v1/article/12/seo/check with
Then the status is
And the body is ""
Examples:
| field | value | status | body |
| seo_title | 121 characters | 400 | the validator error text |
| seo_description | 321 characters | 400 | the validator error text |
| excerpt | 301 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 |
| slug | "Spring" | 400 | invalid_slug |
| slug | 101 characters | 400 | invalid_slug |
| canonical_url | "http://example.com/a" | 422 | invalid_url |
| cover_image_id | 999 (no such image) | 400 | image_not_found |
| social_image_id | 999 (no such image) | 400 | image_not_found |
| seo_dismissed | ["nope"] | 400 | the validator error text |
Scenario Outline: Malformed bodies are refused
When the owner sends POST /v1/article/12/seo/check with
Then the status is 400
And the body is apperr.BodyErrorMessage
Examples:
| body |
| {not json |
| {"slug": null} |
| {"noindex": null} |
| {"seo_dismissed": null} |
| {"body": null} |
| {"noindex": "yes"} |
| {"colour": "x"} |
Scenario: An empty body object is a valid check of the saved state
When the owner sends POST /v1/article/12/seo/check with {}
Then the status is 200
Scenario Outline: Site and author overrides are not accepted
When the owner sends POST /v1/article/12/seo/check with
Then the status is 400 with body apperr.BodyErrorMessage
Examples:
| body |
| {"site_id": 2} |
| {"author_ids": [2]} |
Scenario Outline: Anyone who can edit the article may check it
When POST /v1/article/12/seo/check is sent with
Then the status is
Examples:
| credentials | status |
| the owner's jwt cookie | 200 |
| a verified admin's jwt cookie | 200 |
| a verified account with another role | 200 |
| a valid API key | 200 |
| an unverified account's jwt cookie | 403 |
| no credentials | 401 |
Scenario: A draft can be checked
When the owner sends POST /v1/article/13/seo/check with {} on a draft
Then the status is 200
Scenario: A missing article is not found
When the owner sends POST /v1/article/999/seo/check with {}
Then the status is 404 with body "Article not found"
Scenario: A malformed id is refused
When the owner sends POST /v1/article/abc/seo/check with {}
Then the status is 400 with body apperr.IdUint32ErrorMessage
Scenario: Publishing is never blocked by suggestions
Given the check of the article 13 reports suggestions
When the owner sends PATCH /v1/article/13/publish
Then the status is 200
Rule: The article list carries the SEO status
Scenario: Items carry seo_status and seo_suggestions
Given the articles 1 (ready), 2 (2 suggestions), 3 (noindex true) and 4 (3 suggestions, one dismissed so 2 remain)
When a verified account sends GET /v1/article/
Then the item 1 has seo_status "ready" and seo_suggestions 0
And the item 2 has seo_status "suggestions" and seo_suggestions 2
And the item 3 has seo_status "hidden"
And the item 4 has seo_status "suggestions" and seo_suggestions 2
And every item has the existing fields, excerpt and slug, and no seo_dismissed key
Scenario: A hidden article still reports its suggestion count
Given the article 3 has noindex true and 2 non-dismissed suggestions
When a verified account sends GET /v1/article/
Then the item 3 has seo_status "hidden" and seo_suggestions 2
Scenario: The list and the check agree
Given the articles 1 to 4 above
When the list is read and the check is run for each article with {}
Then for each article seo_status equals the check status and seo_suggestions equals the number of check suggestions
Scenario: Anonymous callers see online items with their status
When a caller without credentials sends GET /v1/article/
Then each online item carries seo_status and seo_suggestions and no draft is listed
Scenario Outline: The seo filter
Given the articles 1 (ready), 2 (suggestions), 3 (hidden) and 4 (suggestions)
When a verified account sends GET /v1/article/?seo=
Then the item ids are
And total is
Examples:
| value | ids | total |
| ready | 1 | 1 |
| suggestions | 4,2 | 2 |
| hidden | 3 | 1 |
Scenario: The filter composes with search, status, site, sort and paging
Given 7 draft articles of the site "journal" titled "Tile ..." with suggestions, 3 online ones and 4 more on the site "renew"
When a verified account sends GET /v1/article/?seo=suggestions&q=tile&status=draft&site=journal&sort=title:asc&size=5&page=2
Then the page holds the 2 remaining items in title order and total is 7
And pages 1 and 2 share no id
Scenario: Counts stay unfiltered
Given 4 online and 2 draft articles of which 3 have suggestions
When a verified account sends GET /v1/article/?seo=suggestions
Then counts is {"all": 6, "online": 4, "draft": 2} and total is 3
Scenario: The filter is applied before paging
Given 30 articles of which 12 have suggestions
When a verified account sends GET /v1/article/?seo=suggestions&size=5&page=3
Then the page holds 2 items and total is 12
Scenario: The filter respects visibility
Given an offline article with suggestions
When a caller without credentials sends GET /v1/article/?seo=suggestions
Then the offline article is not listed and total ignores it
Scenario: Dismissed codes are never counted in the filter
Given an article whose only suggestion is dismissed
When a verified account sends GET /v1/article/?seo=suggestions
Then the article is not listed
Scenario Outline: An invalid seo value is refused
When a client sends GET /v1/article/?
Then the status is 400
And the body is apperr.BodyErrorMessage
And the repository was not called
Examples:
| query |
| seo=maybe |
| seo= |
| seo=READY |
| seo=ready,hidden |
| seo=1 |
Scenario: Invalid format is still reported first
When a client sends GET /v1/article/?seo=maybe&format=pdf
Then the status is 400 and the body is "The format must be one of: markdown, html, json."
Scenario: The existing list behaviour is unchanged without the filter
Given the background articles
When a verified account sends GET /v1/article/
Then total, page, size, counts, ordering and the other item fields are as before this slice
Rule: GET /v1/site/{key}/sitemap lists published URLs
Scenario: The sitemap lists the online articles of one site
Given the site "renew" has the online articles 12 and 16, and a draft 13
And the site "journal" has the online article 20
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the status is 200
And the body is a JSON array of 2 items: {"url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "modified_at": "2026-09-10T09:30:00Z", "noindex": false} and the item of the article 16
And the header X-Total-Count is "2"
And each item has exactly the keys url, modified_at and noindex
Scenario: Noindex articles are included with the flag
Given the online article 16 has noindex true
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the item of the article 16 has "noindex": true
Scenario: The url uses the slug and not the canonical override
Given the online article 12 has canonical_url "https://other.example/a"
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the item url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
Scenario: Drafts are never listed
When a caller with a verified jwt cookie sends GET /v1/site/renew/sitemap
Then the draft 13 is not listed
Scenario: An unpublished article leaves the sitemap
Given the article 12 is unpublished
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the article 12 is not listed and X-Total-Count decreases by 1
Scenario: The sitemap is public
When a caller without credentials sends GET /v1/site/renew/sitemap
Then the status is 200
Scenario: An empty site answers an empty array
Given the site "renew" has only drafts
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the status is 200, the body is [] and X-Total-Count is "0"
Scenario Outline: Paging and the total header
Given the site "renew" has online articles
When an anonymous caller sends GET /v1/site/renew/sitemap
Then the body holds items
And X-Total-Count is ""
Examples:
| articles | query | count |
| 250 | | 100 |
| 250 | ?size=25 | 25 |
| 250 | ?page=3 | 50 |
| 250 | ?page=2&size=100 | 100 |
| 250 | ?size=500 | 250 |
| 250 | ?page=99&size=25 | 0 |
Scenario: Pages partition the sitemap in a stable order
Given 60 online articles
When pages 1, 2 and 3 are requested with size 25
Then they hold 25, 25 and 10 items in id ascending order with no repeat
Scenario Outline: Invalid paging answers 400
When a client sends GET /v1/site/renew/sitemap?
Then the status is 400
And the body is apperr.BodyErrorMessage
Examples:
| query |
| page=0 |
| page=-1 |
| page=abc |
| page= |
| size=0 |
| size=10 |
| size=1000 |
| size=abc |
Scenario: An unknown key is not found
When a client sends GET /v1/site/nope/sitemap
Then the status is 404 with body "site_not_found"
Scenario: The key is not an id
When a client sends GET /v1/site/1/sitemap on a site whose key is "renew"
Then the status is 404
Scenario: The route does not disturb the owner-only site routes
When an admin sends PATCH /v1/site/2 with {"site_name": "x"}
Then the status is 403 with body "Only the owner can do this." as before
Scenario: The browser can read the total header
When a cross-origin client reads the response of GET /v1/site/renew/sitemap with CORS configured
Then Access-Control-Expose-Headers lists X-Total-Count and still lists Link
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When a client sends GET /v1/site/renew/sitemap
Then the status is 500
Rule: Layering and documentation
Scenario: Layering is respected
Then use cases import no persistence, mailer, models, gorm, net/http or chi
And the resolver, the suggestion rules and the sitemap logic live in use cases or domain packages
And the domain packages import no GORM or HTTP
And handlers import no persistence and no repository
And only api/dependecyInjection.go builds the new use cases and api/router.go wires them
And no comment is added anywhere
Scenario: The docs describe the new surface
Then docs/api.md documents GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, the seo query parameter, the seo_status and seo_suggestions item fields, GET /v1/site/{key}/sitemap, its X-Total-Count header and the CORS exposure of that header
And docs/api/articles.md documents the resolved object, the fallback chain, the suggestion codes with their soft ranges and the draft visibility rule
And docs/release-notes.md announces the change
And web/src/data/apiCatalogue.ts lists the three new endpoints and the new list parameter and fields, and its spec and web/src/views/IntegrationView.spec.ts counts are updated