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