feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user