feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user