22 KiB
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.
- Site decides placement, Examples-driven: omitted
site,"site":null,"site":"journal","site":"atelier"with and withoutlang; assert status 201,site_id,lang(starting language of the chosen site, never journal's),is_onlinefalse, new story,created_by, and that the response key set equals the pre-change key set (no new field). - Refusals, Examples-driven over the table:
missing404site_not_found(plain text body, no JSON),Bad Key!and""400invalid_site_key(a short title keeps the validator text),eson atelier 422language_not_enabled,fron notes 422,eson journal 409language_unavailable,pt,xx,english, short title, reader 403missing_permission, anonymous 401Unauthorized. Each: nothing stored in the fake. Refusals reaching the use case (404, 409, 422, short title) record one failedarticle.createevent with a plain reason; refusals before it (400 validation ofsite, the site with story_id/source_article_id/copy 400, 401, 403) record no event, as before. - Setup required: site
freshwithlangfrgives 409setup_required; nothing stored. - Language rules follow the site: the same
{"lang":"fr"}body is 201 on atelier and 409language_unavailableon journal. - 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.
- 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/translationsstill creates in site 1 and ignores asitefield. - 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=atelierfor a writer; the event params hold the site key. - 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 *stringfield withvalidate:"omitempty,sitekey"and the pass-through inCreateArticleCommand; 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
- useApi exposes the four response headers on
ApiErrorfor non-ok answers, leavesmissing_permissiontoasting by default and honoursonMissingPermission: 'passthrough'; headers absent giveundefined. - Article type and draft:
revisionread from GET and PATCH answers; the stored draft carries the base revision; a draft with no revision uses the revision loaded now (updatestoredDraft,useArticleDraftspecs).
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
sitefield 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_langsfollow-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
siteplusstory_idrefusal), 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
sitekey answers 400invalid_site_key.