677 lines
40 KiB
Gherkin
677 lines
40 KiB
Gherkin
Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap
|
|
|
|
As a frontend developer or the RenewCMS back-office editor
|
|
I want the resolved SEO data of an article, a non-persisting check, an SEO status on the article list and a sitemap per site
|
|
So that a frontend can render meta tags and a sitemap without reimplementing the fallback chain
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/seo-slices.md (slice 2), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/articles.md and docs/design/screens/integration.md
|
|
And this slice is backend only: no SPA screen and no change to docs/design
|
|
And it reuses the article and site SEO fields of features/seo_data_backend.feature and adds no column and no migration
|
|
And RenewCMS renders no page and injects no meta tag: it only returns data
|
|
And one rule set (a single use-case-level function over the article, its site, its authors and its body) feeds the list seo_status and seo_suggestions, the check endpoint and the suggestion-related parts of the resolver
|
|
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
|
|
And the soft ranges are: meta title 30 to 60 characters, description 70 to 160, slug "very long" above 75; they only produce suggestions and never block saving or publishing
|
|
And codes listed in the article's seo_dismissed are never reported and never counted
|
|
And the status of an article is "hidden" when noindex is true, otherwise "suggestions" when at least one non-dismissed suggestion remains, otherwise "ready"
|
|
And a caller is "verified" when the jwt cookie (or an API key) resolves to a user with is_verified true, and "public" otherwise, exactly as for GET /v1/article/{id}
|
|
And the fallback chain is: title = seo_title, then the article title, with the site title_template applied last; description = seo_description, then excerpt, then the first sentence of the body, then the site default_description; image = social_image_id, then cover_image_id, then the site default_social_image_id; canonical = canonical_url, then site base_url + "/" + slug; robots = "noindex, nofollow" when noindex is true, otherwise from the site default_robots; og and twitter title and description = social_title and social_description, then the resolved values
|
|
And the sample site 1 is key "renew", base_url "https://www.renew.example", site_name "Renew", title_template "%title% | %site%", default_description "Renovation notes from the Renew team.", default_robots "index", locale "en" and default_social_image_id 7 (path /static/uploadedImages/7.png)
|
|
And the sample article 12 is online on the site 1, titled "How we price a bathroom renovation", slug "how-we-price-a-bathroom-renovation", created 2026-09-01T08:00:00Z, updated 2026-09-10T09:30:00Z, with no SEO field set and the body "<p>We price by the square metre. Tiles cost extra.</p>"
|
|
And error responses are plain text from http.Error: 404 "Article not found" (apperr.ArticleNotFoundErrorMessage), 404 "site_not_found" (apperr.SiteNotFoundErrorMessage), 400 apperr.IdUint32ErrorMessage for a malformed id, 400 apperr.BodyErrorMessage for a malformed body or paging parameter
|
|
And request validation goes through validate tags and validator.Validate.Struct
|
|
And handlers reach articles, sites, authors and images only through use cases and domain interfaces, and no comment is added anywhere
|
|
|
|
Rule: GET /v1/article/{id}/seo returns the resolved object
|
|
|
|
Scenario: The object has the documented shape
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then the status is 200
|
|
And the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld
|
|
And og has exactly the keys type, title, description, image, image_alt, url, site_name and locale
|
|
And twitter has exactly the keys card, title, description, image and image_alt
|
|
And image has exactly the keys url, alt and source
|
|
And fallbacks has exactly the keys title, description, image and canonical
|
|
And the body has no body, no id and no seo_dismissed key
|
|
|
|
Scenario: A bare article resolves every output through the fallbacks
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then title is "How we price a bathroom renovation | Renew"
|
|
And description is "We price by the square metre."
|
|
And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
|
|
And robots is "index, follow"
|
|
And image is {"url": "https://www.renew.example/static/uploadedImages/7.png", "alt": "", "source": "site"}
|
|
And og is {"type": "article", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": "", "url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "site_name": "Renew", "locale": "en"}
|
|
And twitter is {"card": "summary_large_image", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": ""}
|
|
And fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site"}
|
|
And published_at is "2026-09-01T08:00:00Z" and modified_at is "2026-09-10T09:30:00Z"
|
|
|
|
Scenario Outline: The title chain and the template
|
|
Given the article 12 has seo_title "<seo_title>" and the site title_template is "<template>"
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then title is "<title>"
|
|
And fallbacks.title is "<source>"
|
|
|
|
Examples:
|
|
| seo_title | template | title | source |
|
|
| | %title% \| %site% | How we price a bathroom renovation \| Renew | title |
|
|
| Bathroom costs | %title% \| %site% | Bathroom costs \| Renew | seo_title |
|
|
| Bathroom costs | %site% - %title% | Renew - Bathroom costs | seo_title |
|
|
| Bathroom costs | %title% | Bathroom costs | seo_title |
|
|
| Bathroom costs | News: %title% | News: Bathroom costs | seo_title |
|
|
|
|
Scenario Outline: An empty site_name leaves nothing in place of %site% and the title is trimmed
|
|
Given the site 1 has site_name "" and title_template "<template>"
|
|
And the article 12 has seo_title "Bathroom costs"
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then title is "<title>"
|
|
|
|
Examples:
|
|
| template | title |
|
|
| %title% \| %site% | Bathroom costs \| |
|
|
| %site% %title% | Bathroom costs |
|
|
| %title% | Bathroom costs |
|
|
|
|
Scenario Outline: The description chain
|
|
Given the article 12 has seo_description "<seo_description>", excerpt "<excerpt>", the body "<body>" and the site default_description "<default>"
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then description is "<description>"
|
|
And fallbacks.description is "<source>"
|
|
|
|
Examples:
|
|
| seo_description | excerpt | body | default | description | source |
|
|
| Own words. | A teaser. | <p>First one. Second.</p> | Site. | Own words. | seo_description |
|
|
| | A teaser. | <p>First one. Second.</p> | Site. | A teaser. | excerpt |
|
|
| | | <p>First one. Second.</p> | Site. | First one. | body |
|
|
| | | <p>No terminator here</p> | Site. | No terminator here | body |
|
|
| | | <h2>Title</h2><p>What? Yes.</p> | Site. | Title What? | body |
|
|
| | | | Site. | Site. | site |
|
|
| | | | | | none |
|
|
|
|
Scenario Outline: The image chain
|
|
Given the article 12 has social_image_id <social>, cover_image_id <cover> and cover_image_alt "<alt>", and the images 5, 6 and 7 exist
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then image.url ends with "<path>", image.alt is "<image_alt>" and image.source is "<source>"
|
|
And og.image equals image.url and twitter.image equals image.url
|
|
And fallbacks.image is "<source>"
|
|
|
|
Examples:
|
|
| social | cover | alt | path | image_alt | source |
|
|
| 6 | 5 | A field | /static/uploadedImages/6.png | | social |
|
|
| null | 5 | A field | /static/uploadedImages/5.png | A field | cover |
|
|
| null | null | | /static/uploadedImages/7.png | | site |
|
|
|
|
Scenario: No image anywhere
|
|
Given the article 12 has no cover and no social image and the site has no default social image
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then image is {"url": "", "alt": "", "source": "none"}
|
|
And og.image and twitter.image are ""
|
|
And twitter.card is "summary"
|
|
And fallbacks.image is "none"
|
|
|
|
Scenario Outline: Canonical and robots
|
|
Given the article 12 has canonical_url "<canonical>" and noindex <noindex>, and the site default_robots is "<robots>"
|
|
When an anonymous caller sends GET /v1/article/12/seo
|
|
Then canonical_url is "<resolved>" and og.url is "<resolved>"
|
|
And fallbacks.canonical is "<source>"
|
|
And robots is "<result>"
|
|
|
|
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 "<p>"
|
|
|
|
Scenario Outline: No format parameter is honoured
|
|
When an anonymous caller sends GET /v1/article/12/seo<query>
|
|
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 <credentials>
|
|
Then the status is <status>
|
|
And the body is <body>
|
|
|
|
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 <condition>
|
|
When the owner sends POST /v1/article/12/seo/check with {}
|
|
Then suggestions contains the code "<code>" with field "<field>" and params <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 <body>
|
|
Then the code <code> is <present>
|
|
|
|
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 <text> characters of text, <headings>
|
|
Then the code <code> is <present>
|
|
|
|
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 <saved>
|
|
When the owner sends POST /v1/article/12/seo/check with <body>
|
|
Then the code <code> is <present>
|
|
|
|
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": "<p>Sentence one of exactly seventy characters, padded here to reach the minimum. Two.</p>"}
|
|
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": "<p>x</p>", "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": [<all 11 codes>]}
|
|
Then status is "hidden" and suggestions is []
|
|
|
|
Scenario Outline: The status
|
|
Given the article 12 has <state>
|
|
When the owner sends POST /v1/article/12/seo/check with {}
|
|
Then status is "<status>"
|
|
|
|
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 <body>
|
|
Then slug is <slug>
|
|
|
|
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 <field> <value>
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
|
|
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 <body>
|
|
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 <body>
|
|
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 <credentials>
|
|
Then the status is <status>
|
|
|
|
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=<value>
|
|
Then the item ids are <ids>
|
|
And total is <total>
|
|
|
|
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/?<query>
|
|
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 <articles> online articles
|
|
When an anonymous caller sends GET /v1/site/renew/sitemap<query>
|
|
Then the body holds <count> items
|
|
And X-Total-Count is "<articles>"
|
|
|
|
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?<query>
|
|
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
|