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

+177
View File
@@ -0,0 +1,177 @@
Feature: First-time language setup for existing articles
Existing articles have no language. One owner confirms a default language
for every site that has none, and all assignments apply in one transaction.
It labels articles. It never translates, links or moves them.
Background:
Given the language rules of features/article_localization_storage.feature apply
And error responses are plain text whose body is the snake_case code
And the setup page is global: its route is /settings/sites/:key/languages/setup, where :key is only the site it was opened from
And a site is "pending" when it has no confirmed default language
And these sites exist
| key | name | Locale | articles | state |
| journal | Journal | en | 8 | unconfirmed default row "en" |
| notes | Notes | null | 3 | no language row |
| archive | Archive | pt-BR | 5 | unconfirmed default row "pt-BR" |
| gallery | Gallery | en-GB | 0 | unconfirmed default row "en-GB" |
Scenario: Read what is pending
When an owner sends GET /v1/language/setup
Then the status is 200
And "sites" lists every pending site with key, name, locale, article_count and a suggestion
And the suggestion is { "lang": "pt-BR", "substituted": false } for archive and null for notes
And "available" lists the selectable tags like GET /v1/site/{key}/languages
And a site whose default is confirmed is not listed
Scenario Outline: Read access
When <caller> sends GET /v1/language/setup
Then the status is <status>
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
| an owner | 200 |
Scenario: Apply for every pending site
When an owner sends POST /v1/language/setup with
| key | lang |
| journal | en |
| notes | en |
| archive | pt-BR |
| gallery | en-GB |
Then the status is 200
And every listed site has a confirmed default language row with the chosen tag, enabled
And all 16 articles carry the chosen tag of their site
And the body says 16 articles were assigned, per site
And every article keeps its id, title, body, slug, is_online, story_id and timestamps
And no story is merged, split or moved
And GET /v1/site/journal/languages has "needs_setup" false
Scenario: The owner may change a suggestion
When an owner confirms "archive" with "pt" instead of "pt-BR"
Then archive has one language row "pt", confirmed and default
And no row "pt-BR" remains
And its 5 articles carry "pt"
Scenario: A site with no articles still needs confirming
When an owner posts every pending site except "gallery"
Then the status is 422 and the body is "setup_incomplete"
And nothing is applied
Scenario Outline: Rows that are refused
When an owner posts <body>
Then the status is <status> and the body is "<code>"
And no site and no article has changed
Examples:
| body | status | code |
| notes with lang "english" | 422 | invalid_language |
| notes with lang "xx" | 422 | unsupported_language |
| notes with an empty lang | 422 | invalid_language |
| the same site key twice | 422 | duplicate_site |
| a site key "nowhere" | 404 | site_not_found |
| no "sites" key, or malformed JSON | 400 | Invalid request body |
Scenario: Retrying is safe
Given setup succeeded
When the same POST is sent again
Then the status is 200 and zero articles are assigned
And no row, article or story changes
And a request that asks for a different tag on an already confirmed site changes nothing for that site
And the response lists that site key in "skipped" instead of dropping the choice silently
Scenario: Failure leaves nothing half done
Given the write fails while handling the second site
When an owner posts every pending site
Then the status is 500
And no site has a confirmed default and no article is assigned
And a later POST completes every site
Scenario: Another setup is running
Given another owner's POST holds the setup
When an owner posts
Then the status is 409 and the body is "setup_in_progress"
And nothing is applied
When the first setup has finished and the owner posts again
Then the status is 200 and the finished sites are skipped
Scenario: A lock error anywhere in the setup is a conflict
Given a site row, a read, a write or the commit of the setup fails on a database lock
Then the status is 409 and the body is "setup_in_progress" and nothing is applied
Scenario: A site that became pending after the first read is locked too
Given a site is created after the setup read the pending sites and before it locked them
Then the setup locks it as well before deciding, so it must be listed or the status is 422 "setup_incomplete"
And no pending site is ever confirmed without its lock
Scenario: Two simultaneous setups on SQLite
Given a database on SQLite
When two owners post the same body at the same time
Then each answers 200 or 409 and never 500, at least one answers 200
And every article is assigned exactly once
Scenario: Setup is recorded in the activity log
When an owner completes the setup for several sites
Then one "site.language_setup" event of the sites category is recorded per confirmed site after the commit, with the default_language change and the assigned count
And a refused setup records one failed event with a plain reason, and skipped sites record nothing
Scenario: Create racing setup never strands an article
Given journal has unassigned articles
When setup commits while articles are being created concurrently in journal
Then every article of journal ends with the confirmed default tag
And none is left unassigned and none has another tag
Scenario: Locks are taken in a fixed order
Given two owners post the same pending sites in different row orders
Then neither request deadlocks and both finish with 200 or one with 409 "setup_in_progress"
Scenario: Persistence portability of the setup transaction
Then one targeted task test:mysql and task test:postgres run covers the setup transaction and its lock order
Scenario: Sites created after the migration stay pending
Given a site is created after slice 1 and has no language row
Then it is listed as pending by GET /v1/language/setup
And seeding a suggested row on site create is out of scope and tracked as a follow-up
Scenario: Route and access
Given the route /settings/sites/:key/languages/setup
Then it is open to owners only (settings.manage_sites)
And the breadcrumb and back link go to the Languages page of :key
And it is reached from the "Set up languages" banner of a site with needs_setup true
Scenario Outline: Setup states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| the owner opens setup with Journal, Notes and Archive pending | Ready to assign | loc.l2.ready | heading "Set article languages", subtitle "Existing articles don't have a language yet. Confirm or change the language for each site.", the info alert "This labels articles. It doesn't translate them." / "Each existing article becomes the only version of its own article group. Each site's language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm."; rows "Journal 8 articles · Site locale en" with "English · en", "Notes 3 articles · No site locale" with "Choose a language" and the error "Required before you can apply.", "Archive 5 articles · Site locale pt-BR" with "Portuguese (Brazil) · pt-BR"; helper "Seeded from site locale en. Confirm or change.". The button "Confirm and assign to 16 articles" is disabled until every row has a language; the note "Counts are articles in 3 sites." |
| only Notes is pending | No-locale site | loc.l2.noLocale | the error alert "Choose a language for Notes" / "Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in." and the confirm button disabled |
| only Gallery is pending (0 articles, en-GB) | Site with no articles | loc.l2.empty | the page "Languages" / "Content languages for Gallery." with the card "Confirm the default language", "Gallery has no articles yet. New articles will be written in this language.", the field "Default content language" "English (United Kingdom) · en-GB" with "Seeded from site locale en-GB. The full locale is kept. Confirm or change it.", and a "Save" button |
| the owner confirms and the POST is in flight | Applying | loc.l2.applying | the info alert "Assigning languages" / "Articles created while this runs get the site's default language." and the busy button "Assigning…" disabled |
| the POST returns 409 setup_in_progress | Setup already running | loc.l2.inProgress | the info alert "Setup is already running" / "Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site's default language. Starting it again is safe: sites that already have a language are skipped." with a "Check again" button that repeats GET /v1/language/setup; the rows are read-only and the confirm button disabled |
| the POST returns 200 with 16 articles assigned | Done | loc.l2.done | the success alert "Languages assigned" / "16 articles now have a language. Nothing was translated, and no articles were grouped." with an "Open Articles" button; the confirm button is gone |
| the POST fails with a server error | Failed | loc.l2.fail | the error alert "Nothing was changed" / "The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe." with a "Try again" button; the rows keep their values |
Scenario: The page sends one request for all rows
When the owner confirms with Journal "en", Notes "en", Archive "pt-BR"
Then exactly one POST /v1/language/setup is sent with all three rows
And the Languages page of every listed site no longer shows the setup banner
Scenario: Nothing is pending
Given the owner opens the setup page and no site is pending, including after "Check again" resolves
Then the Done success alert "Languages assigned" is shown without an article count
And this state has no frame, so the assumption is recorded in the QA notes
Scenario: Unknown or already complete :key
Given the page is opened with a :key that is unknown or whose default is already confirmed
Then the page still lists the globally pending sites
And the back link and breadcrumb go to the Languages page of :key, or to Settings, Sites when the :key is unknown
And this case has no frame, so the assumption is recorded in the QA notes
Scenario: Substituted seed
Given a site's suggestion has "substituted" true (Locale "fr-BE" gives "fr")
Then the row shows the seeded tag "fr"
And its help text reuses the Languages "broader" wording, the closest drawn copy
And this state has no frame, so the assumption is recorded in the QA notes
+51
View File
@@ -0,0 +1,51 @@
# QA: First-time language setup
Accepted together with features/language_setup.feature.
## Automated, no server (task test)
1. Use case over in-memory fakes: "Apply for every pending site", "The owner may change a suggestion", "Retrying is safe", "A site with no articles still needs confirming", refused rows. Assert sentinels with errors.Is. Assert that the whole apply is one Transactor call and that site-row locks are taken in ascending site id order.
2. HTTP: NewHandler over the use case, a fresh chi.NewRouter mirroring api/router.go under /v1/language, behind require(role.KeySettingsManageSites), validator.InitValidator(), httptest. Cover 200, 400, 401, 403, 404, 409 and 422 with exact plain-text bodies and the JSON key set. setup_in_progress is simulated by a fake whose apply returns the sentinel.
3. Regression: slice 1 tests ("Create racing a confirmation", "Assigning a language is idempotent") untouched.
## Automated, SQLite file (task test)
4. Fixture: the four sites above with 8, 3, 5 and 0 articles, one soft-deleted. POST applies; assert ids, titles, bodies, slugs, is_online, story ids and timestamps unchanged; soft-deleted articles are assigned too; no pending site remains; a second POST changes nothing (hash of all rows). A site created after migration with no row is listed and can be confirmed.
5. Atomicity: inject a failing write on the second site; assert no row is confirmed and no article is assigned; the next POST completes.
6. Concurrency: goroutines creating articles during setup; every final lang equals the confirmed tag; bound the SQLITE_BUSY retries. Two concurrent POSTs: one 200, the other 200 or 409 setup_in_progress, never a deadlock.
## Automated, real databases (task test:mysql, task test:postgres)
7. One targeted run of steps 4 to 6 on MySQL 8.4, MariaDB 11 and PostgreSQL, sharing the run planned in the site_languages QA. Check lock ordering for deadlocks. Record NOT RUN if the daemon is down, or use the podman shim.
## Automated (Vitest + Vue Test Utils, task test:web)
8. One spec per row of "Setup states". Assert the copy, the disabled confirm button until every row has a language, that Journal and Archive are seeded and Notes is not, the single POST body, and the 409 / 500 transitions.
9. Mock set: GET with Journal, Notes, Archive (8, 3, 5 articles); GET with only Notes; GET with only Gallery; GET with nothing pending; GET with a substituted suggestion; POST 200 { assigned: 16 }; POST 409; POST 500.
10. Route guard: a reader is redirected; an owner reaches the page; the back link goes to /settings/sites/:key/languages, and to Settings, Sites for an unknown :key.
11. The Languages page banner links to /settings/sites/:key/languages/setup when needs_setup is true.
12. task test:web, the type check, bun lint, and a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
13. Run once at the end of the slice (group L2, 7 states) with the board fixtures Journal, Notes, Archive and Gallery (SETUP in locale-fixtures.js: 8, 3 and 5 articles). Desktop 1440 and mobile 390, light and dark.
14. Not checked visually (no frame): nothing-pending, unknown :key, substituted seed.
## Assumptions recorded for gaps without a frame
- Nothing pending: the Done alert without a count.
- Substituted seed: the Languages "broader" wording.
- Unknown or complete :key: the global list is shown and the back link falls back to Settings, Sites.
- Setup route: one global page (Option 1), reached from a per-site banner.
## Fix round additions
- Skipped list and stable lock loop: TestLanguageSetupSkippedChoices, TestLanguageSetupLocksUntilStable (test/localization_fix_http_test.go).
- Setup activity events: TestLanguageActivity.
- Two simultaneous setups on SQLite: TestLanguageConcurrencyOnSQLite.
## Manual
- Keyboard order and focus return after "Try again" and "Check again".
- Real two-owner run: start setup in two browsers and confirm the second sees "Setup is already running".
- A copy of a real database with articles: run setup, check the articles list and one public read still match, then restart and confirm nothing changes.
@@ -0,0 +1,42 @@
Feature: The visual checker covers the Localization board
web/visual discovers the Localization board, its groups, its 1024 px
captures and its fixtures, so that this slice and every later localization
slice is gated against docs/design.
Background:
Given the board is docs/design/source/RenewCMS Screens Localization.dc.html with frame LocaleFrame
And the state tables are docs/design/screens/localization-*.md and docs/design/states.md
And task visual:check is a hard gate: every capture within 10 %, no unmocked /v1 call, no size mismatch, no exception without the user's approval
And no threshold is changed
And nothing in docs/design is edited
Scenario: Groups are discovered from the screen file
When task visual:check runs for "localization-site-languages"
Then the checker reads the visual reference line and the three groups L1, L1b and L2
And it lists their 25, 9 and 7 states by their loc.* ids
And a state with no setup in web/visual fails the run
Scenario Outline: Capture sizes
Given a state lists <sizes> in its Sizes column
Then the checker captures the board frame and the SPA at <widths>, in light and dark
And a state with 1440 and 390 only gets no 1024 capture
Examples:
| sizes | widths |
| 1440, 390 | 1440x900 and 390x844 |
| 1440, 1024, 390 | 1440x900, 1024x? and 390x844 |
Scenario: Selecting frames
Given LocaleFrame is selected by LocaleFrame{s:{v:"sl",x:"multi"}}
Then the checker renders that state in the board and captures its frame
And a state is addressed by its loc.* id, not by the legacy name
Scenario: Fixtures and mocks
Then every state's setup uses the matching fixture of locale-fixtures.js (Journal, Atelier, Archive, Notes, Gallery)
And the mocked /v1 responses are derived from those fixtures
And the board's it, ja and zh-Hans tags are used by mocks only
And states that depend on the current date or time are skipped as clock-sensitive and nothing else is
Scenario: Existing screens are unaffected
When task visual:check runs for any screen that existed before
Then its states, sizes and verdicts are the same as before
@@ -0,0 +1,19 @@
# QA: Localization board in the visual checker
Accepted together with features/localization_visual_checker.feature.
## Automated (task test:web)
1. Unit specs next to web/visual (or its existing test location) for: group discovery from localization-site-languages.md (25, 9, 7 states and the ids), parsing of the Sizes column (1440, 1024, 390), mapping an id to its selector and to the fixture, and the failure on a state with no setup.
2. Regression: the existing screen and state lists parse the same as before (snapshot of the current discovery).
## Visual
3. Run task visual:check -- localization-site-languages once at the end of the slice. Every capture of the 41 states, at every listed size, in light and dark, must be within 10 %. The 1024 captures are for loc.l1.multi and loc.l1.social. Clock-sensitive states: none expected.
4. Run one existing screen (for example task visual:check -- profile) and confirm the same pass as before.
5. No threshold increase and no exception without the user's approval.
## Manual
- Open the Localization board in a browser and compare the LocaleFrame selectors used with the checker log.
- Check that the first run on a machine still needs bunx playwright install chromium in web/.
+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
+61
View File
@@ -0,0 +1,61 @@
# QA: Site languages (API and Languages page)
Accepted together with features/site_languages.feature. Backend: Go tests in test/, black-box. SPA: Vitest plus the visual gate.
## Automated, no server (task test)
1. Use cases over in-memory fakes of the language, site and article repositories (unique type names, shared package). Table-driven over "Rules that reject a change", "Social locale format and language part", "Changing the set", "A stale revision" and "Sending the same state again". Assert the Err sentinels with errors.Is. Assert that the update, the count of versions per language and the revision comparison run inside the Transactor under the site-row lock.
2. Revision digest: same rows give the same revision, any change of lang, default, enabled or social_locale gives another one, counts do not.
3. HTTP: NewHandler over the use cases, a fresh chi.NewRouter mirroring api/router.go (GET open to any verified user, PUT behind require(role.KeySettingsManageSites)), validator.InitValidator(), httptest, a jwt cookie signed with a test authtoken secret. Cover read, 401, 403, 404, 400, 409, 422 and 200. Assert status and exact plain-text body for every error. Assert the JSON key set of the read.
4. Regression: existing site tests (the site JSON gains public_url_pattern only in the public-url feature) and article tests untouched. The internal/domain/language package is unchanged (diff check).
## Automated, SQLite file (task test)
5. Migration: a slice 1 database gains site_languages.social_locale and sites.public_url_pattern. Existing rows are unchanged. A second Migrate changes nothing (sqlite_master and a row hash).
6. PUT atomicity: inject a failing write on the third row; assert nothing changed. Assert language_in_use against a draft, an offline and an online article, and that a soft-deleted article does not count.
7. Concurrency: N goroutines create articles while PUT changes the default. Assert every article has the default current at its lock and none has lang "". Retry SQLITE_BUSY and bound the retries.
## Automated, real databases (task test:mysql, task test:postgres)
8. One targeted run of steps 5 to 7 (columns, PUT atomicity, version count) on MySQL 8.4, MariaDB 11 and PostgreSQL. The setup transaction is covered by the language_setup QA. Record NOT RUN if the daemon is down, or use the podman shim.
## Automated (Vitest + Vue Test Utils, task test:web)
Mock /v1 per case. Do not freeze the system date.
9. One spec per row of "Page states", "Add a language", "Row actions" and "Social locale". Assert the exact copy, the accessible names ("Edit social locale for French", "Actions for French"), the request body of each PUT (full state, the revision from the last GET, social_locale null for an empty field) and that blocked states send no request.
10. Mock set: GET 200 for Journal, Atelier and Archive (counts from the board fixtures), GET 5xx, PUT 200, PUT 500, PUT 409 languages_changed, PUT 409 language_in_use, PUT 422 invalid_social_locale.
11. Route guard: a reader reaches the page and sees no controls; an anonymous visitor is redirected to login.
12. SitesView: each row has "Languages" and "Public URL" actions with the right targets, for owners.
13. Add dialog: a disabled language is listed "Disabled — re-enable instead" and cannot be added. Row menu items are Make default, Disable, Remove; Re-enable is a row button.
14. i18n en/fr parity for every new key.
15. task test:web, the type check, bun lint, and a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
16. Run once at the end of the slice (group L1, 25 states). Desktop 1440 and mobile 390, light and dark. 1024 for loc.l1.multi and loc.l1.social. Reuse the board fixtures: Journal (4 enabled, es and it disabled), Atelier (social locale), Archive (single), counts from locale-fixtures.js. The board's it, ja and zh-Hans are mocks only.
17. Known risk: the mobile rows are taller than needed (localization-corrections.md); tune in implementation, never raise the threshold.
18. Not checked visually (no frame): setup banner, row menu, disabled language in the picker, SitesView row actions.
## Assumptions recorded for gaps without a frame
- Setup banner: presence and target only, copy pending the designer.
- 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.
## Fix round additions
- Soft-deleted version keeps the language in use: test/localization_fix_http_test.go (TestLanguageInUseCountsSoftDeletedVersions) and the sqlite persistence case.
- Lock contention to 409: TestLanguageLockContention, TestDriverLockContention, TestTransactorClassifiesLockContention.
- SQLite concurrency: TestLanguageConcurrencyOnSQLite (docs/api/site-languages.md explains why FOR UPDATE is a no-op there).
- Activity: TestLanguageActivity.
## Manual
- Keyboard order and focus return in dialogs and the row menu; Escape closes; Tab order on mobile (44 px targets).
- Dialog motion, hover and reduced-motion.
- Two owners editing at once, in two browsers: the second sees "Languages changed. Reload."
- Intl.DisplayNames names (for example "Portuguese (Brazil)") in en and fr.
- docs/ gains the HTTP contract (shapes and codes above) and the accepted visibility exception for the next SPA slices.
- task mutate scoped with MUTATE_FILES to the new use cases.
+121
View File
@@ -0,0 +1,121 @@
Feature: Public URL pattern of a site
An owner can give each site one pattern for the address of its articles on
the website, with {lang} and {slug}. It is validated on the server. When it
is unset the product shows "Public URL not configured" and invents no URL.
Background:
Given error responses are plain text whose body is the snake_case code
And an "owner" is a verified user holding settings.manage_sites and a "reader" is a verified user without it
And the pattern is stored on the site as public_url_pattern, a string or null, at most 512 characters
And it is read through the existing GET /v1/site/ list (every verified user) and written through the existing PATCH /v1/site/{id} (owner)
And no new route is added
And the design in docs/design/localization.md (L1b) is the visual spec of record
Scenario: Reading the pattern
Given site 1 has public_url_pattern "https://journal.example.com/{lang}/{slug}"
When a reader sends GET /v1/site/
Then the item for site 1 contains "public_url_pattern" with that value
And every key present before this change is present with an unchanged value and type
Scenario Outline: Writing the pattern
When an owner sends PATCH /v1/site/1 with {"public_url_pattern": <value>}
Then the status is <status> and <effect>
Examples:
| value | status | effect |
| "https://journal.example.com/{lang}/{slug}" | 200 | the site returns that value |
| "https://journal.example.com/{lang}/articles/{slug}" | 200 | the site returns that value |
| "http://localhost:3000/{slug}" | 200 | the site returns that value |
| "https://journal.example.com/{slug}/{slug}" | 200 | the site returns that value (repeats allowed) |
| null | 200 | the pattern is unset |
| "" | 200 | the pattern is unset |
| " https://journal.example.com/{slug} " | 200 | the stored value is trimmed |
Scenario Outline: Patterns that are refused
When an owner sends PATCH /v1/site/1 with {"public_url_pattern": "<value>"}
Then the status is 422 and the body is "<code>"
And the stored pattern is unchanged
Examples:
| value | code |
| ftp://journal.example.com/{lang}/{slug} | invalid_scheme |
| journal.example.com/{slug} | invalid_scheme |
| https:///{slug} | invalid_host |
| https://journal.example.com/{language}/{slug} | unknown_placeholder |
| https://journal.example.com/{lang | unknown_placeholder |
| https://journal.example.com/my pages/{lang}/{slug} | not_encoded |
| https://journal.example.com/é/{slug} | not_encoded |
| https://journal.example.com/{lang} | missing_slug |
| a pattern of 513 characters | invalid_pattern |
Scenario: A pattern is checked in a fixed order
When the pattern is "ftp://x.example.com/{language} here"
Then the code is "invalid_scheme"
And with scheme fixed the code is "unknown_placeholder", then "not_encoded", then "missing_slug"
Scenario Outline: Only an owner can write
When <caller> sends PATCH /v1/site/1 with a valid pattern
Then the status is <status> and the stored pattern is unchanged
Examples:
| caller | status |
| an anonymous client | 401 |
| a reader | 403 |
Scenario: Saving the pattern changes nothing else
When an owner sets the pattern
Then no article, canonical URL or public response changes
And the site's other fields are unchanged
Scenario: Existing site behaviour is unchanged
Then the existing site tests pass without edits and a PATCH without public_url_pattern leaves it unchanged
Scenario: Route and access
Given the route /settings/sites/:key/public-url
Then it is open to every verified signed-in user
And owners can edit, other users see the Read-only view
And owners reach it from the "Public URL" action on the Settings, Sites row, non-owners by URL only
Scenario Outline: Public URL states
When <trigger>
Then the screen is "<state>" (<id>) and shows <visible>
Examples:
| trigger | state | id | visible |
| an owner opens Journal with the pattern saved | Set | loc.l1b.set | heading "Public URL", subtitle "Where Journal articles appear on your website. Used for canonical and alternate links.", the monospace field "Public URL pattern" holding https://journal.example.com/{lang}/{slug}, the help "Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.", the preview line and "Save" and "Cancel" |
| the owner types https://journal.example.com/{lang}/articles/{slug} | Editing | loc.l1b.editing | the preview follows the field on each change |
| the owner types a pattern with {language} | Unknown placeholder | loc.l1b.unknown | the error "Unknown placeholder {language}. Use {lang} and {slug}." and Save disabled |
| the owner types ftp://journal.example.com/{lang}/{slug} | Bad scheme | loc.l1b.scheme | the error "Start with https:// or http://." and Save disabled |
| the owner types a pattern with a space in the path | Not encoded | loc.l1b.encoded | the error "Remove spaces and characters that need encoding. Use letters, digits, - and _." and Save disabled |
| the owner types https://journal.example.com/{lang} | Missing {slug} | loc.l1b.noSlug | the error "Add {slug} so each article has its own address." and Save disabled |
| the owner saves and the PATCH returns 200 | Saved | loc.l1b.saved | a success toast; the field holds the saved value |
| an owner opens a site with no pattern from "Public URL not configured" | Unset | loc.l1b.unset | the info alert "Public URL not configured" / "Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.", the placeholder https://example.com/{lang}/{slug}, no preview and Save disabled |
| a reader opens Journal | Read-only view | loc.l1b.viewOnly | the alert "Only owners can change this" / "You can read the pattern. Ask an owner to change it.", the field read-only and no Save or Cancel |
Scenario: Client and server agree
Given the page validates as the owner types
When the server still returns 422 with unknown_placeholder, invalid_scheme, not_encoded or missing_slug
Then the field shows the same error as the matching state above
Scenario: Preview
Given the preview uses a real article of the site, shown with the site's default language
Then {lang} is replaced by the tag and {slug} by the article slug
Scenario: Preview of a site without articles
Given the site has no articles
Then the preview line is not shown
And this state has no frame, so the assumption is recorded in the QA notes
Scenario: Cancel
When the owner edits the field and presses "Cancel"
Then the field returns to the saved value and nothing is sent
And the design does not describe Cancel, so the assumption is recorded in the QA notes
Scenario: Clearing the field
When the owner empties the field and saves
Then a PATCH with null is sent and the site shows the Unset state
Scenario: Failures with no frame
When the load or the save fails with a 5xx
Then the page reuses the Languages "Couldn't load languages" / "Couldn't save languages" alert copy
And these states are skipped by the visual gate
+38
View File
@@ -0,0 +1,38 @@
# QA: Public URL pattern
Accepted together with features/site_public_url_pattern.feature.
## Automated, no server (task test)
1. Domain: table-driven over "Patterns that are refused" and "Writing the pattern" and the check order. Assert the new Err sentinels with errors.Is.
2. HTTP: the existing site handler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring NewSiteRouter, validator.InitValidator(), httptest, jwt cookie. Cover GET /v1/site/ (key set, new field) and PATCH /v1/site/{id}: 200, 401, 403, 422 with exact plain-text bodies, and a PATCH without the field. Assert no new route exists.
3. Regression: the existing site tests untouched.
## Automated, SQLite file (task test)
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)
5. One spec per row of "Public URL states". Assert exact copy, that Save is disabled for each invalid state, the live preview, the PATCH body (value or null) sent to /v1/site/{id}, and the mapping of server 422 codes to the same messages.
6. Mock set: GET /v1/site/ with Journal set, Journal unset, GET /v1/article/ with one article (title "Comment nous chiffrons une rénovation de salle de bain", lang fr), PATCH 200, PATCH 422.
7. Route guard: a reader sees the Read-only view; an anonymous visitor is redirected.
8. Cancel resets to the saved value without a request; a site with no articles shows no preview.
9. i18n en/fr parity, task test:web, the type check, bun lint, a diff check for comments.
## Visual (task visual:check -- localization-site-languages, hard gate at 10 %)
10. Run once at the end of the slice (group L1b, 9 states). Desktop 1440 and mobile 390, light and dark. Board fixture Journal. The preview article is the board's French sample: it comes from the mocked article list.
11. Skipped (no frame): save failure, load failure, no-articles preview.
## Assumptions recorded for gaps without a frame
- Cancel resets the field to the saved value.
- No articles: no preview line.
- Save and load failures reuse the Languages alert copy.
- The design's GET/PUT /v1/site/{key} is replaced by the existing list and PATCH /v1/site/{id}.
## Manual
- Keyboard and focus order, error announcement, reduced motion.
- A real owner edit on a dev server: save a pattern, reload, clear it.