feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -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
|
||||
@@ -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/.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user