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