feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -84,7 +84,8 @@ Feature: Every real action of the back-office is recorded in the activity log fr
|
||||
Scenario: A title change is recorded as a text diff when a title edit exists
|
||||
Given a use case that changes an article's title from "Spring release" to "Spring release 2"
|
||||
Then its event has action "article.edit" and changes [{"field": "title", "kind": "text", "before": "Spring release", "after": "Spring release 2"}]
|
||||
And only the fields title, online and body can ever appear in an article event's changes
|
||||
And only the fields title, online, body and, since features/seo_data_backend.feature, site, authors and the SEO fields can ever appear in an article event's changes
|
||||
And SEO free-text fields (excerpt, seo_title, seo_description, social_title, social_description, cover_image_alt) are recorded as a size delta in characters with kind "body" and never as text, while short values (slug, canonical_url, noindex, cover_image, social_image) are recorded as text before and after
|
||||
|
||||
Scenario: Publishing is recorded with the publish state
|
||||
Given the article 8 "Spring release" is offline
|
||||
|
||||
@@ -757,9 +757,10 @@ Feature: Authors backend, public bylines that articles credit in order
|
||||
Given the purge of a group fails midway
|
||||
Then every author avatar_id refers to an image that still exists
|
||||
|
||||
Scenario: The usage lookup array shape is unchanged
|
||||
Scenario: The usage lookup stays an array and later gains additive keys
|
||||
When a verified account looks up the usage of an image
|
||||
Then the response keeps the same array shape as before this slice
|
||||
Then the response is a JSON array whose article items keep id, title and published
|
||||
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
|
||||
|
||||
Rule: Every author write is recorded in the activity log
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `te
|
||||
|
||||
## I. Media
|
||||
|
||||
46. Usage lookup counts an avatar, shape unchanged, image not "Unused". Media list `used_in` sums article, site and author usage.
|
||||
46. Usage lookup counts an avatar, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused". Media list `used_in` sums article, site and author usage.
|
||||
47. Deleting the image (204) nulls `avatar_id`.
|
||||
48. Merge repoints authors and reports `authors_updated`; purge does the same; a failed purge group leaves every `avatar_id` pointing at an existing image.
|
||||
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
Feature: SEO data backend, article and site search fields, unique slugs and media usage
|
||||
|
||||
As an editor or the owner of a RenewCMS installation
|
||||
I want to store search and sharing data on articles and sites, with a slug that is unique per site
|
||||
So that a frontend can later resolve titles, descriptions, images and addresses from RenewCMS
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/seo-slices.md (slice 1), docs/design/change-list.md ("SEO round"), docs/design/screens/editor.md, docs/design/screens/sites.md and docs/design/states.md
|
||||
And this slice is backend only: no SPA, no change to docs/design, and no resolver, check endpoint, sitemap or list seo filter
|
||||
And an article gains these snake_case fields: slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed
|
||||
And in article JSON the text fields are "" when empty, cover_image_id and social_image_id are null when unset, noindex is false by default and seo_dismissed is [] by default
|
||||
And every article read (GET /v1/article/{id}, POST /v1/article, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish) carries all twelve fields
|
||||
And each item of GET /v1/article/ carries excerpt and slug, and none of the other ten new fields
|
||||
And the SEO fields join body, site_id and author_ids as the known fields of PATCH /v1/article/{id}
|
||||
And a PATCH with no known field, an unknown field only, or a null value on any field other than cover_image_id and social_image_id answers 400 with apperr.BodyErrorMessage, as before
|
||||
And limits are counted in runes: excerpt 300, seo_title 120, seo_description 320, cover_image_alt 200, social_title 120, social_description 320, canonical_url 2048
|
||||
And the slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters, the rule of authors
|
||||
And canonical_url is empty or an absolute https URL with a host
|
||||
And seo_dismissed is a list of the 11 suggestion codes title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short; an unknown code answers 400; duplicates are collapsed with the order kept
|
||||
And a slug is unique per site (site_id, slug), never globally
|
||||
And one slug rule applies at creation, in PATCH regeneration and in the migration: lowercase, accents folded, every run of other characters becomes one hyphen, hyphens trimmed, cut to 100 characters with no trailing hyphen, and a result with no letter or digit becomes "article"
|
||||
And when that slug is taken on the site, creation and the migration add -2, -3 and so on, truncating the base so the result fits 100 characters
|
||||
And a slug conflict on PATCH follows the authors slug conflict: status 409 with the plain-text body "slug_taken" from http.Error, and additionally the response header X-Slug-Suggestion holds the first free "<slug>-N" from 2 on the article's target site, fitting 100 characters
|
||||
And other error responses are plain text from http.Error whose body is the existing token or the validator error text: invalid_slug (400, as for authors), invalid_url (422, as for authors), image_not_found (400, as for authors and sites), site_not_found (404), and the new invalid_title_template (400)
|
||||
And a missing article answers 404 "Article not found" and an offline article still reads as not found publicly
|
||||
And a site gains site_name, title_template and default_description; site JSON keeps default_social_image_id and default_robots
|
||||
And POST /v1/site/ and PATCH /v1/site/{id} stay owner only through RequireOwner: a non-owner session gets 403 "Only the owner can do this." and an API key gets 403 "This action needs a real session."
|
||||
And request validation goes through validate tags and validator.Validate.Struct
|
||||
And handlers reach articles, sites and media only through use cases and domain interfaces, and no comment is added anywhere
|
||||
|
||||
Rule: Article SEO fields are edited through PATCH /v1/article/{id}
|
||||
|
||||
Scenario: All SEO fields are set at once
|
||||
Given the article 8 "Spring release" is on the site 1 and the images 5 and 6 exist
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}
|
||||
Then the status is 200
|
||||
And the body carries those values and the unchanged title, body, site_id and authors
|
||||
And the article is stored with those values
|
||||
|
||||
Scenario: Absent fields are untouched
|
||||
Given the article 8 has an excerpt, a cover image and a canonical_url
|
||||
When the user sends PATCH /v1/article/8 with {"seo_title": "New title"}
|
||||
Then only seo_title changed
|
||||
|
||||
Scenario Outline: Text fields and the flag are cleared with empty values
|
||||
Given the article 8 has every SEO field set
|
||||
When the user sends PATCH /v1/article/8 with <body>
|
||||
Then the status is 200
|
||||
And <result>
|
||||
|
||||
Examples:
|
||||
| body | result |
|
||||
| {"excerpt": ""} | excerpt is "" |
|
||||
| {"seo_title": ""} | seo_title is "" |
|
||||
| {"seo_description": ""} | seo_description is "" |
|
||||
| {"cover_image_alt": ""} | cover_image_alt is "" |
|
||||
| {"social_title": ""} | social_title is "" |
|
||||
| {"social_description": ""} | social_description is "" |
|
||||
| {"canonical_url": ""} | canonical_url is "" |
|
||||
| {"noindex": false} | noindex is false |
|
||||
| {"seo_dismissed": []} | seo_dismissed is [] |
|
||||
| {"cover_image_id": null} | cover_image_id is null |
|
||||
| {"social_image_id": null} | social_image_id is null |
|
||||
|
||||
Scenario Outline: Boundaries are accepted
|
||||
When the user sends PATCH /v1/article/8 with <field> <value>
|
||||
Then the status is 200
|
||||
|
||||
Examples:
|
||||
| field | value |
|
||||
| excerpt | 300 characters |
|
||||
| excerpt | 300 multi-byte characters |
|
||||
| seo_title | 120 characters |
|
||||
| seo_description | 320 characters |
|
||||
| cover_image_alt | 200 characters |
|
||||
| social_title | 120 characters |
|
||||
| social_description | 320 characters |
|
||||
| slug | 100 characters |
|
||||
| canonical_url | "https://example.com/a?b=1#c" |
|
||||
| canonical_url | 2048 characters, https |
|
||||
|
||||
Scenario Outline: Fields are validated
|
||||
When the user sends PATCH /v1/article/8 with <field> <value>
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| field | value | status | body |
|
||||
| excerpt | 301 characters | 400 | the validator error text |
|
||||
| seo_title | 121 characters | 400 | the validator error text |
|
||||
| seo_description | 321 characters | 400 | the validator error text |
|
||||
| cover_image_alt | 201 characters | 400 | the validator error text |
|
||||
| social_title | 121 characters | 400 | the validator error text |
|
||||
| social_description | 321 characters | 400 | the validator error text |
|
||||
| canonical_url | 2049 characters, https | 400 | the validator error text |
|
||||
| slug | "Spring" | 400 | invalid_slug |
|
||||
| slug | "spring_2026" | 400 | invalid_slug |
|
||||
| slug | "-spring" | 400 | invalid_slug |
|
||||
| slug | "spring--2026" | 400 | invalid_slug |
|
||||
| slug | 101 characters | 400 | invalid_slug |
|
||||
| canonical_url | "http://example.com/a" | 422 | invalid_url |
|
||||
| canonical_url | "/relative" | 422 | invalid_url |
|
||||
| canonical_url | "example.com" | 422 | invalid_url |
|
||||
| canonical_url | "https://" | 422 | invalid_url |
|
||||
| seo_dismissed | ["nope"] | 400 | the validator error text |
|
||||
| cover_image_id | 999 (no such image) | 400 | image_not_found |
|
||||
| social_image_id | 999 (no such image) | 400 | image_not_found |
|
||||
| cover_image_id | 0 | 400 | the validator error text |
|
||||
|
||||
Scenario Outline: A wrong type or null on other fields is refused
|
||||
When the user sends PATCH /v1/article/8 with <body>
|
||||
Then the status is 400
|
||||
And the body is apperr.BodyErrorMessage
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {} |
|
||||
| {"colour": "x"} |
|
||||
| {"slug": null} |
|
||||
| {"excerpt": null} |
|
||||
| {"noindex": null} |
|
||||
| {"seo_dismissed": null} |
|
||||
| {"noindex": "yes"} |
|
||||
| {"excerpt": 5} |
|
||||
| {"cover_image_id": "a"} |
|
||||
| {"seo_dismissed": "x"} |
|
||||
|
||||
Scenario: Duplicate dismissed codes are collapsed
|
||||
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short", "cover_alt", "short"]}
|
||||
Then seo_dismissed is ["short", "cover_alt"]
|
||||
|
||||
Scenario: Every known dismissed code is accepted
|
||||
When the user sends PATCH /v1/article/8 with all 11 codes in seo_dismissed
|
||||
Then the status is 200
|
||||
|
||||
Scenario: A refused update changes nothing else
|
||||
When the user sends PATCH /v1/article/8 with {"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}
|
||||
Then the status is 422 with body "invalid_url"
|
||||
And the body and the excerpt of the article 8 are unchanged
|
||||
|
||||
Scenario: SEO fields and the other fields change together
|
||||
When the user sends PATCH /v1/article/8 with {"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}
|
||||
Then the body, the site_id, the authors and the excerpt are all updated
|
||||
|
||||
Scenario: An update without SEO fields keeps them
|
||||
Given the article 8 has every SEO field set
|
||||
When the user sends PATCH /v1/article/8 with {"body": "New"}
|
||||
Then every SEO field is unchanged
|
||||
|
||||
Scenario Outline: Any account that can edit the article edits its SEO fields
|
||||
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
|
||||
Then the status is 200
|
||||
|
||||
Examples:
|
||||
| credentials |
|
||||
| the owner's jwt cookie |
|
||||
| a verified admin's jwt cookie |
|
||||
| a verified account with another role |
|
||||
| a valid API key |
|
||||
|
||||
Scenario Outline: Without a verified account the article is not edited
|
||||
When PATCH /v1/article/8 is sent with {"excerpt": "Teaser"} and <credentials>
|
||||
Then the status is <status>
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| credentials | status |
|
||||
| an unverified account's jwt cookie | 403 |
|
||||
| no credentials | 401 |
|
||||
|
||||
Scenario: A missing article is not found
|
||||
When the user sends PATCH /v1/article/999 with {"excerpt": "x"}
|
||||
Then the status is 404 with body "Article not found"
|
||||
|
||||
Scenario: A malformed id is refused
|
||||
When the user sends PATCH /v1/article/abc with {"excerpt": "x"}
|
||||
Then the status is 400 with body apperr.IdUint32ErrorMessage
|
||||
|
||||
Scenario: noindex is only a flag
|
||||
Given the article 8 is online
|
||||
When the user sends PATCH /v1/article/8 with {"noindex": true}
|
||||
Then the article is still online
|
||||
And an anonymous GET /v1/article/8 still returns it with "noindex": true
|
||||
|
||||
Scenario: The cover alt is independent of the cover
|
||||
When the user sends PATCH /v1/article/8 with {"cover_image_alt": "A field"} on an article without cover
|
||||
Then the status is 200
|
||||
And cover_image_id is null and cover_image_alt is "A field"
|
||||
|
||||
Rule: Article reads carry the SEO fields
|
||||
|
||||
Scenario Outline: Single-article reads carry all twelve fields
|
||||
Given the article 8 has every SEO field set
|
||||
When <request> is sent
|
||||
Then the article in the response has slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed with the stored values
|
||||
|
||||
Examples:
|
||||
| request |
|
||||
| GET /v1/article/8 |
|
||||
| PATCH /v1/article/8 |
|
||||
| PATCH /v1/article/8/publish |
|
||||
| PATCH /v1/article/8/unpublish |
|
||||
|
||||
Scenario: A new article has the defaults
|
||||
When the user sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
|
||||
Then the status is 201
|
||||
And the article has slug "spring-release", excerpt "", seo_title "", seo_description "", cover_image_id null, cover_image_alt "", social_title "", social_description "", social_image_id null, canonical_url "", noindex false and seo_dismissed []
|
||||
|
||||
Scenario: The list carries the excerpt and the slug
|
||||
Given the article 8 has the excerpt "Short teaser." and the slug "spring-release"
|
||||
When a verified account sends GET /v1/article/
|
||||
Then each item of items has "excerpt" and "slug"
|
||||
And the article 8 item has excerpt "Short teaser." and slug "spring-release"
|
||||
And no item has seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex or seo_dismissed
|
||||
And the status, total, page, size and counts are as before this slice
|
||||
|
||||
Scenario: Anonymous readers see the SEO fields of online articles
|
||||
Given the article 9 is online with SEO fields set
|
||||
When a caller without credentials sends GET /v1/article/9
|
||||
Then the SEO fields are returned
|
||||
|
||||
Scenario: An offline article is still not found publicly
|
||||
When a caller without a verified account sends GET /v1/article/{offline id}
|
||||
Then the status is 404 with body "Article not found"
|
||||
|
||||
Scenario: The list for anonymous callers shows online articles only
|
||||
Given one online and one offline article
|
||||
When a caller without a verified account sends GET /v1/article/
|
||||
Then only the online article is listed, with its excerpt and slug
|
||||
|
||||
Rule: The slug is unique per site and generated at creation
|
||||
|
||||
Scenario Outline: The slug is generated from the title at creation
|
||||
When the user sends POST /v1/article with {"title": "<title>", "body": "x"}
|
||||
Then the slug is "<slug>"
|
||||
|
||||
Examples:
|
||||
| title | slug |
|
||||
| Spring release | spring-release |
|
||||
| Été à Paris | ete-a-paris |
|
||||
| Dr. O'Neil Smith | dr-o-neil-smith |
|
||||
| !!! | article |
|
||||
|
||||
Scenario: A long title gives a slug of at most 100 characters
|
||||
When the user sends POST /v1/article with a title whose slug would exceed 100 characters
|
||||
Then the slug has at most 100 characters and does not end with "-"
|
||||
|
||||
Scenario: A generated slug that is taken on the site is de-duplicated silently
|
||||
Given the site 1 has the articles with slugs "spring-release" and "spring-release-2"
|
||||
When the user sends POST /v1/article with {"title": "Spring release", "body": "x"}
|
||||
Then the status is 201
|
||||
And the slug is "spring-release-3"
|
||||
|
||||
Scenario: A title without letters or digits is de-duplicated too
|
||||
Given the site 1 has an article with slug "article"
|
||||
When the user sends POST /v1/article with {"title": "!!!", "body": "x"}
|
||||
Then the slug is "article-2"
|
||||
|
||||
Scenario: The suffix still fits the slug limit at creation
|
||||
Given a title whose slug is 100 characters and that slug is taken on the site
|
||||
When the user creates the article
|
||||
Then the slug is at most 100 characters and ends with "-2"
|
||||
|
||||
Scenario: The same title on another site keeps the plain slug
|
||||
Given the default site 1 has an article with slug "spring-release"
|
||||
And the site 2 has no such slug
|
||||
When the article is created on the site 2
|
||||
Then the slug is "spring-release"
|
||||
|
||||
Scenario: A new article is created on the default site, so uniqueness uses that site
|
||||
Given the default site 2 has an article with slug "spring-release"
|
||||
When the user creates "Spring release" without a site
|
||||
Then the slug is "spring-release-2"
|
||||
|
||||
Scenario: The same slug is allowed on two sites
|
||||
Given the article 8 on the site 1 has slug "spring"
|
||||
And the article 9 is on the site 2
|
||||
When the user sends PATCH /v1/article/9 with {"slug": "spring"}
|
||||
Then the status is 200
|
||||
|
||||
Scenario: The slug is editable at any time
|
||||
Given the article 8 is online with slug "spring-release"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring-2026"}
|
||||
Then the status is 200
|
||||
And the slug is "spring-2026" and the article is still online
|
||||
|
||||
Scenario: Keeping the own slug is not a collision
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring-release", "excerpt": "x"} and the slug is already that article's
|
||||
Then the status is 200
|
||||
|
||||
Scenario Outline: A taken slug is refused with a suggestion
|
||||
Given the site 1 holds the articles 8 and 9 and the slugs <taken> are used by other articles
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
|
||||
Then the status is 409
|
||||
And the body is "slug_taken"
|
||||
And the header X-Slug-Suggestion is "<suggestion>"
|
||||
And the article 8 is unchanged
|
||||
|
||||
Examples:
|
||||
| taken | suggestion |
|
||||
| "spring" | spring-2 |
|
||||
| "spring", "spring-2" | spring-3 |
|
||||
| "spring", "spring-2", "spring-3" | spring-4 |
|
||||
|
||||
Scenario: The suggestion fits the slug limit
|
||||
Given another article has a 100-character slug and the article 8 asks for it
|
||||
Then the 409 suggestion is at most 100 characters and ends with "-2"
|
||||
|
||||
Scenario: The suggestion ignores the article itself
|
||||
Given the article 8 has slug "spring-2" and the article 9 has "spring"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
|
||||
Then the status is 409 with suggestion "spring-2"
|
||||
|
||||
Scenario: The repository can report a race
|
||||
Given the repository reports a unique violation on (site_id, slug)
|
||||
When the user sends PATCH /v1/article/8 with a free-looking slug
|
||||
Then the status is 409 with body "slug_taken" and a suggestion header "<slug>-2"
|
||||
And the article is unchanged
|
||||
|
||||
Scenario: An empty slug regenerates it from the title
|
||||
Given the article 8 "Spring release" has slug "custom-address"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": ""}
|
||||
Then the status is 200
|
||||
And the slug is "spring-release"
|
||||
|
||||
Scenario: Regeneration follows the creation rule
|
||||
Given the article 8 titled "!!!" has slug "custom"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": ""}
|
||||
Then the slug is "article"
|
||||
|
||||
Scenario: Regenerating to the slug it already has is a no-op
|
||||
Given the article 8 "Spring release" already has slug "spring-release"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": ""}
|
||||
Then the status is 200 and no activity event is recorded
|
||||
|
||||
Scenario: Regeneration that collides is refused, never silently suffixed
|
||||
Given the article 9 on the same site has slug "spring-release" and the article 8 "Spring release" has slug "custom"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": ""}
|
||||
Then the status is 409
|
||||
And the body is "slug_taken"
|
||||
And the header X-Slug-Suggestion is "spring-release-2"
|
||||
And the slug of the article 8 is still "custom"
|
||||
|
||||
Scenario: Regeneration combined with a move uses the target site
|
||||
Given the article 8 "Spring release" is on the site 1 and the site 2 holds another article with slug "spring-release"
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": ""}
|
||||
Then the status is 409 with suggestion "spring-release-2"
|
||||
|
||||
Scenario: Moving to another site re-checks the slug on the target
|
||||
Given the article 8 on the site 1 has slug "spring" and the site 2 has an article with slug "spring"
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
||||
Then the status is 409
|
||||
And the body is "slug_taken" with the header X-Slug-Suggestion "spring-2"
|
||||
And the article 8 is still on the site 1 with slug "spring"
|
||||
|
||||
Scenario: Moving to a site where the slug is free keeps the slug
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2} and the slug is free on the site 2
|
||||
Then the status is 200
|
||||
And the slug is unchanged
|
||||
|
||||
Scenario: Moving and changing the slug together checks the new slug on the target
|
||||
Given the site 2 has an article with slug "spring" and none with "autumn"
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2, "slug": "autumn"}
|
||||
Then the status is 200
|
||||
When the user sends PATCH /v1/article/9 with {"site_id": 2, "slug": "spring"} on another article
|
||||
Then the status is 409 with suggestion "spring-2"
|
||||
|
||||
Scenario: A refused move changes no SEO field
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2, "excerpt": "new"} and the slug collides on the site 2
|
||||
Then the status is 409
|
||||
And the excerpt, the site_id and every other field of the article 8 are unchanged
|
||||
|
||||
Scenario: A freed slug can be reused
|
||||
Given the article 9 has slug "spring" and is deleted
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "spring"}
|
||||
Then the status is 200
|
||||
|
||||
Scenario: Publishing and unpublishing never change the slug
|
||||
When the article 8 is published then unpublished
|
||||
Then its slug is unchanged
|
||||
|
||||
Scenario: Slug uniqueness is enforced by the database
|
||||
When the repository contract suite runs
|
||||
Then a second article with the same (site_id, slug) is refused by every driver
|
||||
|
||||
Rule: Site SEO fields are edited by the owner only
|
||||
|
||||
Scenario: The owner sets the site SEO fields
|
||||
Given the site 2 "Shop"
|
||||
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
|
||||
Then the status is 200
|
||||
And the body is the site with those three values and its other fields unchanged
|
||||
|
||||
Scenario: A site created without the new fields has the defaults
|
||||
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
|
||||
Then the site has site_name "", title_template "%title% | %site%" and default_description ""
|
||||
|
||||
Scenario: A site is created with the new fields
|
||||
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}
|
||||
Then the status is 201
|
||||
And the body carries those three values
|
||||
And the site is stored with them
|
||||
|
||||
Scenario Outline: Creation validates the new fields like editing
|
||||
When the owner sends POST /v1/site/ with a valid site and <field> <value>
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And no site is added when the status is not 201
|
||||
|
||||
Examples:
|
||||
| field | value | status | body |
|
||||
| title_template | "%title%" | 201 | |
|
||||
| title_template | "just text" | 400 | invalid_title_template |
|
||||
| title_template | "%site%" | 400 | invalid_title_template |
|
||||
| title_template | "" | 400 | invalid_title_template |
|
||||
| title_template | 201 characters | 400 | the validator error text |
|
||||
| default_description | 320 characters | 201 | |
|
||||
| default_description | 321 characters | 400 | the validator error text |
|
||||
| site_name | 100 characters | 201 | |
|
||||
| site_name | 101 characters | 400 | the validator error text |
|
||||
|
||||
Scenario: The list carries the new fields
|
||||
When a verified account sends GET /v1/site/
|
||||
Then every site has site_name, title_template and default_description
|
||||
|
||||
Scenario: Absent fields are untouched
|
||||
Given the site 2 has a custom title_template and default_description
|
||||
When the owner sends PATCH /v1/site/2 with {"site_name": "Renew"}
|
||||
Then only site_name changed
|
||||
|
||||
Scenario Outline: The template must contain %title%
|
||||
When the owner sends PATCH /v1/site/2 with {"title_template": <template>}
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
|
||||
Examples:
|
||||
| template | status | body |
|
||||
| "%title%" | 200 | |
|
||||
| "%title% \| %site%" | 200 | |
|
||||
| "%site% - %title%" | 200 | |
|
||||
| "News: %title%" | 200 | |
|
||||
| "%site%" | 400 | invalid_title_template |
|
||||
| "just text" | 400 | invalid_title_template |
|
||||
| "%TITLE%" | 400 | invalid_title_template |
|
||||
| "" | 400 | invalid_title_template |
|
||||
| 201 characters | 400 | the validator error text |
|
||||
|
||||
Scenario Outline: Other site SEO fields are validated
|
||||
When the owner sends PATCH /v1/site/2 with <field> <value>
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
|
||||
Examples:
|
||||
| field | value | status | body |
|
||||
| default_description | 321 characters | 400 | the validator error text |
|
||||
| default_description | 320 characters | 200 | |
|
||||
| default_description | "" | 200 | |
|
||||
| site_name | 101 characters | 400 | the validator error text |
|
||||
| site_name | "" | 200 | |
|
||||
|
||||
Scenario: A refused site update changes no other field
|
||||
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "title_template": "nope"}
|
||||
Then the status is 400 with body "invalid_title_template"
|
||||
And the name of the site 2 is unchanged
|
||||
|
||||
Scenario: The existing site fields keep their names
|
||||
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5, "default_robots": "noindex"}
|
||||
Then the status is 200 and the site carries default_social_image_id 5 and default_robots "noindex"
|
||||
|
||||
Scenario Outline: Other roles and keys are refused
|
||||
When <request> is sent with <credentials>
|
||||
Then the status is 403
|
||||
And the body is "<body>"
|
||||
And no site changed
|
||||
|
||||
Examples:
|
||||
| request | credentials | body |
|
||||
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| PATCH /v1/site/2 | a verified account with another role | Only the owner can do this. |
|
||||
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
|
||||
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| POST /v1/site/ | a valid API key | This action needs a real session. |
|
||||
|
||||
Scenario: No credentials is unauthorised
|
||||
When PATCH /v1/site/2 is sent with no credentials
|
||||
Then the status is 401
|
||||
|
||||
Scenario: The role is checked before the body
|
||||
When an admin sends PATCH /v1/site/2 with the body "{not json"
|
||||
Then the status is 403 with body "Only the owner can do this."
|
||||
|
||||
Scenario: A no-op site edit records nothing
|
||||
When the owner sends PATCH /v1/site/2 with the SEO values it already has
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Rule: Existing articles are backfilled with a unique slug
|
||||
|
||||
Scenario: Every existing article gets a slug from its title
|
||||
Given a database from before this slice holding the articles "Spring release" (id 1) and "Autumn notes" (id 2) on the site 1
|
||||
When database.Migrate runs
|
||||
Then the articles have slugs "spring-release" and "autumn-notes"
|
||||
|
||||
Scenario: Conflicts get suffixes in id order within a site
|
||||
Given the site 1 holds the articles 1, 2 and 3 all titled "News"
|
||||
When database.Migrate runs
|
||||
Then their slugs are "news", "news-2" and "news-3" in id order
|
||||
|
||||
Scenario: A suffix skips slugs that exist
|
||||
Given the site 1 holds the articles 1 "News", 2 "News 2" and 3 "News"
|
||||
When database.Migrate runs
|
||||
Then every slug of the site 1 is distinct and the article 1 has "news"
|
||||
|
||||
Scenario: Slugs are unique per site, not globally
|
||||
Given the article 1 on the site 1 and the article 2 on the site 2 are both titled "News"
|
||||
When database.Migrate runs
|
||||
Then both slugs are "news"
|
||||
|
||||
Scenario Outline: A title with no letter or digit becomes article, suffixed on conflict
|
||||
Given the site 1 holds the articles 5 titled "<first>", 6 titled "<second>" and 7 titled "<third>"
|
||||
When database.Migrate runs
|
||||
Then their slugs are "article", "article-2" and "article-3" in id order
|
||||
And no slug is of the form "article-<id>" unless it comes from the suffix rule
|
||||
|
||||
Examples:
|
||||
| first | second | third |
|
||||
| !!! | ??? | ... |
|
||||
|
||||
Scenario: Long titles stay within 100 characters
|
||||
Given two articles on the same site whose titles give a slug longer than 100 characters
|
||||
When database.Migrate runs
|
||||
Then both slugs have at most 100 characters, are distinct and match the slug rule
|
||||
|
||||
Scenario: The migration is idempotent
|
||||
Given the migration already ran
|
||||
When database.Migrate runs again
|
||||
Then no slug changes
|
||||
|
||||
Scenario: An edited slug survives a restart
|
||||
Given the owner changed the slug of the article 1 to "custom"
|
||||
When database.Migrate runs again
|
||||
Then the article 1 keeps "custom"
|
||||
|
||||
Scenario: New site columns get defaults
|
||||
Given a database holding the sites from before this slice
|
||||
When database.Migrate runs
|
||||
Then every site has site_name "", title_template "%title% | %site%" and default_description ""
|
||||
|
||||
Scenario: Existing articles get neutral SEO defaults
|
||||
When database.Migrate runs on articles from before this slice
|
||||
Then excerpt, seo_title, seo_description, cover_image_alt, social_title, social_description and canonical_url are "", the image ids are null, noindex is false and seo_dismissed is []
|
||||
|
||||
Scenario: A database with a partially backfilled table completes
|
||||
Given some articles have a slug and some are empty
|
||||
When database.Migrate runs
|
||||
Then the empty ones get unique slugs and the existing ones are unchanged
|
||||
|
||||
Scenario: The migration creates the per-site unique index
|
||||
When database.Migrate runs
|
||||
Then a unique index on (site_id, slug) exists on articles
|
||||
|
||||
Scenario: It works on every database driver
|
||||
When the repository contract suite runs
|
||||
Then the article and site repositories behave the same on SQLite, MySQL and PostgreSQL
|
||||
And the SQLite schema test accounts for the new columns and the index
|
||||
|
||||
Rule: Cover, social and site images count as media usages
|
||||
|
||||
Scenario: A cover or social image counts as used
|
||||
Given the image 5 is the cover of the article 8 and appears in no article body, author or site
|
||||
When a verified account looks up the usage of the image 5
|
||||
Then the usage lists the article 8 and the image is not "Unused"
|
||||
|
||||
Scenario Outline: Each reference kind counts
|
||||
Given the image 5 is referenced only as <reference>
|
||||
When a verified account sends GET /v1/media/?filter=unused
|
||||
Then the image 5 is <listed>
|
||||
|
||||
Examples:
|
||||
| reference | listed |
|
||||
| the cover of an article | not in the list |
|
||||
| the social image of an article | not in the list |
|
||||
| the default social image of a site | not in the list |
|
||||
| the avatar of an author | not in the list |
|
||||
| a file name in an article body | not in the list |
|
||||
| nothing | in the list |
|
||||
|
||||
Scenario: An article that uses the image twice counts once
|
||||
Given the image 5 is both in the body and the cover of the article 8
|
||||
Then used_in of the image 5 is 1 for that article
|
||||
|
||||
Scenario: The media list sums every kind
|
||||
Given the image 5 is the cover of an article, the social image of another, in a third article body, the default social image of a site and the avatar of an author
|
||||
When a verified account sends GET /v1/media/
|
||||
Then the used_in of the image 5 is 5
|
||||
|
||||
Scenario: Usage lists articles, authors and sites
|
||||
Given the image 5 is in the body of the article 3, the cover of the article 8, the avatar of the author 2 and the default social image of the site 1
|
||||
When a verified account sends GET /v1/media/5/usage
|
||||
Then the status is 200
|
||||
And the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"]}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"]}, {"type": "author", "id": 2, "title": "<author name>", "fields": ["avatar"]} and {"type": "site", "id": 1, "title": "<site name>", "fields": ["default_social_image"]}
|
||||
And author and site items carry no published key
|
||||
|
||||
Scenario: The change is additive for existing consumers
|
||||
Given the image 5 is in the body of the article 3
|
||||
When a verified account sends GET /v1/media/5/usage
|
||||
Then the item still has id, title and published with the same values as before this slice
|
||||
And the only new keys are type and fields
|
||||
|
||||
Scenario: An article using an image in several places is one item with all fields
|
||||
Given the image 5 is the cover, the social image and in the body of the article 8
|
||||
When a verified account sends GET /v1/media/5/usage
|
||||
Then there is one item for the article 8 with fields ["body", "cover", "social"]
|
||||
|
||||
Scenario: An unused image has an empty usage array
|
||||
When a verified account sends GET /v1/media/9/usage on an unused image
|
||||
Then the status is 200 and the body is []
|
||||
|
||||
Scenario: An unknown image is not found
|
||||
When a verified account sends GET /v1/media/999/usage
|
||||
Then the status is 404 with the existing image-not-found body
|
||||
|
||||
Scenario: Offline articles are listed with published false
|
||||
Given the image 5 is the cover of an offline article
|
||||
When a verified account looks up the usage of the image 5
|
||||
Then the item has "published": false
|
||||
|
||||
Scenario: Deleting an image in use clears the references and is never refused
|
||||
Given the image 5 is the cover of the article 8 and the social image of the article 9, with cover_image_alt "A field"
|
||||
When the owner deletes the image 5
|
||||
Then the status is 204
|
||||
And the article 8 has cover_image_id null and the article 9 has social_image_id null
|
||||
And the cover_image_alt of the article 8 is unchanged
|
||||
And the article bodies are untouched
|
||||
And the avatar and site references to the image 5 are cleared as before
|
||||
|
||||
Scenario: A failed delete leaves every reference in place
|
||||
Given the repository fails while removing the image 5
|
||||
When the owner deletes the image 5
|
||||
Then the cover, social, avatar and site references to the image 5 are all unchanged
|
||||
|
||||
Scenario: Merging copies repoints article cover and social images
|
||||
Given the image 6 is a copy of the image 5 and the article 8 has the cover 6 and the article 9 the social image 6
|
||||
When the copies are merged into the image 5
|
||||
Then the article 8 has cover_image_id 5 and the article 9 has social_image_id 5
|
||||
And the merge response counts both articles in articles_updated
|
||||
And updated_article_ids lists both
|
||||
|
||||
Scenario: An article rewritten in body and cover counts once
|
||||
Given the article 8 has the copy 6 in its body and as its cover
|
||||
When the copies are merged into the image 5
|
||||
Then articles_updated is 1 and the article 8 has the image 5 in both places
|
||||
|
||||
Scenario: Purge repoints articles the same way
|
||||
Given a purge group whose kept image is 5 and whose duplicate 6 is the cover of the article 8
|
||||
When the purge runs
|
||||
Then the article 8 has cover_image_id 5
|
||||
And the purge result counts it in articles_updated
|
||||
|
||||
Scenario: A failed purge group leaves covers pointing at existing images
|
||||
Given the purge of a group fails midway
|
||||
Then every article cover_image_id and social_image_id refers to an image that still exists
|
||||
|
||||
Scenario: Authors and sites keep being repointed
|
||||
Given the duplicate 6 is used by the author 2 and the site 1
|
||||
When the copies are merged into the image 5
|
||||
Then authors_updated is 1 and sites_updated is 1
|
||||
|
||||
Scenario: Cover and social usage counts in the purge preview
|
||||
Given a duplicate group whose only usage is as article covers
|
||||
When the purge preview runs
|
||||
Then the kept image is the one in use, and used_in includes cover and social usages
|
||||
|
||||
Rule: SEO edits are recorded in the activity log without leaking the body
|
||||
|
||||
Scenario Outline: A short-value SEO edit is recorded once after the change
|
||||
Given the article 8 "Spring release" has <before>
|
||||
When the user sends PATCH /v1/article/8 with <body>
|
||||
Then one event exists with action "article.edit", category "articles", target {"type": "article", "id": "8", "name": "Spring release"}, outcome "success" and source "web"
|
||||
And its changes are [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
|
||||
And the event was recorded after the change was applied
|
||||
|
||||
Examples:
|
||||
| before | body | field | from | to |
|
||||
| slug "spring" | {"slug": "spring-2026"} | slug | spring | spring-2026 |
|
||||
| canonical_url "" | {"canonical_url": "https://www.example.com/a"} | canonical_url | (empty) | https://www.example.com/a |
|
||||
| noindex false | {"noindex": true} | noindex | false | true |
|
||||
| cover_image_id null | {"cover_image_id": 5} | cover_image | (empty) | the file name of the image 5 |
|
||||
| social_image_id 5 | {"social_image_id": null} | social_image | the file name of the image 5 | (empty) |
|
||||
|
||||
Scenario: A regenerated slug is recorded like an edited one
|
||||
Given the article 8 "Spring release" has slug "custom"
|
||||
When the user sends PATCH /v1/article/8 with {"slug": ""}
|
||||
Then one event "article.edit" exists with changes [{"field": "slug", "kind": "text", "before": "custom", "after": "spring-release"}]
|
||||
|
||||
Scenario Outline: Free-text SEO fields are recorded as a size change, never as text
|
||||
Given the article 8 has <field> of <before> characters
|
||||
When the user sends PATCH /v1/article/8 with <field> of <after> characters
|
||||
Then one event "article.edit" exists with changes [{"field": "<field>", "kind": "body", "delta": <delta>}]
|
||||
And no stored field and no /v1/activity response contains any text of either value
|
||||
|
||||
Examples:
|
||||
| field | before | after | delta |
|
||||
| excerpt | 0 | 120 | 120 |
|
||||
| seo_title | 40 | 55 | 15 |
|
||||
| seo_description | 160 | 100 | -60 |
|
||||
| social_title | 0 | 30 | 30 |
|
||||
| cover_image_alt | 0 | 16 | 16 |
|
||||
|
||||
Scenario: Same-length free text with different content is still a change
|
||||
Given the article 8 has seo_title "AAAAA"
|
||||
When the user sends PATCH /v1/article/8 with {"seo_title": "BBBBB"}
|
||||
Then one event "article.edit" exists with the change on seo_title and delta 0
|
||||
And no event or response contains "AAAAA" or "BBBBB"
|
||||
|
||||
Scenario: A body and SEO edit together record both
|
||||
When the user sends PATCH /v1/article/8 with {"body": "New body text", "slug": "new"}
|
||||
Then one event "article.edit" exists with changes for body (kind "body", delta) and slug (kind "text")
|
||||
And the event holds no text of the body
|
||||
|
||||
Scenario: Dismissing suggestions is not an edit
|
||||
When the user sends PATCH /v1/article/8 with {"seo_dismissed": ["short"]}
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Scenario: A no-op SEO edit records nothing
|
||||
When the user sends PATCH /v1/article/8 with the SEO values it already has
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Scenario: A refused slug is recorded as failed with a plain reason
|
||||
When the user sends PATCH /v1/article/8 with a taken slug
|
||||
Then one event exists with action "article.edit", outcome "failed" and failure_reason "That slug is already used by another article on this site."
|
||||
|
||||
Scenario Outline: Nothing is recorded when there is nothing to attribute
|
||||
When <request> is sent
|
||||
Then no activity event is recorded
|
||||
|
||||
Examples:
|
||||
| request |
|
||||
| PATCH /v1/article/999 with an SEO field |
|
||||
| PATCH /v1/article/abc with an SEO field |
|
||||
| PATCH /v1/article/8 with {"slug": "Bad Slug"} |
|
||||
| PATCH /v1/article/8 with a 301-character excerpt |
|
||||
| PATCH /v1/article/8 with an unknown cover image |
|
||||
| PATCH /v1/article/8 with no credentials (401) |
|
||||
|
||||
Scenario: A failing audit repository never changes the response
|
||||
Given the audit repository fails on Append
|
||||
When the user sends PATCH /v1/article/8 with {"slug": "new"}
|
||||
Then the response equals that of a healthy repository
|
||||
And the slug was changed
|
||||
|
||||
Scenario Outline: A site SEO edit is recorded
|
||||
When the owner sends PATCH /v1/site/2 with <body>
|
||||
Then one event "site.edit" exists with category "sites" and changes [{"field": "<field>", "kind": "text", "before": "<from>", "after": "<to>"}]
|
||||
|
||||
Examples:
|
||||
| body | field | from | to |
|
||||
| {"site_name": "Renew"} | site_name | (empty) | Renew |
|
||||
| {"title_template": "%title% - %site%"} | title_template | %title% \| %site% | %title% - %site% |
|
||||
| {"default_description": "A shop."} | default_description | (empty) | A shop. |
|
||||
|
||||
Scenario: The activity API lists the SEO events
|
||||
Given article and site SEO events were recorded
|
||||
When a verified account sends GET /v1/activity/?category=articles
|
||||
Then the article.edit events are returned
|
||||
|
||||
Rule: Layering and documentation
|
||||
|
||||
Scenario: Layering is respected
|
||||
Then use cases import no persistence, mailer, models, gorm, net/http or chi
|
||||
And the article and site domain packages import no GORM or HTTP
|
||||
And handlers import no persistence
|
||||
And only api/dependecyInjection.go builds the repositories and use cases, including the widened GetMediaUsageUseCase
|
||||
|
||||
Scenario: The docs describe the new surface
|
||||
Then docs/api.md documents the new article and site fields, the 409 slug_taken response with its X-Slug-Suggestion header, the empty-slug regeneration, the new error tokens, the widened GET /v1/media/{id}/usage items and the new used_in sources
|
||||
And docs/api/articles.md documents the twelve article fields, their limits and the per-site slug rule
|
||||
And docs/architecture.md mentions the per-site slug uniqueness, the backfill and the media usage sources
|
||||
And docs/release-notes.md announces the change, including the additive usage response keys
|
||||
And web/src/data/apiCatalogue.ts lists the new PATCH request fields and its spec checks them
|
||||
@@ -0,0 +1,91 @@
|
||||
# QA procedure: SEO data backend (slice 1 of 4)
|
||||
|
||||
Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header.
|
||||
|
||||
## Divergences from docs/design
|
||||
|
||||
`docs/design` is read-only; the SPA slices (3 and 4) align to the code, not to these design words.
|
||||
|
||||
- **Site image and robots names:** the API uses `default_social_image_id` and `default_robots` (existing site contract). The design writes `social_image_id` for the site and, in places, `robots`. The SPA uses the code names.
|
||||
- **Non-owner site writes:** the API answers 403 with the existing text "Only the owner can do this." (`apperr.OwnerRequiredErrorMessage`). The design mentions a 403 `owner_only` token, which does not exist.
|
||||
- **Empty-slug fallback:** one rule everywhere. A title with no letter or digit becomes `article`, then `article-2`, `article-3` on conflict, in the API and in the migration. `docs/design/seo-slices.md` (decision 5) says `article-<id>` for the migration; that wording is not followed.
|
||||
- **Slug conflict transport:** the conflict follows the authors slug conflict (plain text `slug_taken`, 409); the suggestion travels in the `X-Slug-Suggestion` response header, not in a JSON body. The `slug.suggestion` of the slice 2 check endpoint is separate.
|
||||
- **Empty slug:** `slug: ""` regenerates the slug from the title (the SPA "Reset to match the title" sends `""`); 409 only if the regenerated slug belongs to another article on the site.
|
||||
- **Status codes:** `invalid_url` is 422 (authors); `invalid_slug`, `invalid_title_template` and `image_not_found` are 400.
|
||||
- **Usage response:** `GET /media/{id}/usage` stays an array; items gain `type` and `fields`; authors and sites are listed; non-article items have no `published`.
|
||||
|
||||
## Setup
|
||||
|
||||
- New files `test/seo_data_*_test.go`. Fakes have unique names: `mockSeoArticleRepo` (articles with per-site unique slug, unique-violation injection on (site_id, slug), `FindByBodyContaining`, cover and social references, repoint helpers), `mockSeoSiteRepo`, `mockSeoAuthorRepo`, `mockSeoImageRepo` (images, delete, merge and purge repointing, failure injection), and a fake audit repository with fail and panic switches (reuse).
|
||||
- Routers mirror `api/router.go`: article, site, media (including `/{id}/usage`) and activity routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Signed `jwt` cookies with the `user_id` claim for: owner, admin, a verified account whose role is `"editor"`, an unverified account. An API-key request uses the key authentication path.
|
||||
- Helpers create articles, sites, images, authors and references directly in the fakes. Assertions read the fakes, never only the JSON.
|
||||
|
||||
## A. Article PATCH, SEO fields
|
||||
|
||||
1. Set all twelve fields: 200, body and stored values equal, unrelated fields unchanged.
|
||||
2. Absent fields untouched; clear table (`""`, `false`, `[]`, `null` on the two image ids).
|
||||
3. Boundary table: 300, 120, 320, 200, 120, 320, slug 100, canonical 2048; ASCII and multi-byte counted in runes; a canonical URL with query and fragment.
|
||||
4. Validation table with exact status and token: over-limit fields give 400 validator text; slug cases (`Spring`, `spring_2026`, `-spring`, `spring--2026`, 101 chars) give 400 `invalid_slug`; canonical non-https, relative, no host give 422 `invalid_url`; unknown dismissed code gives 400 validator text; unknown image ids give 400 `image_not_found`; image id `0` gives the validator text. Article unchanged each time.
|
||||
5. Null and wrong-type table (`{}`, unknown, `null` on slug, excerpt, noindex and `seo_dismissed`, wrong types) give 400 `apperr.BodyErrorMessage`; `null` on the two image ids gives 200.
|
||||
6. Duplicate dismissed codes collapse, order kept; all 11 codes accepted. A refused update changes no other field. Combined PATCH of body, site, authors and SEO.
|
||||
7. Callers: owner, admin, "editor" and API key give 200; unverified gives 403; none gives 401. Missing article gives 404 "Article not found"; `abc` gives `apperr.IdUint32ErrorMessage`.
|
||||
8. `noindex` leaves `is_online` unchanged. `cover_image_alt` is accepted without a cover.
|
||||
|
||||
## B. Article reads
|
||||
|
||||
9. GET one, PATCH, publish and unpublish carry the twelve fields with stored values; POST returns the generated slug and the other defaults.
|
||||
10. List items carry `excerpt` and `slug` and none of the other ten new fields (key absence check); `total`, `page`, `size` and `counts` unchanged. Anonymous and unverified readers get online articles only, and an offline article is 404 "Article not found".
|
||||
|
||||
## C. Slug
|
||||
|
||||
11. Generation table (accents, punctuation runs, `!!!` gives `article`, 100-character cap with no trailing hyphen).
|
||||
12. Creation de-duplication: `spring-release` and `-2` taken gives `-3`; `article` taken gives `article-2`; suffix fits 100; same title on another site keeps the plain slug; default-site uniqueness.
|
||||
13. Same slug on two sites via PATCH is 200. Editable when online. Own slug re-sent is 200.
|
||||
14. 409 table: `spring`, `spring-2`, `spring-3` taken give header suggestions `spring-2`, `-3`, `-4`; body is exactly `slug_taken`; 100-character suggestion fits; the suggestion ignores the article itself; repository unique-violation injection gives the same 409; the article is unchanged.
|
||||
15. Empty slug: regenerates from the title (200); follows the creation rule (`!!!` gives `article`); already equal is a 200 with no event; collision gives 409 `slug_taken` with the suggestion header and leaves the slug unchanged (never silently suffixed); combined with a move uses the target site.
|
||||
16. Move table: collision on the target gives 409 with suggestion and leaves site and slug unchanged; free slug moves with the slug kept; move plus new slug checks the new slug on the target; a refused move changes no other field.
|
||||
17. Delete frees the slug. Publish and unpublish keep it.
|
||||
|
||||
## D. Site POST and PATCH
|
||||
|
||||
18. Owner sets the three fields on PATCH (200, others unchanged); absent fields untouched; `GET /v1/site/` and POST responses carry them; defaults `""`, `"%title% | %site%"`, `""` when omitted on POST.
|
||||
19. POST with the three fields: 201 and stored; creation validation table equals the PATCH tables.
|
||||
20. Template table: valid templates give 200; `%site%`, `just text`, `%TITLE%`, empty give 400 `invalid_title_template`; 201 characters gives the validator text. Description 320 and `""` give 200, 321 gives the validator text; site_name 100 gives 200, 101 gives the validator text, `""` gives 200.
|
||||
21. A refused PATCH leaves the other fields unchanged. `default_social_image_id` and `default_robots` still work.
|
||||
22. Refusal table: admin and "editor" get 403 "Only the owner can do this." on PATCH and POST; API key gets 403 "This action needs a real session."; none gives 401; malformed JSON from an admin still gets the 403. A no-op edit is 200 with no event.
|
||||
|
||||
## E. Migration
|
||||
|
||||
23. Repository-contract harness (temp-dir SQLite; MySQL and Postgres opt-in through the existing env switches). `database.Migrate` backfill table: distinct titles; collisions in id order (`news`, `news-2`, `news-3`); suffix skipping existing slugs; per-site scope; titles `!!!`, `???`, `...` on one site give `article`, `article-2`, `article-3`; long titles stay within 100 and are distinct.
|
||||
24. Idempotence: a second run changes nothing; an edited slug survives; a partially backfilled table completes without touching existing slugs.
|
||||
25. New site columns default (`""`, `"%title% | %site%"`, `""`, no backfill of `site_name`); neutral article defaults; the unique index on (site_id, slug) exists and a duplicate insert is refused on every driver. `test/sqlite_schema_unchanged_test.go` is updated for the new columns and index.
|
||||
26. Contract suites for the article and site repositories cover every new column, round-trip of `seo_dismissed`, null image ids and the per-site uniqueness.
|
||||
|
||||
## F. Media usage
|
||||
|
||||
27. Usage lookup: the item table for body, cover, social, avatar and site image, each with its `type`, `id`, `title` and `fields`; one item per article with merged `fields`; `published` present for articles (true or false) and absent for authors and sites; the article item keeps `id`, `title` and `published` with their previous values and the only new keys are `type` and `fields` (additive check); empty array when unused; unknown image gives the existing 404.
|
||||
28. `used_in` and the `unused` filter count cover, social, site and avatar once per article, author and site; an article using the image in body and cover counts once.
|
||||
29. Delete: never refused; references are nulled, `cover_image_alt` and article bodies are untouched, avatars and sites cleared as before, and a failed removal rolls back every reference.
|
||||
30. Merge: articles' cover and social ids repointed; `articles_updated` counts each article once even if body and cover both changed; `updated_article_ids` lists them; `authors_updated` and `sites_updated` still correct. Purge does the same, a failed group leaves every reference on an existing image, and the preview treats cover and social as usage.
|
||||
|
||||
## G. Activity
|
||||
|
||||
31. Short-value table: slug (including a regenerated one), canonical_url, noindex, cover_image and social_image give one `article.edit` with `kind: "text"` and `before`/`after`, recorded after the change.
|
||||
32. Free-text table (excerpt, seo_title, seo_description, social_title, cover_image_alt, and social_description) gives `kind: "body"` and `delta` in characters; the stored event and the `/v1/activity/` response contain none of the old or new text (substring check), including the same-length edit. Body plus SEO edit records both with no body text.
|
||||
33. `seo_dismissed`-only and no-op edits record nothing. A taken slug records a failed event with the plain reason. No event for 404, malformed id, validation refusals, 401. A failing and a panicking audit repository change neither response nor data.
|
||||
34. Site edits record `site.edit` with before and after for `site_name`, `title_template` and `default_description`. `GET /v1/activity/?category=articles` returns the events. `activity-log-producers.feature` carries the matching rule.
|
||||
|
||||
## H. Quality gates
|
||||
|
||||
35. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the article and site domain packages import no GORM or HTTP; handlers import no persistence; `api/dependecyInjection.go` wires the widened `GetMediaUsageUseCase`; validation goes through validate tags and `validator.Validate.Struct`; no comments added.
|
||||
36. Scoped mutation run through `task mutate` only, on the new and changed use cases (article create and update, site create and update, media usage, delete, merge, purge and list, and the slug helper). Example: `MUTATE_FILES=<those file names> MUTATE_RUN='TestSeoData' MUTATE_WORKERS=2 task mutate -- <use case package path>`. Report LIVED and NOT COVERED mutants and re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
||||
37. Web: `web/src/data/__tests__/apiCatalogue.spec.ts` is updated for the new article and site request fields and runs under `task test:web`. Report NOT RUN if the runner is not installed.
|
||||
38. Docs, checked by reading: `docs/api.md`, `docs/api/articles.md`, `docs/architecture.md`, `docs/release-notes.md` as listed in the feature.
|
||||
|
||||
## Manual
|
||||
|
||||
- MySQL and Postgres: run the contract suites with the opt-in env vars against real servers.
|
||||
- Real boot on a copy of a pre-slice database: all articles get distinct slugs per site, nothing else changes, the second boot changes nothing.
|
||||
- Docker with the `/data` volume: same boot check.
|
||||
- Read the activity log screen and the CSV export: SEO events show field names and no free text.
|
||||
- Not covered here: the SPA (slices 3 and 4) and the resolver, check endpoint and sitemap (slice 2).
|
||||
@@ -562,9 +562,10 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa
|
||||
Given the purge of a group fails midway
|
||||
Then every site default_social_image_id refers to an image that still exists
|
||||
|
||||
Scenario: The usage lookup array shape is unchanged
|
||||
Scenario: The usage lookup stays an array and later gains additive keys
|
||||
When a verified account looks up the usage of an image
|
||||
Then the response keeps the same array shape as before this slice
|
||||
Then the response is a JSON array whose article items keep id, title and published
|
||||
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
|
||||
|
||||
Rule: Every site write is recorded in the activity log
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test
|
||||
|
||||
## H. Media
|
||||
|
||||
38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused".
|
||||
38. Usage lookup counts a site's default social image, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), image not "Unused".
|
||||
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
|
||||
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
|
||||
|
||||
|
||||
Reference in new issue
Block a user