feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
Feature: Site languages (API and Languages page)
An owner manages the content languages of a site: which are enabled, which
is the default, and the social locale of each. Every signed-in user can read
the list, only an owner can change it. The whole set is replaced atomically.
Nothing here deletes, unpublishes or relabels an article.
Background:
Given the language rules of features/article_localization_storage.feature apply
And the domain package internal/domain/language is not modified by this slice
And error responses are plain text whose body is the snake_case code
And the new rules use the design statuses 409 and 422, existing site errors keep their statuses
And an "owner" is a verified user holding settings.manage_sites and a "reader" is a verified user without it
And the design in docs/design/localization.md (L1) and screens/localization-site-languages.md is the visual spec of record
And version counts are site-level aggregates visible to every signed-in reader, an accepted exception recorded in docs/
And site "journal" (id 1, Locale "en") has a confirmed default and these languages
| lang | default | enabled | social_locale | versions | published |
| en | true | true | null | 7 | 6 |
| fr | false | true | fr_FR | 4 | 2 |
| de | false | true | null | 4 | 3 |
| ar | false | true | null | 1 | 0 |
| es | false | false | null | 1 | 0 |
| it | false | false | null | 0 | 0 |
Scenario: Read the languages
When a reader sends GET /v1/site/journal/languages
Then the status is 200
And the body has "revision" (an opaque digest string), "default" "en", "needs_setup" false
And "languages" lists en first, then the other enabled languages by tag, then the disabled ones by tag
And each language has lang, enabled, is_default, version_count, published_count, social_locale and social_locale_state
And "available" lists each selectable tag with a "substitute" that is null or the broader supported tag
And a version count covers every non-deleted article of that language, drafts and offline ones included
Scenario Outline: Read access
When <caller> sends GET /v1/site/<key>/languages
Then the status is <status> and the body is "<body>"
Examples:
| caller | key | status | body |
| an anonymous client | journal | 401 | Unauthorized |
| a reader | journal | 200 | |
| an owner | journal | 200 | |
| an owner | nowhere | 404 | site_not_found |
Scenario Outline: Social locale states
Given site "atelier" has the languages en, en-GB, fr, pt-BR with social_locale null, null, "fr_FR", null
When an owner reads its languages
Then language "<lang>" has social_locale_state "<state>" and social_locale "<explicit>"
And its effective value is "<effective>"
Examples:
| lang | state | explicit | effective |
| en-GB | derived | null | en_GB |
| pt-BR | derived | null | pt_BR |
| 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
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
Scenario: A site that still needs setup
Given site "notes" has no confirmed default language
When a reader sends GET /v1/site/notes/languages
Then "needs_setup" is true
When an owner sends PUT /v1/site/notes/languages with any body
Then the status is 409 and the body is "setup_required"
And nothing is stored
Scenario Outline: Changing the set
Given an owner holds the current "revision" of site "journal"
When the owner sends PUT /v1/site/journal/languages with that revision and <change>
Then the status is 200 and the body is the new read shape
And <effect>
And no article changes its lang, status or publication
Examples:
| change | effect |
| fr added with enabled true | French stays one row, enabled |
| pt-br added (lowercase) as enabled | a row "pt-BR" exists and "pt-br" does not |
| default "fr" | fr is default, en is no longer default and stays enabled |
| de enabled false | de is Disabled, its 4 versions and 3 online ones are untouched |
| es enabled true | es is enabled again, its version is untouched |
| it omitted | it is gone |
| fr social_locale "fr_CA" | fr has social_locale_state "set" and value "fr_CA" |
| fr social_locale null | fr has social_locale_state "not_set" |
Scenario Outline: Rules that reject a change
Given an owner holds the current "revision" of site "journal"
When the owner sends PUT /v1/site/journal/languages with <body>
Then the status is <status> and the body is "<code>"
And the stored languages, default and flags are unchanged
Examples:
| body | status | code |
| default "en" with en enabled false | 422 | default_not_enabled |
| default "ja" | 422 | default_not_enabled |
| every language enabled false | 422 | no_enabled_language |
| languages [] | 422 | no_enabled_language |
| es omitted (1 version) | 409 | language_in_use |
| ar omitted (1 draft or offline version) | 409 | language_in_use |
| en omitted | 422 | default_not_enabled |
| a language "english" | 422 | invalid_language |
| a language "xx" | 422 | unsupported_language |
| a language "pt-PT" | 422 | unsupported_language |
| the same tag twice, "fr" and "FR" | 422 | duplicate_language |
| fr social_locale "fr-FR" | 422 | invalid_social_locale |
| fr social_locale "fr" | 422 | invalid_social_locale |
| fr social_locale "" | 422 | invalid_social_locale |
| fr social_locale "pt_BR" | 422 | invalid_social_locale |
| no "revision" key | 400 | Invalid request body |
| malformed JSON | 400 | Invalid request body |
Scenario: Social locale format and language part
Given the format is "ll_RR" or "lll_RR", letters only and one underscore
And the language part must equal the language part of the row's tag
Then "fr_FR" and "fr_CA" are valid on "fr", and "pt_BR" is invalid on "fr"
Scenario: One bad entry rolls back the whole request
Given an owner holds the current "revision" of site "journal"
When the owner sends a PUT that makes fr the default, disables de and omits es
Then the status is 409 and the body is "language_in_use"
And fr is not the default, de is still enabled and every row is unchanged
Scenario: A stale revision is refused
Given an owner read revision R1 and another owner then changed the languages
When the first owner sends PUT with revision R1
Then the status is 409 and the body is "languages_changed"
And nothing is applied
When the first owner reads again and repeats the change with the new revision
Then the status is 200
Scenario: Sending the same state again is a no-op
Given an owner sends a valid PUT and receives revision R2
When the same body is sent again with revision R2
Then the status is 200 and the revision is still R2
And no row has changed
Scenario Outline: Only an owner can write
When <caller> sends PUT /v1/site/journal/languages with a valid body
Then the status is <status> and nothing is stored
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
Scenario: A soft-deleted version keeps its language in use
Given language "it" of site "journal" has no live version and one soft-deleted version
When an owner sends PUT /v1/site/journal/languages that omits "it"
Then the status is 409 and the body is "language_in_use"
And the version count shown for "it" stays 0
Scenario: A busy site lock is a conflict, never a server error
Given another change holds the lock of site "journal"
When an owner sends PUT /v1/site/journal/languages with the current revision
Then the status is 409 and the body is "languages_changed"
And nothing is applied
And every lock error of the PUT (site lock, reads, writes, commit) is answered the same way on SQLite, MySQL and PostgreSQL
Scenario: Concurrent create and PUT on SQLite
Given a database on SQLite
When a PUT of the languages races the creation of another site
Then the PUT answers 200 or 409 and never 500
And the site keeps exactly one default language
Scenario: Changing the languages is recorded in the activity log
When an owner changes the set, the default or a social locale
Then one "site.languages_edit" event of the sites category targets the site once the change is committed
And its changes list the default_language when it changed and the languages before and after
When the PUT changes nothing or is a read, no event is recorded
When a PUT is refused, one failed event with a plain reason is recorded and an unknown site key records nothing
Scenario: Removing a language is decided under the site lock
Given an offline article is being created in site "journal" while an owner makes "fr" the default
Then every created article ends with the default that was current when it took the site lock
And none is left with lang ""
And a language used by a version at the time of a remove request is never removed
Scenario: Persistence additions are safe to migrate
Given a database migrated by slice 1 with site_languages rows
When database.Migrate runs
Then site_languages gains a nullable social_locale column of at most 35 characters, null on every row
And sites gains a nullable public_url_pattern column
And running Migrate again changes nothing
Scenario: Persistence portability of this slice
Given the setup transaction, the PUT atomicity and the new columns
Then one targeted task test:mysql and task test:postgres run covers them on MySQL 8.4, MariaDB 11 and PostgreSQL
Scenario: Sites created after the migration have no language row
Given a site is created after slice 1
Then it has no site_languages row and appears as pending setup
And seeding a suggested row on site create is out of scope and tracked as a follow-up
Scenario: Existing behaviour is unchanged
Then the existing site, article and slice 1 tests pass without edits
And no article read changes shape
Scenario: Route and access
Given the route /settings/sites/:key/languages
Then it is open to every verified signed-in user
And owners see the controls, other users see the Read-only view
And an unknown :key shows the Load failed state
Scenario: Entry points from Settings, Sites
Given an owner on Settings, Sites
Then each site row has the actions "Languages" and "Public URL"
And they open /settings/sites/:key/languages and /settings/sites/:key/public-url
And non-owners reach those pages by URL only
And the frame for these row actions is pending the designer, so only their presence and targets are asserted
Scenario Outline: Page states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| the GET is in flight | Loading | loc.l1.loading | heading "Languages", subtitle "Content languages for Journal. Each article version is written in one language.", 4 skeleton rows |
| the GET fails (network or 5xx) | Load failed | loc.l1.error | alert "Couldn't load languages" / "Check your connection and try again." and a "Try again" button that repeats the GET |
| an owner opens Archive (one language pt-BR) | Single language | loc.l1.single | the card "Archive uses one language", "New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.", Default tag, "pt-BR", "Social locale" "Derived: pt_BR", the alert "Regional language kept: pt-BR", and the "Add language" button |
| an owner opens Journal | Multilingual | loc.l1.multi | the table headed "Language"; one row per language with name, code, a Default tag or a Disabled tag, "4 versions · 2 published", "Social locale" value, an "Edit" button (label "Edit social locale for French"), a "Re-enable" button on disabled rows, and an actions button (label "Actions for French"); the footnote "Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out." |
| a reader opens Journal | Read-only view | loc.l1.readonly | the alert "Only owners can change languages" / "You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.", the same rows and no Add language, Edit, Re-enable or actions button |
| an owner opens Atelier | Social locale | loc.l1.social | "Derived: en_GB" and "Derived: pt_BR", "Set: fr_FR" and "Not set" shown in the rows (1440, 1024, 390) |
Scenario Outline: Add a language
Given an owner on Journal clicks "Add language"
Then the dialog "Add a language" opens with "Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries."
When <action>
Then the screen is "<state>" (<id>) and <result>
Examples:
| action | state | id | result |
| the dialog is open with Portuguese (Brazil) chosen | Add a language | loc.l1.add | read-only "Language code" "pt-BR" ("Used in the API and in page language metadata.") and "Social locale" "Derived: pt_BR" are shown; buttons "Cancel" and "Add language" |
| the owner confirms | Saved | loc.l1.saved | a PUT with the full state plus the new row is sent, the dialog closes, and the toast "French added" / "Writers can now create French versions." appears |
| the PUT fails with a server error | Add failed | loc.l1.saveFail | the dialog stays open, the selection is kept, the alert "Couldn't save languages" / "Nothing changed. Your selection is kept. Try again." appears and the button reads "Try again" |
| French is chosen and it is already enabled | Duplicate language | loc.l1.adddup | the field error "French is already enabled for Journal." shows, "Add language" is disabled and no request is sent |
| Portuguese (Portugal) is chosen (substitute "pt") | Only a broader tag supported | loc.l1.broader | the warning "Portuguese (Portugal) isn't available" / "Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can't be told apart from other Portuguese.", a checked box "Add Portuguese (pt) instead", and the buttons "Cancel" and "Add Portuguese"; "pt" is sent only once that box is checked |
Scenario: Adding a language that is already listed but disabled
Given Spanish is listed on Journal as disabled
When the owner opens the Add a language dialog
Then Spanish is listed as "Disabled — re-enable instead" and cannot be added
And this state has no frame, so the copy is the assumption recorded in the QA notes
Scenario: The row menu offers three items
Given an owner on Journal opens "Actions for French"
Then the menu offers "Make default", "Disable" and "Remove"
And "Re-enable" is a separate button on disabled rows, not a menu item
And the menu itself is not drawn, so the items are inferred from the frames and recorded in the QA notes
Scenario Outline: Row actions
Given an owner on Journal
When <action>
Then the screen is "<state>" (<id>) and <result>
Examples:
| action | state | id | result |
| the French row menu "Make default" is chosen | Make default | loc.l1.default | the dialog "Make French the default language?" with "New articles start in the default language, and it is the first choice for each article's title in the list.", the info alert "No content changes", and the buttons "Cancel" and "Make French default" |
| the PUT of that change fails | Set default failed | loc.l1.defaultFail | the dialog shows the alert "Couldn't change the default language" / "Nothing changed. Try again." and "Try again" |
| the German row menu "Disable" is chosen | Disable with content | loc.l1.disable | the dialog "Disable German?" with "German is used by 4 versions: 1 draft and 3 published.", the warning "Nothing is deleted or unpublished" and the buttons "Cancel" and "Disable German" |
| the PUT of that change returns languages_changed | Languages changed | loc.l1.stale | the alert "Languages changed. Reload." / "Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn't applied." and a "Reload" button that repeats the GET |
| "Disable" is chosen on the default language English | Disable default blocked | loc.lang.disableDefault | no request is sent; the warning "English is the default" / "Choose another default first, then disable English." |
| "Disable" is chosen on the only enabled language | Disable last blocked | loc.lang.disableLast | no request is sent; the warning "At least one language must stay enabled" / "Journal needs a language for new articles." |
| "Re-enable" is chosen on Spanish | Re-enable | loc.l1.reenable | the dialog "Re-enable Spanish?" with "Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes." and "Re-enable Spanish" |
| the owner confirms | Re-enabled | loc.l1.reenableDone | the toast "Spanish re-enabled" / "Writers can create Spanish versions again. Drafts can be published." |
| the PUT of that change fails | Re-enable failed | loc.l1.reenableFail | the dialog alert "Couldn't re-enable Spanish" / "Nothing changed. Spanish is still disabled. Try again." and "Try again" |
| "Remove" is chosen on Italian (0 versions) | Remove (no content) | loc.l1.remove | the dialog "Remove Italian?" / "No article uses Italian. You can add it again later." and a danger button "Remove Italian" |
| the PUT of that change fails | Remove failed | loc.l1.removeFail | the alert "Couldn't remove Italian" / "Nothing changed. Italian is still listed. Try again." and a danger "Try again" |
| "Remove" is chosen on Spanish (1 version) | Remove blocked | loc.l1.removeBlocked | no request is sent; the dialog "Spanish can't be removed" / "Spanish is used by 1 version. Disable the language to keep it, or delete it first." with "Close" and "Disable Spanish" |
Scenario: A server-side language_in_use refusal is shown as Remove blocked
Given another owner created a Spanish version after this page loaded
When the owner confirms "Remove Spanish"
Then the PUT returns 409 "language_in_use"
And the screen shows the Remove blocked dialog for Spanish and the list reloads
Scenario Outline: Social locale
Given an owner on Atelier clicks "Edit" on the French row
When the owner types "<input>"
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| input | state | id | visible |
| fr_FR | Social locale: edit | loc.l1.socialEdit | the dialog "Social locale for French" / "Used for og:locale on French pages. Leave it empty to omit the tag." with the monospace field "fr_FR", the help "Format: language_REGION, for example fr_FR. Letters only, one underscore." and "Cancel" and "Save social locale" |
| fr-FR | Social locale: invalid | loc.l1.socialInvalid | the field error "Use letters and one underscore, for example fr_FR." and "Save social locale" disabled |
Scenario: Saving a social locale
When the owner saves "fr_CA", or clears the field
Then a PUT with the full state is sent with social_locale "fr_CA", or null for an empty field
And a server 422 "invalid_social_locale" shows the same field error as the invalid state
Scenario: Setup banner
Given GET /v1/site/archive/languages returned "needs_setup" true
Then the page shows a banner with an action to /settings/sites/archive/languages/setup
And the banner has no frame, so only its presence and target are asserted and its copy is pending the designer
Scenario: Failure states with no frame
Then the Languages page failure reuses the Load failed copy
And the board's it, ja and zh-Hans tags are visual mocks only, the backend never holds them