feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ Feature: Integration screen (API reference)
| Media | 11 | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge |
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
| Settings | 1 | GET /v1/settings |
And the catalogue also lists, as public requests, GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}, and documents the new keys of GET /v1/article/{id}/seo, as in features/localization_public_api_and_surfaces.feature (the counts above grow by those two public routes)
And the "Session only" group has 37 non-expandable rows in 6 sub-groups (the full list is in features/integration_api_key_routes.feature); the first three are:
| sub-group | rows |
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
+2 -1
View File
@@ -25,7 +25,8 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
created_at, used_in, duplicate;
- media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor;
- merge: articles_updated, merged;
- usage: id, title, published;
- usage: type, id, title, published, fields, story_id, lang;
- the two public language routes and the new keys of GET /v1/article/{id}/seo are in the catalogue (features/localization_public_api_and_surfaces.qa.md, item 9);
- user list item: id, kind, username, display_name, email, role, status, created_at;
- invite: id, kind, email, status, created_at, expires_at;
- settings: version.
@@ -0,0 +1,539 @@
Feature: Localized public API, SEO output and the existing surfaces that show languages
A visitor or a frontend reads one published version by site, language and slug, or
resolves an article group (story) with an explicit fallback, and gets the other
published languages as alternates. The resolved SEO object, the sitemap and the
back-office surfaces that already show articles become language aware. Nothing
falls back silently, no draft is ever returned and no hidden sibling is named.
Background:
Given the design of record is docs/design/localization.md (Public lookup and URLs, Locale and SEO corrections), docs/design/localization-tables.md, docs/design/screens/localization-metadata-api.md (L10) and docs/design/screens/localization-surfaces.md (L12)
And the language rules, slug uniqueness per (site, lang) and unique (story_id, lang) of features/article_localization_storage.feature apply
And the site languages, default and social locale of features/site_languages.feature and the public_url_pattern of features/site_public_url_pattern.feature already exist
And error responses are plain text whose body is the snake_case code, or the existing apperr text where the route already used one
And a version is "published" when is_online is true, and "eligible" when it is published and its language is enabled on the site
And the public routes are open to everyone: a jwt cookie or API key is ignored, so a verified user never gets a draft from them
And handlers reach articles, sites and languages only through use cases and domain interfaces, validation goes through validate tags and validator.Validate.Struct, and no comment is added anywhere
And site "journal" (id 1, key "journal", base_url "https://www.journal.example", Locale "en-GB", default_description "Renovation notes.", public_url_pattern "https://journal.example.com/{lang}/{slug}") has these languages
| lang | default | enabled | social_locale |
| en | true | true | null |
| fr | false | true | fr_FR |
| de | false | true | null |
| ar | false | true | null |
| es | false | false | null |
And story 5 of journal holds these versions
| id | lang | slug | published | title |
| 10 | en | how-we-price-a-bathroom-renovation | yes | How we price a bathroom renovation |
| 12 | fr | comment-nous-chiffrons-une-renovation | yes | Comment nous chiffrons une rénovation |
| 11 | de | so-kalkulieren-wir-eine-badsanierung | yes | So kalkulieren wir eine Badsanierung |
| 13 | ar | takalif | no | Takalif |
| 14 | es | cuanto-cuesta | yes | Cuánto cuesta |
And story 6 of journal holds only the published en version 20 with slug "spring"
And story 7 of journal holds only the unpublished en version 21 with slug "winter"
And article 30 of journal is unassigned (lang "") and published with slug "legacy-note"
Rule: Exact lookup by site, language and slug never falls back
Scenario: A published version is returned with its alternates
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 200
And the body is the JSON of GET /v1/article/12 for an anonymous caller, with the same keys and values (id 12, lang "fr", story_id 5, slug, is_online true)
And the body has one more key "alternates", an array of the other eligible versions of story 5
And alternates is ordered default language first, then by tag: en (10), de (11)
And each alternate has exactly the keys id, lang, story_id, slug and url
And the url of the en alternate is "https://journal.example.com/en/how-we-price-a-bathroom-renovation"
Scenario: Alternates leave out the served version, drafts and disabled languages
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation
Then alternates lists fr (12) and de (11)
And it does not list ar (13, unpublished) nor es (14, language disabled)
And no key or message anywhere in the response mentions version 13
Scenario: A disabled language stays readable by exact lookup
When an anonymous client sends GET /v1/public/site/journal/lang/es/article/cuanto-cuesta
Then the status is 200 and lang is "es"
And alternates lists en, fr and de
Scenario: A story with one version has an empty alternates array
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
Then the status is 200 and alternates is []
Scenario Outline: The lookup matches site, language and slug only
When an anonymous client sends GET /v1/public/site/<key>/lang/<lang>/article/<slug>
Then the status is <status> and the body is "<body>"
Examples:
| key | lang | slug | status | body |
| journal | de | comment-nous-chiffrons-une-renovation | 404 | article_not_found |
| journal | fr | how-we-price-a-bathroom-renovation | 404 | article_not_found |
| journal | ar | takalif | 404 | article_not_found |
| journal | en | winter | 404 | article_not_found |
| journal | en | nope | 404 | article_not_found |
| journal | FR | comment-nous-chiffrons-une-renovation | 200 | |
| journal | pt-br | spring | 404 | article_not_found |
| journal | xx | spring | 404 | article_not_found |
| journal | e | spring | 400 | invalid_language |
| journal | pt_BR | spring | 400 | invalid_language |
| nowhere | fr | comment-nous-chiffrons-une-renovation | 404 | site_not_found |
Scenario: The same slug in another site or language is a different page
Given site "atelier" holds a published fr version with slug "comment-nous-chiffrons-une-renovation"
When an anonymous client sends GET /v1/public/site/atelier/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 200 and site_id is atelier's
And the same request on journal still answers version 12
Scenario: An unassigned article has no language route
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/legacy-note
Then the status is 404 and the body is "article_not_found"
And GET /v1/article/30 still answers 200 as before
Scenario: A draft never answers, even to its owner
Given the owner has a valid jwt cookie
When the client sends GET /v1/public/site/journal/lang/en/article/winter with that cookie
Then the status is 404 and the body is "article_not_found"
Scenario: Unpublishing removes the version at once
Given version 12 is unpublished
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 404 and the body is "article_not_found"
And version 12 is no longer in the alternates of version 10
Scenario: A changed slug breaks the old address and creates no redirect
Given the slug of version 12 is changed to "nouveau-titre"
When an anonymous client sends GET /v1/public/site/journal/lang/fr/article/comment-nous-chiffrons-une-renovation
Then the status is 404 and the body is "article_not_found"
And GET /v1/public/site/journal/lang/fr/article/nouveau-titre answers 200
Scenario: The body format parameter behaves as on GET /v1/article/{id}
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring?format=json
Then body_format is "json"
And ?format=pdf answers 400 with the existing body format message
Scenario: A repository failure answers 500
Given the article repository fails on its next call
When an anonymous client sends GET /v1/public/site/journal/lang/en/article/spring
Then the status is 500 and the body is "Internal server error"
Rule: Story resolution falls back only when asked and only to the default language
Scenario: The requested language is published
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
Then the status is 200 and the article is version 12
And lang is "fr", requested_lang is "fr" and fallback is false
And alternates lists en and de
Scenario: The requested language is missing and no fallback is asked
Given story 5 has no published fr version
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr
Then the status is 404 and the body is "language_not_available"
Scenario: The requested language is missing and the default is the fallback
Given story 5 has no published fr version
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the status is 200 and the article is version 10
And lang is "en", requested_lang is "fr" and fallback is true
Scenario: The requested language is unpublished, not only missing
Given version 12 is unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the article is version 10 and fallback is true
Scenario: Neither the requested language nor the default is published
Given versions 12 and 10 are unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=fr&fallback=default
Then the status is 404 and the body is "language_not_available"
And version 11 (de, published) is not returned: no third language is tried
Scenario: The default language itself is requested and unpublished
Given version 10 is unpublished
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=en&fallback=default
Then the status is 404 and the body is "language_not_available"
Scenario: No language means the site default language
When an anonymous client sends GET /v1/public/site/journal/story/5
Then the article is version 10, lang is "en", requested_lang is "en" and fallback is false
Scenario: Changing the default changes only requests that omit the language
Given the owner makes fr the default language of journal
When an anonymous client sends GET /v1/public/site/journal/story/5
Then the article is version 12 and requested_lang is "fr"
And GET /v1/public/site/journal/story/5?lang=en still answers version 10
And GET /v1/public/site/journal/lang/en/article/how-we-price-a-bathroom-renovation still answers version 10
And GET /v1/article/10 still answers version 10
Scenario: A story of a site with no confirmed default cannot be resolved without a language
Given site "notes" has no confirmed default language
When an anonymous client sends GET /v1/public/site/notes/story/<story>
Then the status is 404 and the body is "language_not_available"
And the same request with an explicit lang that matches a published version answers 200
Scenario: A disabled language is served when asked explicitly
When an anonymous client sends GET /v1/public/site/journal/story/5?lang=es
Then the status is 200 and lang is "es"
Scenario Outline: Refused and unknown requests
When an anonymous client sends GET /v1/public/site/<key>/story/<story>?<query>
Then the status is <status> and the body is "<body>"
Examples:
| key | story | query | status | body |
| journal | 6 | lang=fr | 404 | language_not_available |
| journal | 7 | lang=en | 404 | language_not_available |
| journal | 999 | lang=en | 404 | language_not_available |
| journal | 5 | lang=fr&fallback=latest | 400 | apperr.BodyErrorMessage |
| journal | 5 | lang=fr&fallback= | 400 | apperr.BodyErrorMessage |
| journal | 5 | lang=english | 400 | invalid_language |
| journal | abc | lang=en | 400 | apperr.IdUint32ErrorMessage |
| nowhere | 5 | lang=en | 404 | site_not_found |
Scenario: A story of another site is not found
When an anonymous client sends GET /v1/public/site/atelier/story/5?lang=en
Then the status is 404 and the body is "language_not_available"
Scenario: The response carries no more than a direct read would
When an anonymous client resolves story 5 with lang=fr
Then the keys are those of the exact lookup plus requested_lang and fallback
And created_by, capabilities and every draft or sibling detail are absent as on an anonymous GET /v1/article/{id}
Scenario: Existing article routes are unchanged
Then GET /v1/article/{id}, GET /v1/article/ and PATCH /v1/article/{id}/publish keep their statuses, bodies and access rules
Rule: The resolved SEO object is language aware
Scenario: The shape grows by additive keys
When an anonymous caller sends GET /v1/article/12/seo
Then the body has the keys of features/seo_output_backend.feature plus "lang" and "alternates"
And og has the keys of that feature plus "locale_alternates"
And fallbacks has the keys of that feature plus "site_text_skipped"
And lang is "fr" and alternates has the shape and the order of the public alternates
Scenario: An unassigned article keeps the legacy values
When an anonymous caller sends GET /v1/article/30/seo
Then lang is "" and alternates is [] and og.locale_alternates is []
And canonical_url is "https://www.journal.example/legacy-note" because {lang} cannot be filled, whatever the pattern
And og.locale is the raw Site.Locale "en-GB" as before this slice
Scenario Outline: The canonical is the localized page unless overridden
Given journal's public_url_pattern is <pattern>
And the version <id> has canonical_url "<override>"
When an anonymous caller sends GET /v1/article/<id>/seo
Then canonical_url and og.url are "<canonical>" and fallbacks.canonical is "<source>"
And json_ld.mainEntityOfPage.@id equals canonical_url
Examples:
| pattern | id | override | canonical | source |
| https://journal.example.com/{lang}/{slug} | 12 | | https://journal.example.com/fr/comment-nous-chiffrons-une-renovation | pattern |
| https://journal.example.com/{lang}/articles/{slug} | 11 | | https://journal.example.com/de/articles/so-kalkulieren-wir-eine-badsanierung | pattern |
| https://journal.example.com/{lang}/{slug} | 12 | https://other.example/a | https://other.example/a | canonical_url |
| null | 12 | | https://www.journal.example/comment-nous-chiffrons-une-renovation | site |
| https://journal.example.com/{slug} | 12 | | https://journal.example.com/comment-nous-chiffrons-une-renovation | pattern |
Scenario: A region tag is kept in the address
Given a version in pt-BR with slug "reforma" on a site whose pattern holds {lang}
Then its canonical contains "/pt-BR/reforma"
Scenario: Saving the pattern now changes canonical output
Given journal has no pattern and the canonical of version 12 is "https://www.journal.example/comment-nous-chiffrons-une-renovation"
When an owner sets the pattern "https://journal.example.com/{lang}/{slug}"
Then the canonical of version 12 becomes "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
And a canonical override on any version is unchanged
Scenario Outline: og:locale is derived in a fixed order and never invented
Given the site has Locale "<site_locale>", the language row "<lang>" has social_locale <explicit>
When an anonymous caller sends GET /v1/article/<id>/seo for a published version in <lang>
Then og.locale is "<og_locale>"
Examples:
| site_locale | lang | explicit | id | og_locale | rule |
| en-GB | fr | "fr_FR" | 12 | fr_FR | 1 explicit social locale |
| en-GB | fr | "fr_CA" | 12 | fr_CA | explicit wins over every other rule |
| en-GB | pt-BR | null | 40 | pt_BR | 2 the tag already has a region |
| en-GB | en | null | 10 | en_GB | 3 Site.Locale has the same language part |
| fr-CA | fr | null | 12 | fr_CA | 3 |
| en-GB | de | null | 11 | | 4 nothing resolves, the value is omitted |
| en | en | null | 10 | | 4 a bare Site.Locale has no region to borrow |
| null | de | null | 11 | | 4 |
| en-GB | fr-CA | "fr_FR" | 41 | fr_FR | explicit wins over the tag's region |
Scenario: An unresolved og:locale is an empty string and its alternates are left out
Given the version 11 (de) resolves no social locale
When an anonymous caller sends GET /v1/article/11/seo
Then og.locale is "" and og.locale_alternates is []
Scenario: og:locale:alternate lists the other eligible versions that resolve
When an anonymous caller sends GET /v1/article/10/seo
Then og.locale is "en_GB"
And og.locale_alternates is ["fr_FR"]
And de is left out because it resolves no social locale, ar and es because they are not eligible
Scenario Outline: SEO fields fall back in the language of the version
Given version 12 (fr) has seo_title "<seo_title>", seo_description "<seo_description>", excerpt "<excerpt>" and a body whose first sentence is "<sentence>"
And journal's default_description is "Renovation notes." written in the default language "en"
When an anonymous caller sends GET /v1/article/12/seo
Then title is "<title>" and fallbacks.title is "<title_source>"
And description is "<description>" and fallbacks.description is "<description_source>"
And fallbacks.site_text_skipped is <skipped>
Examples:
| seo_title | seo_description | excerpt | sentence | title | title_source | description | description_source | skipped |
| Mon titre | Ma description. | Un résumé. | Première. | Mon titre \| Journal | seo_title | Ma description. | seo_description | false |
| | | Un résumé. | Première. | Comment nous chiffrons une rénovation \| Journal | title | Un résumé. | excerpt | false |
| | | | Première. | Comment nous chiffrons une rénovation \| Journal | title | Première. | body | false |
| | | | | Comment nous chiffrons une rénovation \| Journal | title | | none | true |
Scenario Outline: Site default text applies only to articles in its own language
Given journal's default_description is "Renovation notes." and the version has no description, excerpt or body sentence
When an anonymous caller sends GET /v1/article/<id>/seo for the version in <lang>
Then description is "<description>" and fallbacks.description is "<source>"
And fallbacks.site_text_skipped is <skipped>
Examples:
| id | lang | description | source | skipped |
| 10 | en | Renovation notes. | site | false |
| 12 | fr | | none | true |
| 30 | (none) | Renovation notes. | site | false |
Scenario: A site without a default description withholds nothing
Given journal has no default_description
When an anonymous caller sends GET /v1/article/12/seo
Then fallbacks.description is "none" and fallbacks.site_text_skipped is false
Scenario: Changing the default language changes which articles may use the site text
Given the owner makes fr the default language of journal
When an anonymous caller sends GET /v1/article/10/seo for the en version without description
Then fallbacks.site_text_skipped is true
And this is the documented limitation of treating the site text as written in the current default language
Scenario: Brand text and images stay shared
Then title_template and site_name apply to every language
And the image chain (social, cover, site default image) is unchanged and each version's cover_image_alt stays its own
Scenario: The SEO read stays idempotent and public
Then the visibility rules, 404 for a draft to a non-verified caller and 400 for a malformed id of features/seo_output_backend.feature are unchanged
And no repository write and no activity event happen
Rule: The sitemap lists every eligible version at its localized address
Scenario: Each eligible version is listed once
When an anonymous caller sends GET /v1/site/journal/sitemap
Then the items are the published versions of journal in enabled languages
And each item has the keys url, modified_at and noindex plus "lang"
And the url of version 12 is "https://journal.example.com/fr/comment-nous-chiffrons-une-renovation"
And the versions 14 (es, disabled), 13 and 21 (drafts) are absent
And X-Total-Count counts the listed items
Scenario: Without a pattern the legacy address is kept
Given journal has no pattern
When an anonymous caller sends GET /v1/site/journal/sitemap
Then the url of version 20 is "https://www.journal.example/spring" as before this slice
Rule: Media usage names the language and counts a story once
Background:
Given image 7 "bathroom.jpg" is the cover of versions 10 (en), 12 (fr) and 11 (de) of story 5, and is in the body of version 12
And image 7 is also the body image of article 20 (story 6, en) and of unassigned article 30
And image 7 is the default_social_image of site journal and the avatar of author 3
Scenario: Article usage items carry lang and story_id
When the client sends GET /v1/media/7/usage with a valid cookie
Then every item of type "article" has the existing keys type, id, title, published and fields plus "story_id" and "lang"
And there is one article item per version, ordered by id: 10, 11, 12, 20, 30
And item 12 is {"type": "article", "id": 12, "title": "Comment nous chiffrons une rénovation", "published": true, "fields": ["body", "cover"], "story_id": 5, "lang": "fr"}
And item 20 has story_id 6 and lang "en"
Scenario: An unassigned article has no language and is its own group
When the client sends GET /v1/media/7/usage
Then item 30 has "lang" "" and its own story_id as stored, and is never grouped with another article
Scenario: Items of other types are unchanged
When the client sends GET /v1/media/7/usage
Then the author, site and entry items carry neither "lang" nor "story_id"
And the order stays articles, authors, sites, then entries as in features/media_field_library.feature
Scenario: An image used by 3 versions of one story is one use
When the client sends GET /v1/media/7/usage
Then the three items 10, 11 and 12 share story_id 5, so a consumer counts them as one article
And GET /v1/media/ reports used_in 5 for image 7: story 5, story 6, article 30, the author and the site
Scenario: A single-language site is unchanged for existing consumers
Given site "notes" has one language
When the client sends GET /v1/media/8/usage for an image used by one article of notes
Then the item has the existing keys plus story_id and lang, and the count is the same as before this slice
Scenario: The usage of an unused image is still an empty array
When the client sends GET /v1/media/4/usage
Then the status is 200 and the body is []
Scenario: The delete-in-use dialog counts stories
Given image 7 is open in the Delete dialog of the Media library
Then the article count in the copy and the lists is 2 articles (story 5, story 6) plus the unassigned article 30, not 5 versions
Scenario: The Media usage panel groups versions under their article (loc.l12.media)
When the user opens the usage of image 7 in Media on journal
Then the Articles section lists story 5 once, with the title of its default-language version and the language of each version that uses the image (EN, DE, FR)
And the Articles heading counts articles, not versions
And a one-language site lists articles exactly as before
Rule: Language configuration shows the derived social locale
Scenario: A bare tag borrows the region of Site.Locale
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
When an owner reads GET /v1/site/atelier/languages
Then language "en" has social_locale_state "derived" and an effective value "en_GB"
And this replaces the deferral in features/site_languages.feature ("Only the explicit value and the tag's own region resolve the social locale")
Scenario: No region anywhere stays not set
Given site "atelier" has Locale "en" and a language "en" with social_locale null
Then language "en" has social_locale_state "not_set" and an effective value null
Rule: Integration page requests
Scenario: The catalogue documents the new requests
Then web/src/data/apiCatalogue.ts lists GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}
And it documents alternates, requested_lang, fallback, the errors article_not_found, language_not_available, site_not_found and invalid_language, and the new keys of GET /v1/article/{id}/seo
And the catalogue sentence "Titles can't be changed after creation." is replaced by one that matches the editable title
And the catalogue counts in web/src/views/IntegrationView.spec.ts and the drift test test/api_catalogue_drift_test.go are updated
Rule: MCP server and llms output
Scenario: The MCP tools expose the language
Then the tool list_articles accepts an optional "lang" input that is sent as the lang query parameter
And the tool descriptions no longer say that a title cannot be changed
And get_article, list_articles and get_sitemap return the JSON they already return, which now carries lang and story_id
And no tool is added for the public routes
Scenario: The llms documents mention languages
Then docs/llms.md, docs/llm/ and the llms-full output describe lang, story_id, alternates, the two public routes and the fallback rule
And the generated output stays in sync (task build:llms leaves no diff)
Rule: SPA, Search and sharing panel (L10)
Scenario Outline: The panel of a localized version
Given the editor is open on <version> of a site with the languages above
And <setup>
When the Search and sharing panel is open
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| version | setup | state | id | visible |
| the French version 12, published | the pattern https://journal.example.com/{lang}/{slug} is set, fr_FR is the social locale, the description is empty with no excerpt or body sentence | Search and sharing: French | loc.seo.noFallback | heading "Search and sharing · French", subtitle "Applies only to the French version. Page language: fr.", the "Canonical" badge with https://journal.example.com/fr/comment-nous-chiffrons-une-renovation and the note "Built from the Public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.", the card "Alternate languages" with "Published versions in enabled languages only.", the rows EN and DE with their URLs and the "Published" badge and the note "Drafts and the disabled Spanish language are left out.", the "Social locale" card with the "og:locale" badge and "Set: fr_FR" and the note "Set by an owner for French in Journal languages. A region is never guessed from the language.", the "Address (slug)" field read-only with the help "Unique within Journal and French. Editing the title never changes the address.", the "Edit slug" button with the notes "Editing the title never changes the slug. Only Edit slug does." and "Links to the old address will stop working. RenewCMS does not create redirects.", the field "Page title" with "Comment nous chiffrons une rénovation \| Journal" and the help "Fallback: French title", the field "Description" empty with the help "No fallback. Empty. English site default not used. The tag is left out." and the field "Image alternative text" with the help "Written per language." |
| the French version 12, published | the owner opens Edit slug | Edit slug | loc.slug.published | the dialog "Edit slug for the French version" with "The address changes for French only.", the field "Address (slug)" focused and in monospace, the warning "This version is published" / "Links to the old address will stop working. RenewCMS does not create redirects.", the note "Editing the title never changes the slug.", "Cancel" and the primary "Change address" |
| the French version 12 | journal has no pattern | Public URL not configured | loc.url.unset | the info alert "Public URL not configured" / "Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work." with the secondary small button "Open Public URL settings" (to /settings/sites/journal/public-url), and no canonical card, no alternates card and no address |
| the French version of a site where fr has no social locale and the tag has no region | the pattern is set | Social locale not set | loc.l10.noSocial | the "Social locale" card with the "og:locale" badge and "Social locale not set" and the note "French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages." |
Scenario: A draft version has no page address
Given the version is a draft
Then the canonical card shows the address the page will have and the alternates card lists the other published versions only
Scenario: A canonical override is shown as the override
Given the version has canonical_url "https://other.example/a"
Then the canonical card shows "https://other.example/a" and the note says it was set by hand
Scenario: Fallback labels follow the API
Then a field whose fallbacks source is "title" shows "Fallback: <Language> title"
And a field whose source is "excerpt" shows "Fallback: <Language> excerpt"
And a field whose source is "body" shows "Fallback: <Language> body"
And a field with source "none" and site_text_skipped false shows "No fallback"
And a field with source "none" and site_text_skipped true shows "No fallback. Empty. <Default language> site default not used. The tag is left out."
And a field whose source is "site" shows "Fallback: <Language> site description"
Scenario Outline: Site text fallback by article language
Given the open version is <article> and the site description is "Rénovation et conseils pour la maison" written in the site default language
Then the Description field shows <help> and the badge "<badge>"
Examples:
| article | help | badge |
| a version in the default language | "Fallback: <Language> site description" and the used text | Uses site text |
| a version in another language | "No fallback. Empty. <Default language> site default not used." | Tag left out |
Scenario: A single-language site shows no language text
Given the site enables one language
Then the panel keeps its current layout: no "Alternate languages" card, no "Social locale" card and no language in the heading
Scenario: Editing the slug of a draft needs no warning
Given the version is a draft
When the owner opens Edit slug
Then the dialog has no "This version is published" alert
Scenario Outline: Slug save outcomes
When the owner changes the address and presses "Change address"
Then the PATCH /v1/article/12 sends {"slug": "...", "if_revision": <revision>} and the result is <result>
Examples:
| response | result |
| 200 | the dialog closes, the address and canonical update, a success toast |
| 400 invalid_slug | the field shows the existing invalid slug message and the dialog stays |
| 409 slug_taken | the field shows the existing slug-taken message and the dialog stays |
| 409 revision_conflict | the conflict dialog of features/localization_translation_flows.feature opens |
Rule: SPA, Integration page (L10 api)
Scenario: The Integration page documents the language requests
Given the route /integration
Then it shows the cards "1. Exact lookup: site + language + slug", "2. Resolve by article group, with fallback", "3. Errors" and "Existing, unchanged" with the texts of loc.l10.api
And the sample responses use the real shapes of this feature
Rule: SPA, existing surfaces (L12)
Scenario Outline: Existing surfaces on a multilingual site
Given the site journal as above and <setup>
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| setup | trigger | state | id | visible |
| the reader can read every language | the user opens Home | Home: recent articles | loc.l12.home | recent articles listed once per article (GET /v1/article/?group=story), the title of the default-language version, and a language code chip only when the shown version is not in the default language |
| the reader can read only the French versions | the user opens Home | Home: reader of French only | loc.l12.homeFr | the titles in French with the code "FR" |
| events carry a lang param | the user opens Activity | Activity | loc.l12.activity | sentences that name the language, for example "Published French version of …" |
| an image used by 3 versions of one article | the user opens the usage of the image in Media | Media usage | loc.l12.media | the image counted once for the article (grouped by the story_id of GET /v1/media/{id}/usage), the versions that use it listed with their language (from the lang of each item) |
| the open version is French | the user opens the article menu | Article menu labels | loc.l12.menu | "Publish French" / "Unpublish French" and "Delete French version" on a multilingual site |
| the open version is French (id 14) | the user opens the editor Details panel | API URL copy | loc.l12.apiCopy | the card "Public API URL" with "Each language version has its own address. The URL carries the language." and the row "/v1/article/14?lang=fr" with a copy icon; copying shows the toast "API URL copied" / "/v1/article/14?lang=fr" |
| the open version is French with a pattern set | the user opens the Search preview | SEO preview | loc.l12.seoPreview | the page "Search preview · French version", the card "How it can appear in search" with the breadcrumb address journal.example.com › fr › comment-nous-chiffrons-une-renovation, the title "Comment nous chiffrons une rénovation \| Journal", the description from the excerpt and the note "Shows the localized address and canonical. Description: Fallback: French excerpt." |
Scenario: Single-language sites show none of it
Given the site enables one language
Then Home, Activity, Media usage, the article menu, the API URL and the SEO preview look and read as before this slice
And Home counts articles, not versions
Scenario: Home counts articles
Given 8 stories with 17 versions
Then Home's total and counts are 8 and the recent list never repeats a story
Scenario: The API URL carries the language only when it is known
Given the open version has lang "" (unassigned)
Then the copied URL is "/v1/article/<id>" with no lang query
Scenario: Copying the URL does not change the request contract
Then GET /v1/article/14?lang=fr answers the same article as GET /v1/article/14 and an unknown lang value is ignored by that route
Rule: Real-database lookups
Scenario: The lookup indexes serve the public queries
Then exact lookup by (site_id, lang, slug) and story resolution by (story_id, lang) return the same rows on SQLite, MySQL and PostgreSQL
And a soft-deleted version, an offline version and an unassigned article are never returned
And the unique indexes idx_articles_site_slug and idx_articles_story_lang exist after Migrate and Migrate again changes nothing
Rule: Layering and documentation
Scenario: Layering is respected
Then the lookup, the resolution, the alternates, the og:locale derivation and the fallback order live in use cases and domain packages
And the domain imports no GORM or HTTP, the use cases import no persistence, mailer, models, gorm, net/http or chi
And the two public routes are mounted by api/router.go with no auth middleware and built only in api/dependecyInjection.go
Scenario: Older features are amended in the same commit
Then these older features and their .qa.md are edited, and nothing else:
| file | edit |
| features/media_library.feature (Rule: Usage) | the example body of "Usage lists the articles whose body contains the url" gains "story_id" and "lang" on each item (assigned articles: the stored story_id and lang) |
| features/media_library.feature (Rule: Usage) | a sentence that article items are one per version and carry story_id and lang |
| features/media_field_library.feature | line 11: "used_in" counts articles by story (a story once), authors, sites and entries; line 12: article items carry story_id and lang; "The articles heading counts articles only" states articles are grouped by story_id, not by item |
| features/media_field_library.feature (delete-in-use scenarios, lines ~234-258) | the article count is the number of distinct stories (plus unassigned articles) |
| features/media_browse.feature | the usage mock at line ~214 gains story_id and lang; counts stay by article |
| features/seo_data_backend.feature and features/sites_backend.feature | only where they assert the exact key set of an article usage item or used_in of an image shared by versions of one story |
| features/seo_output_backend.feature | the exact SEO key set gains lang, alternates, og.locale_alternates, fallbacks.site_text_skipped; the canonical rule and og.locale derivation of assigned articles are replaced; unassigned and pattern-less cases stay |
| features/site_languages.feature | "Only the explicit value and the tag's own region resolve the social locale" is replaced by the derived state |
| features/integration.feature | the request catalogue lists the two public routes and the new SEO keys |
| features/article_localization_storage.feature | none unless the unique indexes are renamed |
Scenario: The documentation describes the contract
Then docs/api/article-localization.md documents both public routes, alternates, requested_lang, fallback, the errors, the SEO keys, the og:locale order, the fallback order and the canonical rule
And docs/api.md, docs/api/articles.md and docs/release-notes.md announce the change
And the SPA mocks of this slice are written from those pages
@@ -0,0 +1,67 @@
# QA: Localized public API, SEO output and existing surfaces
Accepted together with features/localization_public_api_and_surfaces.feature. One pair, full path: Go backend first (public routes, SEO, sitemap, languages read, MCP, llms), then the SPA, which mocks `/v1` from `docs/api/article-localization.md`.
## Backend (Go, `task test`, package `test`)
Build `NewHandler(...)`s over the real use cases wired to the in-memory fakes already used by `test/article_translation_create_http_test.go`, `test/seo_output_support_test.go` and `test/site_support_test.go` (reuse them, add no second copy; new fake types need unique names). Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: the two public routes under `/v1/public` with no auth middleware, `/v1/article` with `OptionalAccess`, `/v1/site` for the sitemap and languages. Call `validator.InitValidator()`, exercise with `httptest`. No live server, no SQLite file, no SMTP.
Fixture: the Background of the feature (journal with en default, fr fr_FR, de, ar, es disabled; story 5 with versions 10, 12, 11, 13, 14; stories 6 and 7; unassigned article 30; atelier with a published fr slug clash; notes without confirmed default). Add pt-BR versions 40 and 41 for the og:locale table.
1. **Exact lookup, Examples-driven** over the table: status, body code, and for 200 the key set equals the anonymous `GET /v1/article/{id}` key set plus `alternates`. Alternates order (default first, then tag), keys `id, lang, story_id, slug, url`, exclusion of the served version, drafts and disabled languages; `url` is the pattern result, or JSON `null` when the site has no pattern (assert the pattern-less case with the fake).
2. **Never falls back and never leaks**: wrong language with right slug, draft with owner cookie (cookie ignored), unpublished after unpublish, old slug after change, unassigned article, soft-deleted version: all `404 article_not_found` with the identical body. A raw-response grep shows the id, slug and title of a hidden sibling appear nowhere.
3. **Story resolution, Examples-driven** over the decision table of the design (requested published, missing without fallback, missing with fallback, requested unpublished, both unavailable, default requested and unpublished, no lang, default changed, no confirmed default, disabled language explicit, other site, bad fallback values, bad lang, bad id). Assert `lang`, `requested_lang`, `fallback` on every 200 and that version 11 (de) is never returned as a third language.
4. **Default change stability**: after changing the default to fr, lang-omitted resolves to 12 while `?lang=en`, the exact lookup and `GET /v1/article/10` are unchanged.
5. **SEO object**: key sets (additive `lang`, `alternates`, `og.locale_alternates`, `fallbacks.site_text_skipped`); canonical table (pattern, override wins, no pattern keeps legacy, unassigned keeps legacy, `{lang}` keeps `pt-BR`); `fallbacks.canonical` values; og:locale Examples table including `en` with Site.Locale `en` and `null` (empty string, alternates `[]`); `og.locale_alternates`; the description/title fallback tables and `site_text_skipped`; the default-change limitation scenario. Existing `test/seo_output_*` specs are amended only where they assert the exact key set, the `og.locale` of an assigned article, or the canonical of a site that has a pattern (unassigned and pattern-less cases must pass unchanged).
6. **Sitemap**: eligible versions only, `lang` key, localized urls with a pattern and legacy urls without, `X-Total-Count`, paging invariants unchanged.
7. **Languages read**: `social_locale_state` `derived` with `en_GB` for bare `en` and Site.Locale `en-GB`, `not_set` for bare Site.Locale; amend `test/site_languages_*` accordingly.
8. **MCP**: `list_articles` forwards `lang` (assert the request URL with a stub HTTP client in `internal/infrastructure/mcpserver`'s existing test style), tool descriptions contain no "cannot be changed".
9. **Catalogue drift**: `test/api_catalogue_drift_test.go` green with the two new routes; `web/src/data/apiCatalogue.ts` counts updated.
10. **Layering**: existing layering tests stay green; the public handler imports no persistence, models or gorm.
11. **Media usage** (extend `TestMediaUsage` in `test/media_library_http_test.go` and the usage cases of `test/media_merge_hardening_test.go` and `test/seo_data_media_http_test.go` rather than a parallel file; the fakes and `NewGetMediaUsageUseCase(...)` wiring already exist): fixture image 7 as in the feature's Rule "Media usage names the language and counts a story once". Assert: one article item per version, ordered by id (10, 11, 12, 20, 30); every article item carries `story_id` and `lang` (item 12 equals the exact JSON of the feature, key set `type,id,title,published,fields,story_id,lang`); the unassigned article 30 has `lang` `""` and is not grouped with any story; author, site and entry items have neither key (assert absence in the raw JSON); order articles, authors, sites, entries unchanged; unused image answers `[]`; `404 Image not found` and `400 apperr.IdUint32ErrorMessage` unchanged. Grouping check: items 10, 11, 12 share `story_id` 5. `GET /v1/media/` gives `used_in` 5 for image 7 (story 5 once, story 6, article 30, author, site) and the same count as before the slice on a one-language fixture. The delete-in-use count (unchanged endpoint, SPA side) is covered in the SPA list below. Amend only the usage assertions that compare an article item's exact JSON or `used_in` of an image shared by several versions of a story; every other media test must pass untouched.
Older features amended in the same commit (listed in the feature's scenario "Older features are amended in the same commit"): `media_library.feature` (Rule: Usage), `media_field_library.feature` (lines 11-12, articles heading, delete-in-use counts), `media_browse.feature` (usage mock), `seo_data_backend.feature` and `sites_backend.feature` (only exact usage key sets / `used_in`), `seo_output_backend.feature`, `site_languages.feature`, `integration.feature`; their `.qa.md` files follow.
Real-database runs are required (gate): `task test:mysql` and `task test:postgres` for a new persistence test in `test/article_localization_storage_persistence_test.go` (or a sibling file) that exercises the repository queries behind exact lookup and story resolution: same results on each driver, soft-deleted and offline rows excluded, unassigned excluded, `EXPLAIN`-free (assert correctness and the presence of `idx_articles_site_slug` and `idx_articles_story_lang`, not the plan). Run SQLite in `task test`. Run both real-DB tasks once, at the end of the backend stage. Use the podman shim if the Docker daemon is off.
Docs (the contract the SPA mocks): `docs/api/article-localization.md`, `docs/api.md`, `docs/api/articles.md`, `docs/llms.md`, `docs/llm/` recipes, `docs/release-notes.md`.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` per case (no backend), from the documented contract. Mount EditorView in DashboardShell with a verified session (user id 7, username "marta") and reuse `editorKit.ts` and `localizationKit.ts`. Fixture: the Background (journal, story 5, version 12 open). Specs next to the code in `__tests__/`; extend the existing `EditorSeo*.spec.ts` files rather than adding a parallel set.
1. **Search and sharing, one test per state**: loc.seo.noFallback (every heading, note, badge, row and help text listed in the feature), loc.slug.published, loc.url.unset (alert, button target `/settings/sites/journal/public-url`, no canonical, no alternates), loc.l10.noSocial.
2. **Fallback labels, Examples-driven** over every `fallbacks` source and `site_text_skipped` value; both site-text articles (default language, other language).
3. **Draft, canonical override, single-language site**: alternates and drafts, override note, no language text on a one-language site.
4. **Edit slug**: warning only when published; request body `{slug, if_revision}`; 400 `invalid_slug`, 409 slug conflict and 409 `revision_conflict` outcomes; focus returns to the Edit slug button.
5. **Integration page**: the four cards and sample shapes; catalogue entries for both routes; no "Titles can't be changed" sentence.
6. **Home**: request uses `group=story`; default-language title; the code chip only for non-default; French-only reader (loc.l12.homeFr); counts are articles; single-language site unchanged.
7. **Activity**: sentences with the language for every article action code that carries `params.lang` (publish, unpublish, create, update, delete), English and French; events without `lang` unchanged.
8. **Media usage**: mock `GET /v1/media/7/usage` with items 10, 11, 12 (story_id 5; langs en, de, fr), 20 (story 6) and 30 (lang ""): the Articles section shows story 5 once with the versions EN, DE, FR listed and the heading counts articles (3 with story 6 and article 30), not 5 items; a one-language site lists as before; the delete-in-use dialog counts stories; items without `story_id`/`lang` (older mocks) still render one row per item (existing `mediaUsage` specs stay green).
9. **Article menu labels**: Publish, Unpublish and Delete name the language on multilingual sites only.
10. **API URL copy**: the Details card text, copied text `/v1/article/14?lang=fr`, toast "API URL copied", unassigned version copies without `lang`, clipboard failure handling as today.
11. **SEO preview**: breadcrumb, title, description fallback line, the note.
12. **Keyboard and focus**: Edit slug dialog trap and Escape, row menu items unchanged.
Existing SPA specs to amend (and nothing else): `EditorSeoPanel`, `EditorSeoSlug`, `EditorSeoFrench`, `SeoIntegration`, `IntegrationView`, `HomeView`, `ActivityView`, media usage specs, `seoApiPreview.spec.ts`, `apiCatalogue` spec.
## Visual (`task visual:check -- <screen>`)
Add setups in `web/visual/states.ts` with the board's own fixture data (Journal, locale-fixtures.js; the French title "Comment nous chiffrons une rénovation", slugs and URLs of the board). Screens and states, light and dark, 1440 and 390 (and 1024 where the table says so):
| Screen file | States |
| --- | --- |
| localization-metadata-api | loc.seo.noFallback, loc.slug.published, loc.url.unset, loc.l10.noSocial, loc.l10.api (see open question below) |
| localization-surfaces | loc.l12.home, loc.l12.homeFr, loc.l12.activity, loc.l12.media (1440, 1024, 390), loc.l12.menu, loc.l12.apiCopy, loc.l12.seoPreview, loc.l12.siteFallback (1440, 390) |
Clock-sensitive: loc.l12.activity and loc.l12.home show relative times if the SPA renders them from the current date; if so mark `skip: 'clock-sensitive'` and cover the language text in Vitest with a fixed event list. No date freezing, no threshold change, no exception without the user's explicit approval.
Decision D6 (user): the specification works around these design conflicts. For loc.l10.api, loc.l12.siteFallback, loc.l12.apiCopy and loc.l12.seoPreview the coder must stop and return the measured differences or route gaps to the user; it must not edit docs/design, add a `skip`, change a threshold or add an exception. The route `language_disabled` is not returned by any public route (a disabled language stays readable). Known gaps (reported at specification time, not waived): loc.l10.api is drawn with the alert "Proposed capability / These requests don't exist yet" and the story id "st_01", which will be false once this slice ships; loc.l12.siteFallback draws two different articles in one frame (French and Arabic), which no single SPA screen can show; loc.l12.apiCopy and loc.l12.seoPreview are drawn as stand-alone "Details" and "Search preview" pages whose route is not named in docs/design/routes.md.
## Manual (not covered by screenshots or Vitest)
1. Keyboard and focus order in the Edit slug dialog and the new alternates and social locale cards; hover and motion.
2. Real browser: copy button writes to the real clipboard.
3. Real embedded frontend build (`task build`, run the binary): `/integration` shows the new cards; no console errors.
4. Real SMTP is not involved.
5. A real site with a Public URL pattern: open the canonical and an alternate URL in a browser is not possible (they point to the website, not RenewCMS); verify instead that the strings match the pattern.
+1 -1
View File
@@ -211,7 +211,7 @@ Feature: Media page, part 1: browse and manage
Then the card's selection toggles and no panel opens
Scenario: Detail panel, in use
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true, story_id: 5, lang: "en" }, { id: 11, title: "Winter checklist for timber windows", published: false, story_id: 6, lang: "en" }]
When the user activates the "north-wall.jpg" card
Then the slideover opens with the title "north-wall.jpg", a 16:10 preview and the actions "Copy URL", "Rename" and "Delete" (Delete in error-coloured text)
And the metadata list reads Type "JPEG", Size "3.9 MB", Dimensions "4032 × 3024", Uploaded "12 Sep 2026", Uploaded by "Marta Alves" and URL in mono
+1 -1
View File
@@ -13,7 +13,7 @@ Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}",
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`,
`GET /v1/media/{id}/usage`, `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
`GET /v1/media/{id}/usage` (article items carry `story_id` and `lang`; counts stay by article), `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and
`DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real
backend. Mount MediaPage inside DashboardShell with a verified session and a test router at
/media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames,
+4 -3
View File
@@ -8,9 +8,9 @@ Feature: Media field usage in the Media library
And API errors are plain text
And this slice changes only web/ (and web/visual); the backend of features/media_field_type.feature is done and not touched
And the design is docs/design/media-field.md section 3.8 and section 4, with the board group "11 Media library: usage" (4 states) of "RenewCMS Screens Schema.dc.html", which draws MediaFrame.dc.html with entries counted as uses
And GET /v1/media/ items carry "used_in", which counts articles, authors, sites and entries together, and no split
And GET /v1/media/ items carry "used_in", which counts articles by story (a story once), authors, sites and entries together, and no split
And GET /v1/media/{id}/usage answers an array ordered articles, authors, sites, then entries by content type name then entry id, with no total and no "first few" wrapper
And an article item is {"type":"article","id","title","published","fields"}
And an article item is {"type":"article","id","title","published","fields","story_id","lang"}, one per version, and the SPA groups article items by story_id
And an entry item is {"type":"entry","id","fields","content_type_key","content_type_name","first_field":{"key","type","value"}} with no title and no published, and first_field absent when the type has no first field
And the SPA keeps the first 3 entry items, counts the rest, and derives the entry title from first_field with the title rules of the entry list (see "Entry title in the usage lists")
And deleting a used image stays allowed; entries keep the id and show "This image is no longer in the library"
@@ -152,6 +152,7 @@ Feature: Media field usage in the Media library
Scenario: The articles heading counts articles only
Given lime-plaster.jpg has used_in 7 (2 articles, 5 entries)
Then the heading reads "Used in 2 articles" and never "Used in 7 articles"
And articles are grouped by story_id, not by item, so several versions of one story count as one article
Scenario: Entry link keyboard and focus
Given the panel of lime-plaster.jpg is open
@@ -231,7 +232,7 @@ Feature: Media field usage in the Media library
And the entry rows show the type name and the title and are not links in the dialog (decision D10)
Scenario Outline: The wording by what uses the image
Given an image "lime-plaster.jpg" with <articles> articles and <entries> entries in its loaded usage
Given an image "lime-plaster.jpg" with <articles> articles (the number of distinct stories, plus unassigned articles) and <entries> entries in its loaded usage
Then the title is "<title>"
And the description reads <description>
+1 -1
View File
@@ -20,7 +20,7 @@ Decisions accepted by the user (all as the feature draft assumes):
- D4 truncation: one line, ellipsis, native title.
- D5 usage failure: inline message with "Try again" in the panel, silent tooltip.
- D6 usage loading: skeleton after 150 ms, delete dialog opens at once with a generic description.
- D7 authors and sites in the usage: filtered out, heading counts articles only.
- D7 authors and sites in the usage: filtered out, heading counts articles only. Article items carry `story_id` and `lang`; articles are grouped by `story_id` (a story counts once, in the heading, the panel and the delete dialog); items without `story_id` still render one row each. See features/localization_public_api_and_surfaces.qa.md, item 8 of the SPA list.
- D8 bulk delete: "N places".
- D9 merge: "N places".
- D10 entry rows in the delete dialog are not links.
+2 -1
View File
@@ -289,7 +289,8 @@ Feature: The media library holds every uploaded image as a library-level asset
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
When the client sends GET /v1/media/1/usage with a valid cookie
Then the response status is 200
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true, "story_id": 1, "lang": "en"}, {"id": 2, "title": "Backyard Notes", "published": false, "story_id": 2, "lang": "en"}]
And the items are one per version, and each carries the stored story_id and lang of its article (lang "" for an unassigned article), as in features/localization_public_api_and_surfaces.feature
Scenario: An unused image has an empty array, not null
When the client sends GET /v1/media/4/usage
+1 -1
View File
@@ -7,7 +7,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`).
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`). Usage article items are one per version and carry `story_id` and `lang` (see features/localization_public_api_and_surfaces.qa.md, item 11); the usage example of the feature is asserted with those keys.
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
+1 -1
View File
@@ -602,7 +602,7 @@ Feature: SEO data backend, article and site search fields, unique slugs and medi
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 the body is a JSON array of 4 items: {"type": "article", "id": 3, "title": "...", "published": ..., "fields": ["body"], "story_id": ..., "lang": "..."}, {"type": "article", "id": 8, "title": "...", "published": ..., "fields": ["cover"], "story_id": ..., "lang": "..."},{"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
+1 -1
View File
@@ -67,7 +67,7 @@ Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `t
## 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.
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`, `fields`, and for articles `story_id` and `lang` (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.
+11 -7
View File
@@ -29,11 +29,11 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
Scenario: The object has the documented shape
When an anonymous caller sends GET /v1/article/12/seo
Then the status is 200
And the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld
And og has exactly the keys type, title, description, image, image_alt, url, site_name and locale
And the body has exactly the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld, lang and alternates
And og has exactly the keys type, title, description, image, image_alt, url, site_name, locale and locale_alternates
And twitter has exactly the keys card, title, description, image and image_alt
And image has exactly the keys url, alt and source
And fallbacks has exactly the keys title, description, image and canonical
And fallbacks has exactly the keys title, description, image, canonical and site_text_skipped
And the body has no body, no id and no seo_dismissed key
Scenario: A bare article resolves every output through the fallbacks
@@ -43,9 +43,10 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
And canonical_url is "https://www.renew.example/how-we-price-a-bathroom-renovation"
And robots is "index, follow"
And image is {"url": "https://www.renew.example/static/uploadedImages/7.png", "alt": "", "source": "site"}
And og is {"type": "article", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": "", "url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "site_name": "Renew", "locale": "en"}
And og is {"type": "article", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": "", "url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "site_name": "Renew", "locale": "en", "locale_alternates": []}
And lang is "" and alternates is [] because this article is unassigned, so the legacy canonical and the raw Site.Locale apply
And twitter is {"card": "summary_large_image", "title": "How we price a bathroom renovation | Renew", "description": "We price by the square metre.", "image": "https://www.renew.example/static/uploadedImages/7.png", "image_alt": ""}
And fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site"}
And fallbacks is {"title": "title", "description": "body", "image": "site", "canonical": "site", "site_text_skipped": false}
And published_at is "2026-09-01T08:00:00Z" and modified_at is "2026-09-10T09:30:00Z"
Scenario Outline: The title chain and the template
@@ -118,8 +119,10 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
And fallbacks.canonical is "<source>"
And robots is "<result>"
And for an assigned article the canonical is built from the Public URL pattern with {lang} and {slug} and fallbacks.canonical is "pattern", while this table (unassigned article, or a site with no pattern) keeps the legacy base_url rule, and og:locale derivation follows features/localization_public_api_and_surfaces.feature
Examples:
| canonical | noindex | robots | resolved | source | result |
| canonical | noindex | robots | resolved | source | result |
| | false | index | https://www.renew.example/how-we-price-a-bathroom-renovation | site | index, follow |
| | false | noindex | https://www.renew.example/how-we-price-a-bathroom-renovation | site | noindex, nofollow |
| https://other.example/a | false | index | https://other.example/a | canonical_url | index, follow |
@@ -574,7 +577,8 @@ Feature: SEO output backend, resolved SEO object, non-persisting check, list sta
Then the status is 200
And the body is a JSON array of 2 items: {"url": "https://www.renew.example/how-we-price-a-bathroom-renovation", "modified_at": "2026-09-10T09:30:00Z", "noindex": false} and the item of the article 16
And the header X-Total-Count is "2"
And each item has exactly the keys url, modified_at and noindex
And each item has exactly the keys url, modified_at, noindex and lang
And only published versions in enabled languages are listed, each at its localized address when the site has a Public URL pattern
Scenario: Noindex articles are included with the flag
Given the online article 16 has noindex true
+1 -1
View File
@@ -45,7 +45,7 @@ Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package
## C. Check endpoint
10. Shape: exact keys of root, suggestion, `slug`.
10. Shape: exact keys of root (now with `lang` and `alternates`), `og` (with `locale_alternates`), `fallbacks` (with `site_text_skipped`), sitemap items (with `lang`), suggestion, `slug`. The canonical of an assigned article on a site with a pattern and its `og.locale` follow features/localization_public_api_and_surfaces.qa.md, item 5; unassigned and pattern-less cases pass unchanged.
11. Code table: one article per code with the exact `field` and `params` and the thresholds above.
12. Edge tables: title 60/61, description 70/69 and 160/161, slug 75/76, `no_h` 999/1000 with h2, h6, h1 only, `short` 299/300, rune counting with 2-byte characters, title measured before the template, text length ignoring markup.
13. Fully prepared article gives `ready` and `[]`; overrides replace saved values (set, clear, null image id, body); the saved article, its `updated_at`, the site, the media usage and the activity log are unchanged after every call.
+3 -3
View File
@@ -54,11 +54,11 @@ Feature: Site languages (API and Languages page)
| fr | set | fr_FR | fr_FR |
| en | not_set | null | null |
Scenario: Only the explicit value and the tag's own region resolve the social locale
Scenario: A bare tag borrows the region of Site.Locale
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
When an owner reads its languages
Then language "en" has social_locale_state "not_set"
And matching the Site.Locale language part is deferred to slice 6
Then language "en" has social_locale_state "derived" and an effective value "en_GB"
And a bare Site.Locale such as "en" leaves the state "not_set", as in features/localization_public_api_and_surfaces.feature
Scenario: A site that still needs setup
Given site "notes" has no confirmed default language
+1 -1
View File
@@ -42,7 +42,7 @@ Mock /v1 per case. Do not freeze the system date.
- Row menu: Make default, Disable, Remove.
- Disabled language in the picker: "Disabled — re-enable instead".
- Version counts are visible to every signed-in reader; the exception is recorded in docs/.
- Social locale rule 3 (Site.Locale language part) is deferred to slice 6.
- Social locale rule 3 (Site.Locale language part) is the `derived` state, covered by features/localization_public_api_and_surfaces.qa.md, item 7.
## Fix round additions
+2 -1
View File
@@ -64,7 +64,8 @@ Feature: Public URL pattern of a site
Scenario: Saving the pattern changes nothing else
When an owner sets the pattern
Then no article, canonical URL or public response changes
Then no article is changed and no canonical override is changed
And the canonical output and sitemap addresses of the site's articles follow the pattern from then on, as in features/localization_public_api_and_surfaces.feature
And the site's other fields are unchanged
Scenario: Existing site behaviour is unchanged
+1
View File
@@ -10,6 +10,7 @@ Accepted together with features/site_public_url_pattern.feature.
## Automated, SQLite file (task test)
3b. Saving the pattern changes no article and no canonical override; the canonical output and sitemap addresses follow the pattern afterwards (features/localization_public_api_and_surfaces.qa.md, items 5 and 6).
4. The column migration is covered in the site_languages QA step 5. Round-trip a pattern of 512 characters and refuse 513.
## Automated (Vitest + Vue Test Utils, task test:web)
+1 -1
View File
@@ -69,7 +69,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, stays a JSON array whose article items keep id, title and published (additive keys are specified in seo_data_backend), 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, including story_id and lang, 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.