Files
RenewCMS/features/localization_translation_flows.qa.md
T

122 lines
22 KiB
Markdown

# QA: Translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
Accepted together with features/localization_translation_flows.feature. One pair, one pass: a Go backend part (POST /v1/article/ accepts `site`) comes first, then the SPA, which mocks `/v1` from the documented contract. Nothing else outside `web/` and `docs/` changes.
## Backend (Go, `task test`, package `test`)
Build `article.NewHandler(...)` over the real `CreateArticleUseCase`, `GetArticleUseCase`, `CreateTranslationUseCase`, `DescribeArticleUseCase` etc. wired to in-memory fakes (the existing `mockArticleRepo`, `mockSiteRepo` and language/localization/transaction fakes of `test/site_support_test.go` and `test/article_translation_create_http_test.go`; reuse, add no second copy). Mount it on a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article` (protected routes inside `authtoken.Verifier` + `authtoken.Authenticator` with a test token key and a cookie for each caller: writer, reader, anonymous), call `validator.InitValidator()`, exercise with `httptest`. No live server, no SQLite file, no SMTP. A new file `test/article_create_site_http_test.go` (unique fake type names) holds the HTTP cases; use-case cases go next to `test/article_create_language_test.go`.
Fixture: sites journal (id 1, default; en default, fr, de, ar; es disabled), atelier (id 2; en, fr, de), notes (id 3; en), archive (id 4; en), fresh (id 5; no confirmed rows). Articles: story 5 with 10 (en), 11 (de), 12 (fr), 13 (ar) in journal. A fr article with slug `couts` in journal and in atelier for the slug cases.
1. **Site decides placement, Examples-driven**: omitted `site`, `"site":null`, `"site":"journal"`, `"site":"atelier"` with and without `lang`; assert status 201, `site_id`, `lang` (starting language of the chosen site, never journal's), `is_online` false, new story, `created_by`, and that the response key set equals the pre-change key set (no new field).
2. **Refusals, Examples-driven over the table**: `missing` 404 `site_not_found` (plain text body, no JSON), `Bad Key!` and `""` 400 `invalid_site_key` (a short title keeps the validator text), `es` on atelier 422 `language_not_enabled`, `fr` on notes 422, `es` on journal 409 `language_unavailable`, `pt`, `xx`, `english`, short title, reader 403 `missing_permission`, anonymous 401 `Unauthorized`. Each: nothing stored in the fake. Refusals reaching the use case (404, 409, 422, short title) record one failed `article.create` event with a plain reason; refusals before it (400 validation of `site`, the site with story_id/source_article_id/copy 400, 401, 403) record no event, as before.
3. **Setup required**: site `fresh` with `lang` `fr` gives 409 `setup_required`; nothing stored.
4. **Language rules follow the site**: the same `{"lang":"fr"}` body is 201 on atelier and 409 `language_unavailable` on journal.
5. **Slug per (site, lang)**: clash in the same site and language gives the suffixed slug; the same slug in another site or another language leaves the plain slug.
6. **Site with a translation is refused**: the four bodies of the Examples table give 400 `apperr.BodyErrorMessage`, nothing stored, and the translation use case is not reached (assert with the fake). `POST /v1/article/10/translations` still creates in site 1 and ignores a `site` field.
7. **Visibility and activity**: the new offline article is 404 for an anonymous GET, absent from the public lists of both sites, listed by `GET /v1/article/?site=atelier` for a writer; the event params hold the site key.
8. **Dependency rule**: the handler test imports no persistence, models or gorm; a grep-style check is not added here (architect covers it). The handler change is the `Site *string` field with `validate:"omitempty,sitekey"` and the pass-through in `CreateArticleCommand`; no domain, repository, mapper or migration change.
Real-database runs: `task test:mysql` and `task test:postgres` are not required, because no query, model or repository changes (existing `GetByKey`, `LockSite`, `Rows`, `SlugTaken` and `Create` are already covered per site by `test/article_localization_storage_persistence_test.go` and `test/site_persistence_test.go`). If the implementation adds any query, add a persistence case there and run both.
Existing Go specs to amend (and nothing else): `test/article_create_language_test.go` (command carries the site key), `test/article_translation_create_http_test.go` (rows of the "unchanged when it is not a translation" table, the `site` plus `story_id` refusal), `test/article_title_rules_test.go` and `test/use_case_baseline_test.go` only where they build `CreateArticleCommand` or `PostArticle` (compile and default-site behaviour must be unchanged), `test/api_catalogue_drift_test.go` stays green because the route list does not change (check it still passes; if the catalogue documents request fields, update it with the docs). `test/seo_data_cleaner_test.go` fails `GetDefault` on purpose and must keep passing for the omitted-site path.
Docs (the HTTP contract the SPA mocks): update `docs/api/article-localization.md` (the `POST /v1/article/` without `story_id` section and its status table with the `site` rows above) and `docs/api/articles.md` (the `POST /v1/article/` request body), and add the `site` field to the create recipe in `docs/llm/recipe-articles.md`. The SPA mocks are written from these pages, so they land with the Go change, before the SPA work.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` per case (no backend), from the documented contract above. Mount EditorView in DashboardShell with a resolved verified session (user id 7, username "marta") and a test router with `/article/:id/edit` and `/article`; mount NewArticleModal from ArticleView and HomeView. Use fake timers for any delay. Specs live in `web/src/views/__tests__/`, `web/src/components/__tests__/`, `web/src/composables/__tests__/` and `web/src/lib/__tests__/`, reusing `editorKit.ts` and `localizationKit.ts`.
Fixture: the 5a fixture (journal, story 5; article 10 en online "Renovation costs"; 12 fr offline "Couts de renovation"; 13 ar online "Takalif" read-only; 11 de readable by the owner) with `revision` added (12: 3). `GET /v1/site/` mocked with journal (default), atelier, notes, archive. `GET /v1/site/{key}/languages` mocked for journal (en default, fr, de, ar, es disabled), atelier (en, fr, de), notes (en), archive (en). Error mocks reject with `ApiError` carrying the plain-text body, `code` and the headers X-Existing-Article-Id, X-Current-Revision, X-Missing-Languages, X-Conflicting-Articles (the useApi change is tested first, item 1).
Client plumbing
1. **useApi exposes the four response headers** on `ApiError` for non-ok answers, leaves `missing_permission` toasting by default and honours `onMissingPermission: 'passthrough'`; headers absent give `undefined`.
2. **Article type and draft**: `revision` read from GET and PATCH answers; the stored draft carries the base revision; a draft with no revision uses the revision loaded now (update `storedDraft`, `useArticleDraft` specs).
Add translation
3. **Entry points, Examples-driven**: language menu row, URL with and without `lang`, `lang` of an existing or disabled language, no `create_translation` (parameters removed, no dialog), list row menu navigation (`dialog=add-translation`, `&lang=` only when one language is missing); Back closes (loc.l4.form).
4. **Offered languages**: enabled minus translations[]; an unreadable existing version is offered; order follows the site.
5. **Form copy, source default and Start empty**: description, help text, radios, footer order, default source rule (default language readable, else first readable), the source option labels, the empty state hiding the source (loc.l4.form, loc.l4.empty).
6. **Title validation, Examples-driven** (nothing, "ab", 201 chars, 200 emoji): no request when invalid.
7. **Request bodies**: copy with a chosen source, start empty (no `source_article_id`); path id is the open version.
8. **Creating**: fields disabled, "Creating…" busy, Escape and backdrop inert (loc.l4.saving).
9. **Success**: dialog closes, route `/article/14/edit`, announcement "German draft created"; with unsaved edits the "Leave without saving?" dialog opens with the German labels and "Keep editing" stays.
10. **Failures, Examples-driven over the table**: 409 language_exists (Open German version navigates through the version switch, loc.dup.readable), 409 language_unavailable with the languages refetched (still enabled gives loc.dup.hidden with no Open action, no title and no id; disabled gives loc.l4.langOff), 422 language_not_enabled, 404 source_unavailable (loc.l4.unreadable), 500 and network (loc.l4.fail, entries kept, "Try again"), 403 with no toast, 400, 404 Article not found (Version unavailable). Assert the three neutral answers render the same DOM.
11. **Recovery from blocking states** (source, start empty, language) re-enables the primary.
12. **Keyboard and focus**: trap, first field, Escape (not while creating), focus returns to the opener.
13. **Long content notes**: authors and images notes from the fetched source for the thresholds in the table; no note for one author and no image (loc.l4.long).
New article modal
14. **Form by site, Examples-driven**: Archive (Title and Site only), Journal (Title, Site, Language, choice), languages request failing (Title and Site only, POST has `site` and no `lang`) (loc.l3.single, loc.l3.multi).
15. **Site field**: options in the order of `GET /v1/site/`, the `is_default` site chosen at open, one-site install still shows the field, sites request failing or empty hides it and POSTs `{title}` only.
16. **Site change** (loc.l3.sitechange): French on Journal then Notes gives English and the help "Notes only offers English, so the language was reset from French."; title kept; the help clears on the next change; Atelier keeps French with no message; languages request failing for the chosen site hides Language and the choice and POSTs `{title, site}`.
17. **Copy**: new description and "Create article"; the old sentence is gone; existing validation, counter, submitting, 400 and Error states of features/new_article_modal.feature unchanged.
18. **Create request**: body `{title, lang, site}` for Atelier and French, `{title, lang:"en", site:"journal"}` on the default site; failures: 409 `language_unavailable` and 422 `language_not_enabled` refetch the chosen site's languages and show the Error alert; 404 `site_not_found` refetches `GET /v1/site/`, resets to the default site and shows the Error alert; 409 `setup_required` and 403 `missing_permission` give the Error state with no global toast.
19. **Translation of an existing article**: `GET /v1/article/?tab=missing&lang=fr&q=...` mocked; no Site field in this mode; picker contents, help text, POST body with `story_id`, `source_article_id` (default-language version), `copy: true` and no `site`; failures reuse the Add translation alerts (loc.l3.translation). Switching the choice back and forth keeps the title and the site.
Revisions and conflicts
20. **if_revision on every save path, Examples-driven**: Save, Save and publish (save step only), Save and open English, Try saving again, title Try again; publish and unpublish carry no body; base revision updates from each 200.
21. **Restored draft**: written against revision 3 while the server is at 5; Save sends 3 and the conflict dialog shows the Restored notice (loc.l11.conflictRestored).
22. **Conflict dialog**: PATCH 409 plus X-Current-Revision then GET; copy, option order, only "Keep my changes" filled, focus, Escape equals Keep, edits and unsaved state untouched (loc.conflict). Mobile sheet at 390 shows all four options.
23. **Chained actions stop**: Save and publish (no `/publish`), Save and open English (no GET 10).
24. **Conflict reload fails**: GET 500 or network shows Save failed, no dialog.
25. **Keep my changes**; **Download my version** (Blob content: title, blank line, body text; toast and announcement "Download started"; dialog stays; no request; loc.l11.download); **Reload theirs** two-step (loc.l11.reloadConfirm), discard clears dirty state, stored draft and sets the base revision; Keep from the confirm step.
26. **Save over theirs**: PATCH with the shown revision, no refetch before it, 200 closes the dialog and updates the base revision, no request for other articles.
27. **Second conflict** (loc.l11.conflict2): GET repeated, newer time 14:41, alert copy, revision updated; **other failures** (500, network, 401 mirror and redirect, 403, 404).
Deletes
28. **Menu by scope, Examples-driven** over the three rows: enabled and disabled items, reasons, single version.
29. **Delete French version dialog and request** (loc.l8.delete): copy, danger button, busy state, DELETE 12 only, navigation to /article, toast; 404 closes quietly.
30. **Delete the only version** (loc.l8.deleteLast) and **delete the whole article** (loc.l8.deleteStoryOk): names in site language order, no hidden version, sequential DELETE (assert each call starts after the previous resolved) once per readable version with the open one last, navigation after the last 204.
31. **Refused whole delete** (loc.l8.deleteStory): first DELETE 403 gives the blocked dialog, no toast, "Delete French version" switches to the confirmation. **Stops half way**: the second DELETE answering 500, 403 or network error means the remaining versions are not requested, the toast "Couldn't delete the whole article" with "Some versions were deleted. Open the language menu to see what is left." shows, and the editor refetches or shows Version unavailable. A first-call failure other than 403 stops the same way.
32. **Single-language wording** unchanged.
Move
33. **Panel and note**: opens from the Site field and from "Move article"; listbox "Site", current site marked, note rows with Published/Draft/Read only, "Move article…" disabled until another site; choosing a site does not enable Save or send site_id (loc.l11b.moveSelect). No sentence about unreadable versions and no footnote.
34. **Confirm, busy and done** (loc.l11b.moveConfirm, moveBusy, moveDone): request body `{site:"atelier"}` to `/v1/story/5/move`, locked dialog, toast and announcement "Moved to Atelier", silent refetch keeps unsaved edits, next Save uses the new revision.
35. **Refusals, Examples-driven over the table** (loc.l11b.move, moveSlug, movePerm): X-Missing-Languages names, the destination's languages from GET site languages, slug from GET article of each id in X-Conflicting-Articles (two ids give two sentences, no Edit slug button), 403 passes through without a global toast, 500/network, site_not_found, 404.
36. **Keyboard and focus** of the panel and its dialogs.
Existing specs to amend (and nothing else)
37. Re-run and update `EditorView.spec.ts`, `EditorHeaderActions.spec.ts` (overflow items no longer "Not available yet." except Edit slug), `EditorLanguageMenu.spec.ts` (Add translation rows act), `EditorRecovery.spec.ts` (draft carries the revision), `EditorPublishing.spec.ts` (PATCH carries if_revision), `NewArticleModal.spec.ts` (title-only form becomes Title, Site, Language), the specs of `MetaSection` / sites_authors_integration (the Site field opens the move panel, the old choose-then-Save path is removed), `ArticleView` row menu (Add translation URL), `useArticleDraft*.spec.ts`.
## Visual (`task visual:check`)
Reference: docs/design/screens/localization-new-article.md, localization-editor.md and localization-conflicts-moves.md, boards in docs/design/source/. Register the screen `localization-new-article` in `web/visual/states.ts` (a new states file next to `editorLocalizationStates.ts`; it is not registered today), and replace the `pending` placeholders of the in-scope states below in `web/visual/editorLocalizationStates.ts`. Reuse the board's Journal, Archive, Atelier and Notes fixtures from `locale-fixtures.js` (article titles, the board's French title, Marta Alves, "Spring newsletter" for loc.l3.sitechange, the times 14:18, 14:32 and 14:41, the ten-author and eight-image source of loc.l4.long). Every capture must be within 10 %, light and dark, at the listed sizes. No exception or threshold change without the user's approval.
Run state by state while iterating (`task visual:check -- localization-new-article loc.l4.form`, ...), the three screens whole once at the end.
In scope (29), 1440 and 390:
- localization-new-article (13): loc.l3.single, loc.l3.multi, loc.l3.translation, loc.l3.sitechange, loc.l4.form, empty, saving, loc.dup.readable, loc.dup.hidden, loc.l4.fail, loc.l4.unreadable, loc.l4.langOff, loc.l4.long.
- localization-editor (4): loc.l8.delete, loc.l8.deleteLast, loc.l8.deleteStoryOk, loc.l8.deleteStory.
- localization-conflicts-moves (12): loc.conflict, loc.l11.conflictRestored, loc.l11.reloadConfirm, loc.l11.conflict2, loc.l11.download, loc.l11b.moveSelect, moveConfirm, moveBusy, moveDone, loc.l11b.move, moveSlug, movePerm.
Skipped (4), each `skip: 'pending'` in the setups (the existing checker reason; they print SKIP and are not investigated): loc.l4.gone, loc.l8.deleteSource, loc.l11b.moveSelectHidden, loc.l11.session. Causes are in Known gaps.
Clock-sensitive: none expected. The conflict times (14:32, 14:41, 14:18) come from mocked `updated_at` values and the stored mirror time, not from `now`. If a setup depends on the real clock, list it as `skip: 'clock-sensitive'`.
## Manual
- Focus order and focus return: dialogs and sheets return focus to their opener, the conflict dialog to the title, the Add translation dialog to the language-menu control.
- Screen reader: "German draft created", "Download started", "Moved to Atelier", dialogs announced as modal, Escape equals Keep my changes.
- Real round trip against `task dev`: create an article on a second site in another language and see it in the list of that site, add a translation (copy and empty) and open it, a real revision conflict from two browser sessions with all four choices, delete a version and the whole article, move a story to a second site with a missing language and with a slug clash, mobile sheets at 390 px.
- A real browser download of the conflict text file (Vitest checks the Blob only).
- Hover, pressed and motion states of the new dialogs; reduced motion.
## Known gaps and scope decisions
- "Save over theirs" has no destructive cue: it is drawn as an equal-weight secondary button next to "Reload theirs", as designed. Design gap to report to Claude Design; no cue is invented.
- Copy not in the design and proposed in the feature, to be confirmed by Claude Design: the toast "French version deleted", "Couldn't delete the whole article" with "Some versions were deleted. Open the language menu to see what is left.", "Couldn't move the article", "Couldn't replace theirs" for a non-conflict failure, and the Language help "Default for Atelier." pattern for any site.
- Skipped states and their cause: loc.l4.gone (a deleted source and an unreadable source give the same neutral 404, so loc.l4.unreadable covers both); loc.l8.deleteSource (siblings that track a version are unknown to the client; deferred); loc.l11b.moveSelectHidden (the client cannot tell a hidden version from a missing language; no footnote); loc.l11.session (the redirect to /login stays, the "Your session expired" dialog is not implemented). loc.l3.sitechange is now reachable through the backend `site` field and is checked.
- The Site field now also appears in the single-language form, as drawn (loc.l3.single shows Archive). Board copy kept verbatim, including the title help "0 / 200" difference with the existing "3 to 200 characters." text, recorded as a gap.
- The client cannot tell an unreadable sibling from a language with no version (the `missing_langs` follow-up on GET /v1/article/{id}), so Add translation is offered for every enabled language without a readable version and the neutral 409 answers the rest.
- "Delete the only version?" is shown when one version is readable, even if an unreadable one remains; the story then stays on the server.
- Deleting the whole article is one DELETE per readable version, sequential and stopping at the first failure other than 404 (a version already gone is skipped), not atomic. The stop-half-way toast is the only handling.
- content.create is not scoped to a site, so there is no "site the caller cannot create in" answer. A site deleted between the lookup and the insert surfaces as the existing 500 "Failed to create article".
- The copy text of the board mentions "Needs review" and "No source"; the freshness badge is release 2, so the words describe a state not yet visible.
- Edit slug: the "Edit slug" action of loc.l11b.moveSlug and the More actions item stay out until the Search and sharing slice.
- Article list row dialogs (Delete, Publish) are not changed in this slice.
- Amended in the same pair commit: features/article_translation_create.feature and .qa.md (the "unchanged when it is not a translation" table and wording, the `site` plus `story_id` refusal), features/localization_editor_navigation.feature and .qa.md (Background line on if_revision, overflow and Add translation scenarios, QA item 20, Known gaps), features/new_article_modal.feature and .qa.md (copy, Site, Language, translation path), features/sites_authors_integration.feature and .qa.md ("Changing the site is an edit"), features/articles_list.feature and .qa.md (Add translation destination), features/editor_core.feature and .qa.md (PATCH body carries if_revision; delete dialog). The Go specs listed under Backend are amended in the same commit.
- Architect fix round: a refetch after a move or a partial delete is retried once; a second failure other than 404 announces "This page may be out of date. Reload it to see the latest." politely and keeps the edits. In the New article modal a 404 "Article not found" source shows the unreadable-source alert (neutral copy, no claim of deletion); Add translation keeps closing with Version unavailable. A bad `site` key answers 400 `invalid_site_key`.