feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
Feature: Edit title and slug, and protect overwrites with a revision
A title can be edited at any time (3 to 200 runes). Editing the title never changes the slug. The slug changes only when sent explicitly, before or after publication, with no redirect. Every article read carries a "revision"; a PATCH may send "if_revision" so a stale editor cannot overwrite silently.
Background:
Given error responses are plain text (a snake_case code or an existing apperr message)
And a "writer" holds content.update and content.read at scope all
And article 10 (site 1, lang en, story 5, online, title "Renovation costs", slug "renovation-costs", authors [Camille]) has revision 3 and content_revision 4
And article 12 (site 1, lang fr, story 5, offline, slug "couts") exists
Scenario Outline: Title boundaries on PATCH, counted in runes
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title": <title>}
Then the response status is <status>
And <effect>
Examples:
| title | status | effect |
| 2 ASCII characters | 400 | the body is apperr.ArticleTitleErrorMessage, nothing changed |
| 3 ASCII characters | 200 | the title is stored |
| 200 ASCII characters | 200 | the title is stored |
| 201 ASCII characters | 400 | the title is unchanged |
| 200 repetitions of the 4-byte rune "😀" | 200 | the title is stored (200 runes, 800 bytes) |
| "" | 400 | the body is apperr.ArticleTitleErrorMessage, nothing changed |
| null | 400 | the body is apperr.BodyErrorMessage |
Scenario: Editing the title never changes the slug
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title":"New title"}
Then the response status is 200
And the slug is still "renovation-costs"
And the article is still online
Scenario: Title and body in one request
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"title":"New title","body":"<p>x</p>"}
Then both are stored in one write
And revision rises by exactly 1
Scenario: The title is editable on a single-language site
Given site "archive" has a single language
When a writer sends PATCH on an archive article with {"title":"New title"}
Then the response status is 200
And the slug is unchanged
Scenario Outline: Explicit slug edits
Given a valid jwt cookie for a writer
When the client sends PATCH with <request>
Then the response status is <status>
And <effect>
Examples:
| request | status | effect |
| /v1/article/10 {"slug":"new-address"} (online) | 200 | still online, no redirect row, the old slug reads as not found publicly |
| /v1/article/12 {"slug":"new-address"} (offline) | 200 | the slug is stored |
| /v1/article/10 {"slug":"renovation-costs"} (same as current) | 200 | nothing changes, revision unchanged |
| /v1/article/10 {"slug":"Not A Slug"} | 400 | the body is apperr.InvalidSlugErrorMessage |
| /v1/article/10 {"slug":"couts"} (used by another language of the same story) | 200 | the slug is stored |
| /v1/article/10 {"slug":"taken-en"} (used by another en article of site 1) | 409 | the body is "slug_taken" with the header X-Slug-Suggestion |
| /v1/article/10 {"slug":""} | 200 | the slug is regenerated from the current title |
Scenario: The same slug in two languages is not a conflict
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 with {"slug":"couts"}
Then the response status is 200
And article 10 (en) and article 12 (fr) both hold the slug "couts"
Scenario: Revision is exposed on every article read and write
Given a valid jwt cookie for a writer
When the client calls GET /v1/article/10, the list, POST, PATCH, publish and unpublish
Then each response carries "revision" as a positive integer
And every pre-existing key is unchanged in name, value and type
Scenario Outline: What raises revision and content_revision
Given a valid jwt cookie for a writer
When the client sends PATCH /v1/article/10 changing only <field>
Then revision rises by 1
And content_revision <content_effect>
Examples:
| field | content_effect |
| title | rises by 1 |
| body | rises by 1 |
| excerpt | rises by 1 |
| slug | rises by 1 |
| cover_image_alt | rises by 1 |
| seo_title | rises by 1 |
| seo_description | rises by 1 |
| social_title | rises by 1 |
| social_description | rises by 1 |
| author_ids | is unchanged |
| cover_image_id | is unchanged |
| social_image_id | is unchanged |
| site_id (single-version story) | is unchanged |
| canonical_url | is unchanged |
| noindex | is unchanged |
| seo_dismissed | is unchanged |
Scenario: Several content fields in one PATCH raise each counter once
When a writer sends PATCH /v1/article/10 changing title, body and excerpt
Then revision rises by 1 and content_revision rises by 1
Scenario: A PATCH that changes nothing leaves both counters alone
When a writer sends PATCH /v1/article/10 with the current values
Then the response status is 200
And revision and content_revision are unchanged
Scenario: Publishing and unpublishing do not touch revisions
When article 12 is published and then unpublished
Then its revision and content_revision are unchanged
Scenario: if_revision matches
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":3}
Then the response status is 200
And the response revision is 4
Scenario: if_revision is stale
Given another writer already saved article 10, so its revision is 4
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":3}
Then the response status is 409 and the body is "revision_conflict"
And the header X-Current-Revision is 4
And nothing is stored
And no successful "article.edit" activity event is recorded
Scenario: A stale if_revision wins over a no-op PATCH
Given article 10 has revision 4
When a writer sends PATCH /v1/article/10 with the current values and "if_revision":3
Then the response status is 409 and the body is "revision_conflict"
Scenario: Overwrite uses the revision the conflict dialog showed
Given the conflict dialog showed revision 4
When a writer sends PATCH /v1/article/10 with "if_revision":4
Then the response status is 200 and revision is 5
When another writer saved in between
Then the response is 409 "revision_conflict" with the then-current X-Current-Revision
And nothing is overwritten
Scenario: Concurrent saves with the same if_revision
When two PATCH requests with the same if_revision run at the same time
Then exactly one answers 200 and the other 409
And the compare and the write are one atomic step
Scenario: Without if_revision the last write wins
When a writer sends PATCH /v1/article/10 with {"body":"x"}
Then the response status is 200
And revision still rises by 1
Scenario Outline: Invalid if_revision
When a writer sends PATCH /v1/article/10 with {"body":"x","if_revision":<value>}
Then the response status is 400 and the body is apperr.BodyErrorMessage
Examples:
| value |
| 0 |
| -1 |
| "3" |
| 1.5 |
| null |
Scenario: if_revision alone is not an update
When a writer sends PATCH /v1/article/10 with {"if_revision":3}
Then the response status is 400 and the body is apperr.BodyErrorMessage
Scenario: Other languages are untouched by an overwrite
When a writer overwrites article 10 with a matching if_revision
Then article 12 keeps its content, its revision and its publication
Scenario: Permissions are unchanged
Then a caller without content.update gets 403 "missing_permission"
And a caller who cannot read the article gets 404 "Article not found"
And an anonymous caller gets 401
And a stale if_revision never reveals current content to a caller lacking permission because permission is checked first
Scenario: Editing an online article without content.publish changes the live page right away
Given a user holds content.update but not content.publish
When the user sends PATCH /v1/article/10 with {"title":"New title"}
Then the response status is 200
And a public read shows the new title
Scenario: Title and slug edits are recorded
When a writer edits the title and the slug of article 10
Then one "article.edit" event is recorded
And its changes include the title and the slug
And its params hold lang "en"
+23
View File
@@ -0,0 +1,23 @@
# QA: Edit title and slug, revision protection (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. UpdateArticle use case over fakes and a fake Transactor, table-driven over title boundaries, explicit slug edits and revision-raising fields. Assert article.ErrInvalidTitle, ErrInvalidSlug, ErrSlugTaken and the new revision-conflict sentinel with errors.Is. The revision compare-and-set and the write run in the same transaction. content_revision rises only for the listed fields.
2. HTTP via NewHandler, a chi router mirroring api/router.go, validator.InitValidator(), httptest and a jwt cookie. A PATCH with "title" present but empty must be 400 (guard against omitempty on a *string skipping validation). Assert body strings, X-Current-Revision, X-Slug-Suggestion, and "revision" in the read, list, POST, PATCH, publish and unpublish JSON, with old keys unchanged.
3. Two goroutines with the same if_revision: exactly one answers 200.
4. Regression: article_title_rules, article_publishing, SEO and pagination tests stay untouched, except the updated pair text.
## Automated, SQLite file (task test)
5. The migration adds edit_revision with default 1 for existing rows; a second Migrate changes nothing.
6. The compare-and-set is one UPDATE ... WHERE edit_revision = ? (or equivalent). With concurrent writers, one wins.
## Automated, real databases (task test:mysql, task test:postgres)
7. Repeat 5-6 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
8. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files. task mutate scoped via MUTATE_FILES to the revision and title code.
9. docs/api/articles.md gains title, slug, revision, if_revision and X-Current-Revision.
## Manual
None.
@@ -136,9 +136,11 @@ Feature: Article language, story and per-site language storage
| fr-CA | GET /v1/article/ list item |
| fr-CA | public article read of an online article |
Scenario: Article write payloads cannot set lang or story_id
When POST or PATCH /v1/article carries "lang" or "story_id"
Scenario: PATCH ignores lang and story_id
When PATCH /v1/article/{id} carries "lang" or "story_id"
Then the fields are ignored and the stored values are unchanged
And POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature
And a POST without them behaves as before
Scenario: Existing behaviour is unchanged
Then the existing article, pagination, publishing and SEO tests pass without edits
+1 -1
View File
@@ -5,7 +5,7 @@ Backend only. Go tests in test/, black-box. No design states. No new endpoints.
## Automated, no server (task test)
1. Language domain: table-driven over "Language tag normalization" and "Suggestion for a site's default language". Assert the sentinel errors with errors.Is.
2. Use case: with fake repositories, run "Creating an article before the default is confirmed" and "after the default is confirmed". Assert that CreateArticle runs inside the Transactor, that the starting language is obtained through LockStartingLanguage of the site language repository (under the site-row lock) and passed to Create as Lang, and that the story is created by the article repository inside the same transaction. Existing create tests stay green.
3. HTTP: NewHandler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring api/router.go, validator.InitValidator(), httptest. Cover "Article reads expose lang and story_id" for GET /v1/article/{id} with a jwt cookie signed by JWT_SECRET (the test secret), the list GET /v1/article/, and the public read of an online article. Assert that old keys are unchanged, that lang and story_id are the added keys, and that unknown "lang" or "story_id" in the request body is ignored. Errors stay plain text.
3. HTTP: NewHandler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring api/router.go, validator.InitValidator(), httptest. Cover "Article reads expose lang and story_id" for GET /v1/article/{id} with a jwt cookie signed by JWT_SECRET (the test secret), the list GET /v1/article/, and the public read of an online article. Assert that old keys are unchanged, that lang and story_id are the added keys, and that "lang" or "story_id" in a PATCH body is ignored. POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature, and a POST without them behaves as before. Errors stay plain text.
4. Regression: run the full existing article, pagination, publishing and SEO tests untouched.
## Automated, SQLite file (task test)
+29
View File
@@ -1,6 +1,8 @@
Feature: An authenticated user can publish and unpublish an article
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Extended by article_story_lifecycle: publishing a version in a disabled language answers 422 language_disabled.
As an authenticated RenewCMS user managing the CMS
I want to flip an article's is_online flag from an HTTP endpoint
So that a draft can be published to the public site and a published article can be
@@ -25,6 +27,7 @@ Feature: An authenticated user can publish and unpublish an article
And an id absent from the article repository answers 404 with body
apperr.ArticleNotFoundErrorMessage, backed by errors.Is(err, article.ErrArticleNotFound),
the same as DeleteArticle already does per features/write_not_found_errors.feature
And publishing and unpublishing act on one article id, one language version; sibling versions are untouched
And GET /v1/article/ with no jwt cookie only returns online articles, per
features/public_article_listing.feature; this slice does not change that behavior,
only feeds it
@@ -93,6 +96,32 @@ Feature: An authenticated user can publish and unpublish an article
| publish |
| unpublish |
Scenario: Publishing a draft in a disabled language is refused
Given a valid jwt cookie for a user holding content.publish
And article 15 is offline in language "es", which is disabled on its site
When the client sends PATCH /v1/article/15/publish with that cookie
Then the response status is 422
And the response body is the text "language_disabled"
And article 15 stays offline
Scenario: An online version in a disabled language can still be unpublished, and publish is idempotent
Given a valid jwt cookie for a user holding content.publish
And article 16 is online in language "es", which is disabled on its site
When the client sends PATCH /v1/article/16/unpublish with that cookie
Then the response status is 200
And the response body is the article with id 16 and "is_online": false
When article 16 is online again and the client sends PATCH /v1/article/16/publish with that cookie
Then the response status is 200
And the response body is the article with id 16 and "is_online": true
Scenario: Publishing one language leaves the other versions alone
Given a valid jwt cookie for a user holding content.publish
And story 5 holds article 10 (en, online) and article 12 (fr, offline)
When the client sends PATCH /v1/article/12/publish with that cookie
Then the response status is 200
And article 12 is online
And the publication and updated_at of article 10 are unchanged
Scenario: Publishing an article makes it appear in the public online-only list
Given a valid jwt cookie for a user with id 1
And the article repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
+9
View File
@@ -50,6 +50,15 @@ Package: `test` (black-box, extending `article_test.go`).
the valid cookie (assert `200`), then call `GET /v1/article/` with no cookie and assert the
decoded JSON array does not contain an element with id `1`.
17. Disabled language: add a fake language repo row for "es" with enabled false and seed offline
article 15 in lang "es". `PATCH /v1/article/15/publish` -> status `422`, body
`"language_disabled\n"`, article stays offline, no `UpdateIsOnline` call recorded on the fake.
18. Seed online article 16 in lang "es" (disabled). `PATCH /v1/article/16/unpublish` -> `200`,
`is_online == false`. Re-seed it online, `PATCH /v1/article/16/publish` -> `200`,
`is_online == true`.
19. Siblings: seed story 5 with article 10 (en, online) and article 12 (fr, offline).
`PATCH /v1/article/12/publish` -> `200`; article 10's `IsOnline` and `UpdatedAt` are unchanged.
## Manual verification (not exercised by the Go test)
- SPA-visible behaviour: confirm the back-office article editor exposes a publish/unpublish
control that calls the new endpoints and updates the visible online/offline state, against the
+255
View File
@@ -0,0 +1,255 @@
Feature: Capabilities, translations, publish rules, delete and story move
An article read tells the editor what the caller may do with the version and which readable sibling versions exist. Publishing respects disabled languages. Deleting the last version removes its story. A story moves to another site as a whole, atomically, never split.
Background:
Given error responses are plain text, with extra data in headers
And site "journal" (id 1) has enabled languages en (default), fr, de, ar and disabled language es
And site "atelier" (id 2) has enabled languages en (default), fr, de
And story 5 in journal holds article 10 (en, online, created_by 1, slug renovation-costs), 12 (fr, offline, 1, couts), 11 (de, offline, 2, kosten) and 13 (ar, online, 3, takalif)
And "owner" holds every content permission at scope all
And "writer1" (user 1) holds content.create, content.read, content.update, content.publish and content.delete at scope own, and content.read at scope all for online articles only
And "reader" holds content.read at scope all only
Scenario: Capabilities of the owner
When the owner sends GET /v1/article/12
Then "capabilities" is ["read","edit","publish","delete","create_translation"]
Scenario Outline: Capabilities per caller and version
When <caller> sends GET /v1/article/<id>
Then "capabilities" is <capabilities>
Examples:
| caller | id | capabilities |
| writer1 | 12 | ["read","edit","publish","delete","create_translation"] |
| writer1 | 11 | ["read","create_translation"] |
| reader | 10 | ["read"] |
| a user with content.update and content.read all | 12 | ["read","edit"] |
| a user with content.publish and content.read all | 12 | ["read","publish"] |
| anonymous | 10 | ["read"] |
Scenario: Translations list readable versions only
When writer1 sends GET /v1/article/10
Then "translations" has entries for 10, 12 and 13 and none for 11
And each entry has id, lang, title, is_online, language_enabled and capabilities
And no count, placeholder or total names the hidden version
Scenario: Translations are ordered default language first, then by tag
When the owner sends GET /v1/article/12
Then the translations are ordered en, ar, de, fr
And the version being read is included
Scenario: Disabled languages are marked
Given language "fr" is disabled on journal
When the owner sends GET /v1/article/10
Then the fr entry has "language_enabled": false and is still listed
Scenario: A single-version article lists itself
Given article 20 is alone in story 8
When the owner sends GET /v1/article/20
Then "translations" has one entry, article 20 itself
Scenario: Anonymous reads expose nothing about siblings
When an anonymous client sends GET /v1/article/10
Then "translations" is []
And "capabilities" is ["read"]
And the public shape is otherwise unchanged
Scenario: The source on the read
Given article 14 was created from article 10 at content_revision 4
When the owner sends GET /v1/article/14
Then "source" is {"article_id":10,"revision":4}
When article 10 is deleted or unreadable to the reader
Then "source" is null
And a version created empty has "source": null
Scenario Outline: Publishing respects the language configuration
Given a valid jwt cookie for a user holding content.publish
When the client sends PATCH /v1/article/<id>/publish
Then the response status is <status>
And <effect>
Examples:
| id | situation | status | effect |
| 12 | fr enabled | 200 | "is_online": true |
| 15 | es disabled, offline version | 422 | the body is "language_disabled" |
| 16 | es disabled, already online | 200 | unchanged, "is_online": true |
| 30 | unassigned lang "" | 200 | "is_online": true |
Scenario: Unpublish and edit stay available in a disabled language
Given fr is disabled on journal and article 12 is online
When the owner sends PATCH /v1/article/12/unpublish
Then the response status is 200
When the owner sends PATCH /v1/article/12 with {"body":"x"}
Then the response status is 200
Scenario: Publishing one version leaves the others alone
When the owner publishes article 12
Then articles 10, 11 and 13 keep their publication and updated_at
Scenario: Re-enabling a language allows publishing again
Given es is re-enabled on journal
When the owner sends PATCH /v1/article/15/publish
Then the response status is 200
Scenario Outline: Existing publish errors are unchanged
When <request>
Then the response status is <status>
And the response body is <message>
Examples:
| request | status | message |
| the owner publishes article 9999 | 404 | Article not found |
| the owner publishes article "abc" | 400 | apperr.IdUint32ErrorMessage |
| an anonymous client publishes article 12 | 401 | Unauthorized |
| the reader publishes article 12 | 403 | missing_permission |
Scenario: Deleting one version keeps the story
When the owner sends DELETE /v1/article/12
Then the response status is 204
And articles 10, 11 and 13 and story 5 remain
And the translations no longer list fr
Scenario: A deleted language can be created again
Given article 12 (fr) was deleted
When the owner sends POST /v1/article/10/translations with lang "fr"
Then the response status is 201
And fr of journal still counts as in use while a deleted fr version exists, per features/site_languages.feature
Scenario: Deleting the last version removes the story
Given story 8 holds only article 20
When the owner sends DELETE /v1/article/20
Then the response status is 204 and story 8 is gone
And creating a translation from story 8 answers 404 "Article not found"
Scenario: Deleting the last of several versions
When the owner deletes 10, 11, 12 and 13 one after another
Then story 5 exists after each of the first three deletions
And story 5 is gone after the fourth
Scenario: Delete is checked per version
When writer1 sends DELETE /v1/article/11
Then the response status is 403 and the body is "missing_permission"
And article 11 remains
Scenario: A story with a hidden version
Given writer1 deletes the last version it can see while an unreadable version remains
Then the story remains
And nothing in the response mentions the other version
Scenario: Move a story
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is 200
And the body is {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}
And all four articles have site_id 2
And their slugs, lang, publication, content and authors are unchanged
And each moved version's revision rises by 1 and content_revision is unchanged
Scenario: A move requires every language to be enabled at the destination
Given atelier has no "ar" language
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is 422 and the body is "language_not_enabled"
And the header X-Missing-Languages is "ar", readable versions only
And nothing is moved
Scenario: A language disabled at the destination counts as missing
Given de is listed but disabled on atelier
When the owner moves story 5 to atelier
Then the response status is 422
And X-Missing-Languages includes "de"
Scenario: A language disabled at the source still needs the destination
Given fr is disabled on journal and enabled on atelier
When the owner moves story 5 to atelier
Then the move succeeds
Given fr is disabled on journal and missing on atelier
When the owner moves story 5 to atelier
Then the response status is 422 and the body is "language_not_enabled"
And X-Missing-Languages is "fr"
Scenario: Address conflict at the destination
Given atelier has an fr article with slug "couts" and an ar article with slug "takalif"
And the caller can read 10, 11 and 12 but not 13
When the caller moves story 5 to atelier
Then the response status is 409 and the body is "slug_conflict"
And the header X-Conflicting-Articles is 12
And article 13 is neither named nor counted
And nothing is moved
Scenario: Every conflict of the story is checked
Given two versions of the story conflict at the destination
When a caller who can read both moves the story
Then no version moves
And both readable conflicting versions are named in X-Conflicting-Articles
Scenario Outline: A move needs update permission on every version, readable or not
When <caller> sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is <status>
And the response body is <message>
And no version is moved
Examples:
| caller | status | message |
| an anonymous client | 401 | Unauthorized |
| writer1 | 403 | missing_permission |
| a user without content.update | 403 | missing_permission |
| a user who can read no version | 404 | Article not found |
Scenario: Hidden versions never leak through the move
Given the caller cannot read some versions of story 5
Then a 403 body and headers, a 422 X-Missing-Languages and a 409 X-Conflicting-Articles name only readable versions
And a 200 article_ids lists only readable versions
Scenario Outline: Move validation
When the owner sends <request>
Then the response status is <status>
And the response body is <message>
Examples:
| request | status | message |
| POST /v1/story/5/move with {"site":"nowhere"} | 404 | site_not_found |
| POST /v1/story/5/move with {} | 400 | apperr.BodyErrorMessage |
| POST /v1/story/5/move with malformed JSON | 400 | apperr.BodyErrorMessage |
| POST /v1/story/9999/move with {"site":"atelier"} | 404 | Article not found |
| POST /v1/story/abc/move with {"site":"atelier"} | 400 | apperr.IdUint32ErrorMessage |
Scenario: Moving to the current site
When the owner sends POST /v1/story/5/move with {"site":"journal"}
Then the response status is 200
And nothing changes
And no activity event is recorded
Scenario: The move is atomic and revalidated inside the transaction
When a version is edited, a language is disabled at the destination, or an address is taken between the pre-check and the write
Then the move is refused with the matching error and no version moves
When a write fails on the third version
Then the first two are rolled back
Scenario: PATCH site_id cannot split a story
When the owner sends PATCH /v1/article/10 with {"site_id":2}
Then the response status is 409 and the body is "story_move_required"
And nothing is changed
Scenario: PATCH site_id counts readable versions only
Given a story holds three versions and a caller reads only one of them
When that caller sends PATCH site_id on the readable version
Then content.update is required on every version of the story, readable or not, and the whole story moves under the story move rules
And every version, hidden ones included, ends up on the destination site in one transaction
And a language, address or slug refusal rolls everything back and names readable versions only
And a caller lacking update on any version gets 403 "missing_permission" with nothing naming a hidden version
And a caller who reads two versions gets 409 "story_move_required"
Scenario: PATCH site_id on a single-version story applies the move rules
Given story 8 holds only article 20 in lang "fr"
When the owner sends PATCH /v1/article/20 with {"site_id":2}
Then the article moves when fr is enabled at atelier
And otherwise the response is 422 "language_not_enabled"
And an unassigned article (lang "") moves without a language check
Scenario: Activity events carry the language
Then publish, unpublish, edit and delete events hold params lang, omitted for an unassigned article
And a move records one "article.edit" event per moved version with the site change and lang
And a refused move records one failed event with a plain reason naming no hidden version
Scenario: Persistence portability
Given the story delete rule, the (story_id, lang) re-creation after a delete and the atomic move with a unique (site_id, lang, slug) conflict
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL
+25
View File
@@ -0,0 +1,25 @@
# QA: Capabilities, translations, publish rules, delete and story move (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. Use cases over fakes (article, site, language, localization, role grants) and a fake Transactor: the capabilities and translations mapping, the publish rule, deleting the last version and MoveStory. Table-driven over every Examples table; assert the sentinels with errors.Is. MoveStory runs the permission check, the language check, the slug check and all writes inside one Transactor.Run, and a failing write rolls back all fake rows.
2. HTTP via the article NewHandler and the new story handler, a chi router mirroring api/router.go (/article and /story mounts, authtoken group), validator.InitValidator(), httptest and jwt cookies for owner, writer1, reader and none. Assert exact bodies and the headers X-Existing-Article-Id (absent where not allowed), X-Current-Revision, X-Conflicting-Articles and X-Missing-Languages. Hidden versions appear in no body, header or count.
3. Anonymous reads: the key set is the slice 1 set plus revision, capabilities, translations and source.
4. Activity: a fake recorder receives params.lang on publish, unpublish, edit, delete and per-version move events.
5. Regression: the article_publishing and article_title_rules tests pass with the updated pair text.
## Automated, SQLite file (task test)
6. Deleting the last version removes the story row, deleting one keeps it. Re-creating fr after deleting fr works. The site_languages in-use count still includes a deleted fr version (slice 2 TestLanguageInUseCountsSoftDeletedVersions stays green).
7. Move: all site_id values change together. A unique (site_id, lang, slug) violation at the destination rolls back every row. An injected write failure on the third row leaves all rows unchanged.
8. PATCH site_id counts readable versions only: with one readable version in a multi-version story it runs the full story move (every version, hidden ones too, moves in one transaction; language and slug refusals roll back and name readable versions only), which needs content.update on every version (403 missing_permission otherwise); with two readable versions it is 409 story_move_required.
## Automated, real databases (task test:mysql, task test:postgres)
8. Repeat 6-7 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
9. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files. The architect checks api/router.go and api/dependecyInjection.go.
10. task mutate scoped via MUTATE_FILES to the new use cases.
## Manual
None.
+30 -3
View File
@@ -1,4 +1,5 @@
Feature: The article title rule is the same in the request and the domain
Superseded in part by article_edit_revision: titles are editable.
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
@@ -26,9 +27,7 @@ Feature: The article title rule is the same in the request and the domain
500 with body "Failed to create article"
And POST /v1/article/ sits behind the jwtauth Verifier and Authenticator group and
requires a valid jwt cookie
And UpdateArticleUseCase exposes only UpdateBody, AddImage and UpdateIsOnline; no
route or use case updates an article's title after creation, so this feature only
concerns article creation
And PATCH /v1/article/{id} accepts `title` with the same 3-200 rune rule, rejected as 400 with apperr.ArticleTitleErrorMessage (see features/article_edit_revision.feature)
Scenario Outline: CreateArticle now measures title length in runes, not bytes
Given an empty article repository fake
@@ -76,6 +75,34 @@ Feature: The article title rule is the same in the request and the domain
And the response body is the text apperr.ArticleTitleErrorMessage
And the response body is not "Failed to create article"
Scenario Outline: PATCH /v1/article/{id} enforces the same 3-200 rune boundary
Given a valid jwt cookie for a user holding content.update
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 with that cookie and a title of <length> ASCII characters
Then the response status is <status>
And <storage_effect>
Examples:
| length | status | storage_effect |
| 2 | 400 | the title is unchanged |
| 3 | 200 | the title is stored |
| 200 | 200 | the title is stored |
| 201 | 400 | the title is unchanged |
Scenario: A domain title rejection on update is a 400, not a 500
Given a valid jwt cookie for a user holding content.update
And the article repository fake is configured so its next Update call returns article.ErrInvalidTitle
When the client sends PATCH /v1/article/1 with that cookie and title "Valid Title"
Then the response status is 400
And the response body is the text apperr.ArticleTitleErrorMessage
Scenario: Editing the title does not change the slug
Given a valid jwt cookie for a user holding content.update
And the article with id 1 has slug "backyard-renovation-update"
When the client sends PATCH /v1/article/1 with that cookie and title "A brand new title"
Then the response status is 200
And the slug is still "backyard-renovation-update"
Scenario: A non-title repository failure during creation still answers 500
Given a valid jwt cookie for a user with id 1
And the article repository fake is configured to fail on its next Create call with
+9
View File
@@ -73,6 +73,15 @@ Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_tes
article\n"` -- regression proof a non-title failure still answers 500.
14. `POST /v1/article/` with no jwt cookie -> status `401` (regression: the route is
still gated).
15. `PATCH /v1/article/1` with a cookie of a user holding content.update and a title of
2 / 3 / 200 / 201 ASCII characters -> `400` unchanged / `200` stored / `200` stored /
`400` unchanged. Body `apperr.ArticleTitleErrorMessage + "\n"` on the 400s.
16. `PATCH /v1/article/1` with `"title": ""` (present but empty) -> `400`, title unchanged
(guard against `omitempty` on a `*string` skipping validation).
17. Configure the fake repo so its next `Update` returns `article.ErrInvalidTitle`.
`PATCH /v1/article/1` with title "Valid Title" -> `400`, body
`apperr.ArticleTitleErrorMessage + "\n"`.
18. `PATCH /v1/article/1` with only a new title -> `200`, slug unchanged.
## Manual verification (not exercised by the Go test)
- None. This slice has no SPA-visible behaviour and no SMTP involvement.
+189
View File
@@ -0,0 +1,189 @@
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 <body>
Then the response status is <status>
And the response body is <message>
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 <situation>
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 <request>
Then the response status is <status>
And the response body is <message>
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
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/ with <body>
Then the response status is <status>
And <effect>
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 |
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
+26
View File
@@ -0,0 +1,26 @@
# QA: Create a translation (backend)
Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test)
1. CreateTranslation use case over in-memory fakes (article, site, language, author and image repositories, a fake Transactor; unique type names). Table-driven over "Copy from the source version", "Start empty", "Request validation" and "One neutral answer". Assert the sentinels with errors.Is (language exists, language unavailable, language not enabled, source unavailable, source other story, setup required). The read check, the source recheck, the language check, the free-slug search, the insert and the source_revision capture run inside one Transactor.Run, and the copy and source_revision come from the same read.
2. Both endpoints reach the same use case: a spy asserts one call per request with identical arguments.
3. HTTP: NewHandler over use cases, a fresh chi router mirroring api/router.go (POST /{id}/translations and POST / inside the authtoken group, requireArticle(content.create)), validator.InitValidator(), httptest and a jwt cookie. Cover every Examples row: status, exact plain-text body, presence or absence of X-Existing-Article-Id. The three neutral situations are byte-identical in body and header set.
4. Race: two goroutines request the same language, one wins, the other gets language_unavailable.
5. Activity: a fake audit recorder receives article.create with params lang, story_id and source_article_id. Failure events contain no hidden-version data.
6. Regression: the existing POST /v1/article tests stay untouched, except the amended slice 1 scenario.
## Automated, SQLite file (task test)
7. Repository: the unique (story_id, lang) violation maps to the language-unavailable sentinel; the slug retry works within (site, lang); source_article_id and source_revision round-trip; the migration adds them without changing existing rows; a second Migrate changes nothing.
8. Concurrency: N goroutines create fr for one story, exactly one succeeds, busy retries are bounded.
## Automated, real databases (task test:mysql, task test:postgres)
9. Repeat 7-8 on MySQL 8.4, MariaDB 11 and PostgreSQL. Record NOT RUN if the daemon is down, or use the podman shim (see memory).
## Build and lint
10. task test, task lint:go and CGO_ENABLED=0 go build -tags nofrontend ./... pass. No comments in the added Go files.
11. docs/api documents the endpoint, bodies, codes and headers.
12. task mutate scoped with MUTATE_FILES to the new use case.
## Manual
None.