Feature: Create a translation of an article version A user who can create articles adds a version of an existing article in another language. The version joins the story of the version it is created from, in the same site, as an offline draft. Two entry points share one implementation: POST /v1/article/{id}/translations and POST /v1/article/ with story_id. Hidden versions are never named, counted or hinted at. 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, with extra data in response headers And a "writer" is a verified user with content.create, content.update and content.read at scope all And a "restricted writer" holds content.create and content.read at scope own And site "journal" (id 1) has enabled languages en (default), fr, de, ar and disabled language es And story 5 in "journal" holds article 10 (lang en, online, created by user 1, title "Renovation costs") and article 11 (lang de, offline, created by user 2) And article 10 has body, excerpt, seo_title, seo_description, social_title, social_description, cover_image_id 3, cover_image_alt, social_image_id 4, canonical_url, noindex true, authors [Camille] and content_revision 4 Scenario: Copy from the source version Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts de renovation","copy":true} Then the response status is 201 and the body is the created article JSON And it has a new id, lang "fr", story_id 5, site_id 1, is_online false and the given title And body, excerpt, seo_title, seo_description, social_title, social_description, cover_image_id, cover_image_alt, social_image_id and authors equal article 10's And the slug is generated from the new title, not copied And canonical_url is empty, noindex is false and seo_dismissed is empty And created_by is the caller and revision is 1 And the source is article 10 with source_revision 4, captured in the same transaction as the copy And articles 10 and 11 are unchanged And no file or image row is duplicated Scenario: Copy from a chosen source Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Kosten","copy":true,"source_article_id":11} Then the response status is 201 And the content is copied from article 11 And the source is article 11 with its content_revision at that moment Scenario: Start empty Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts de renovation","copy":false} Then the response status is 201 And body, excerpt, SEO and social text are empty And there is no cover image and no social image And authors is the creating user's byline And there is no source and GET shows "source": null Scenario Outline: Request validation Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with Then the response status is And the response body is And nothing is stored Examples: | body | status | message | | {"title":"Couts","copy":true} | 400 | the validate tag error text | | {"lang":"fr","copy":true} | 400 | the validate tag error text | | {"lang":"fr","title":"ab","copy":true} | 400 | the validate tag error text | | {"lang":"fr","title":"<201 characters>","copy":true} | 400 | the validate tag error text | | {"lang":"fr","title":"Couts"} | 400 | apperr.BodyErrorMessage | | {"lang":"fr","title":"Couts","copy":false,"source_article_id":10} | 400 | apperr.BodyErrorMessage | | {"lang":"fr", | 400 | apperr.BodyErrorMessage | | {"lang":"english","title":"Couts","copy":true} | 422 | invalid_language | | {"lang":"xx","title":"Couts","copy":true} | 422 | unsupported_language | | {"lang":"ja-JP","title":"Couts","copy":true} | 422 | unsupported_language | Scenario: The language tag is normalized Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with {"lang":"FR","title":"Couts","copy":true} Then the response status is 201 And the stored lang is "fr" Scenario: Language not configured on the site Given site "journal" has no language row for "pt" When a writer sends POST /v1/article/10/translations with {"lang":"pt","title":"Custos","copy":true} Then the response status is 422 and the body is "language_not_enabled" And nothing is stored Scenario Outline: One neutral answer when the language cannot be used Given a valid jwt cookie for the caller And When the caller sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts","copy":true} Then the response status is 409 and the body is "language_unavailable" And there is no X-Existing-Article-Id header And the body and the header set are identical across the three situations And nothing is stored Examples: | situation | | an fr version exists but is unreadable to the restricted writer (draft of another user) | | fr is disabled on the site | | a concurrent request created fr first (unique violation on (story_id, lang)) | Scenario: The language already exists and is readable Given a valid jwt cookie for a writer When the client sends POST /v1/article/10/translations with {"lang":"de","title":"Kosten","copy":true} Then the response status is 409 and the body is "language_exists" And the header X-Existing-Article-Id is 11 And nothing is stored When the client sends the same request with "lang":"en" Then the response status is 409 and the body is "language_exists" And the header X-Existing-Article-Id is 10 Scenario: Two concurrent requests for the same language Given a valid jwt cookie for a writer When two requests for lang "fr" on story 5 run at the same time Then exactly one answers 201 And the other answers 409 "language_unavailable" and stores nothing And story 5 holds exactly one fr version Scenario Outline: The path version and the source must be readable When the caller sends Then the response status is And the response body is And nothing is stored Examples: | request | status | message | | anonymous POST /v1/article/10/translations | 401 | Unauthorized | | reader without content.create POST /v1/article/10/translations | 403 | missing_permission | | writer POST /v1/article/9999/translations | 404 | Article not found | | restricted writer POST /v1/article/11/translations | 404 | Article not found | | writer POST /v1/article/abc/translations | 400 | apperr.IdUint32ErrorMessage | | writer POST /v1/article/10/translations with source_article_id 9999 | 404 | source_unavailable | | restricted writer POST /v1/article/10/translations with source_article_id 11 | 404 | source_unavailable | | writer POST /v1/article/10/translations with source_article_id 20 (other story) | 422 | source_other_story | Scenario: Access to the source is rechecked at submit Given a writer opened the translation dialog with source article 11 And article 11 was deleted after the dialog opened When the writer submits POST /v1/article/10/translations with source_article_id 11 Then the response status is 404 and the body is "source_unavailable", the same as for an unreadable source And nothing is stored Scenario: Unassigned articles cannot be translated yet Given article 30 has lang "" When a writer sends POST /v1/article/30/translations with {"lang":"fr","title":"Couts","copy":true} Then the response status is 409 and the body is "setup_required" And nothing is stored Scenario: The slug of the new version is free within site and language Given site "journal" already has an fr article with slug "couts-de-renovation" When a writer creates an fr translation titled "Couts de renovation" Then the new slug is the next free suffixed slug in (site 1, fr) When a writer creates a translation with the same title in a language with no clash Then the slug is the plain slug Scenario: Entry point 2 behaves exactly like entry point 1 Given a valid jwt cookie for a writer When the client sends POST /v1/article/ with {"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true} Then the response status is 201 And the result, errors, headers and stored data are exactly those of POST /v1/article/10/translations with the same lang, title and source And the same use case runs for both Scenario Outline: POST /v1/article/ is unchanged when it is not a translation (it creates in the default site when no site is given; a "site" key names the target site, see localization_translation_flows) Given a valid jwt cookie for a writer When the client sends POST /v1/article/ with Then the response status is And Examples: | body | status | effect | | {"title":"Plain","body":"x"} | 201 | a new story is created, lang is the site's starting language as before | | {"title":"Neuf","lang":"fr"} | 201 | a new story is created in fr | | {"title":"Neuf","lang":"pt"} | 422 | the body is "language_not_enabled" | | {"title":"Neuf","lang":"es"} | 409 | the body is "language_unavailable" (disabled) | | {"title":"Neuf","story_id":5} | 400 | the body is apperr.BodyErrorMessage | | {"title":"Neuf","lang":"fr","story_id":999} | 404 | the body is "Article not found" | | {"title":"Neuf","copy":true} | 400 | the body is apperr.BodyErrorMessage | | {"title":"Neuf","lang":"fr","story_id":5,"site":"atelier"} | 400 | the body is apperr.BodyErrorMessage (a site with a translation is refused) | | {"title":"Neuf","lang":"fr","site":"atelier"} | 201 | a new story is created in fr on site atelier | Scenario: PATCH still ignores lang, story_id and source fields Given a valid jwt cookie for a writer When the client sends PATCH /v1/article/10 with {"body":"x","lang":"fr","story_id":9} Then the response status is 200 And lang and story_id are unchanged Scenario: Activity records the creation Given a valid jwt cookie for a writer When a translation is created Then one "article.create" event (articles category) targets the new article And its params hold lang "fr", story_id 5 and source_article_id 10 When a creation is refused Then one failed event is recorded with a plain reason And nothing in it names a hidden version Scenario: A new version does not appear publicly Given a translation was created When a client sends GET /v1/article/{new id} without a jwt cookie Then the response status is 404 and the body is "Article not found" And the public list is unchanged Scenario: Persistence portability Given the unique (story_id, lang) violation, the free-slug retry and the concurrent create Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL