Files
RenewCMS/features/article_story_lifecycle.feature
T

334 lines
17 KiB
Gherkin

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, and a whole article can be deleted at once, atomically. A story moves to another site as a whole, atomically, never split. Nothing reveals a version the caller cannot read.
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
And "restricted" holds content.create at scope all and content.read at scope own
And "deleter" holds content.delete at scope all and content.read at scope own
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, excluded_from_public, source_article_id and capabilities
And no entry, 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 response has no "missing_langs", "excluded_from_public", "source_unavailable" or "partial_view"
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: Missing languages are the enabled languages without a readable version
When <caller> sends GET /v1/article/10
Then "missing_langs" is <missing>
Examples:
| caller | missing |
| owner | [] |
| restricted | ["de","fr"] |
| reader | [] |
Scenario: A version in a disabled language or without language is left out of public surfaces
Given article 14 (es, offline, created by restricted) is in story 5 and es is disabled on journal
And article 30 has lang "" and its own story
When the owner sends GET /v1/article/14
Then "excluded_from_public" is true
And the translations entry of 14 has "excluded_from_public": true and the entry of 10 has false
And GET /v1/article/30 has "excluded_from_public": true
And GET /v1/article/10 has "excluded_from_public": false
Scenario: A deleted source and an unreadable source read the same
Given article 14 tracks article 12 at content revision 2
When the owner sends GET /v1/article/14
Then "source" is {"article_id":12,"revision":2} and "source_unavailable" is false
When the restricted user, who cannot read 12, sends GET /v1/article/14
Then "source" is null and "source_unavailable" is true
When article 12 is deleted and the owner sends GET /v1/article/14
Then "source" is null and "source_unavailable" is true
Scenario: Translations name the source they track only when it is readable
Given article 14 tracks article 12
When the owner sends GET /v1/article/10
Then the translations entry of 14 has "source_article_id": 12 and the entry of 10 has null
When the restricted user sends GET /v1/article/14
Then the translations entry of 14 has "source_article_id": null
Scenario: Partial view follows the read scope of the caller only
When the owner sends GET /v1/article/10
Then "partial_view" is false
When the restricted user sends GET /v1/article/13
Then "partial_view" is true, whether or not the story holds a version the restricted user cannot read
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: Deleting an article removes every version and the article
When the owner sends DELETE /v1/story/5
Then the response status is 204 with an empty body
And articles 10, 11, 12 and 13 and story 5 are gone
And one article.delete event per version is recorded with params.lang and params.story_id 5
Scenario: Versions the caller cannot read are deleted with the article
When the deleter, who reads 10 and 13 only, sends DELETE /v1/story/5
Then the response status is 204
And articles 11 and 12 are deleted too
Scenario: Deleting an article needs delete permission on every version
When writer1, whose content.delete is at scope own, sends DELETE /v1/story/5
Then the response status is 403, the body is "missing_permission" and X-Missing-Permission is "content.delete"
And every version remains
And the answer is the same whether or not the story holds a version writer1 cannot read
Scenario: A failed delete removes nothing
Given the database fails while the versions are deleted
When the owner sends DELETE /v1/story/5
Then the response status is 500 and the body is "Failed to delete article"
And every version and story 5 remain
Scenario: An unknown or unreadable article answers the same not found
When the owner sends DELETE /v1/story/9999
Then the response status is 404 and the body is "Article not found"
When the deleter sends DELETE /v1/story/77, whose only version is a draft by another user
Then the response status is 404 and the body is "Article not found"
And that version remains
Scenario: An invalid id is refused
When the owner sends DELETE /v1/story/abc
Then the response status is 400 with the uint32 id message
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