929 lines
45 KiB
Gherkin
929 lines
45 KiB
Gherkin
Feature: Authors backend, public bylines that articles credit in order
|
|
|
|
As an owner or admin of a RenewCMS installation
|
|
I want to manage authors and credit them on articles in a chosen order
|
|
So that readers see who wrote an article, whether or not the writer has an account
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/screens/authors.md, docs/design/screens/editor.md (site and authors), docs/design/screens/profile.md, docs/design/states.md (author rows), docs/design/routes.md and docs/design/sites-authors-slices.md
|
|
And this slice is backend only: no SPA, no change to docs/design
|
|
And an Author has id (uint32), name, slug, bio, avatar_id (nullable media id), website (nullable), social_links, user_id (nullable and unique), is_linked and article_count, in snake_case JSON
|
|
And an article has zero or more authors in a stored order through an ordered article_authors join; the first is the primary byline
|
|
And zero authors on an article is allowed and never blocks publishing
|
|
And the routes live under /v1/author, mounted inside the JWT group: GET /, POST /, POST /me, PATCH /{id}, DELETE /{id}?reassign_to={id|none}
|
|
And GET /v1/author/ accepts a session or an API key from a verified account; every write, POST /me included, is session-only
|
|
And POST / and DELETE /{id} go through RequireOwnerOrAdmin with its existing body "Only owners and admins can do this."
|
|
And PATCH /{id} is allowed to owner and admin on any author, and to any verified account on the author linked to itself, and otherwise answers 403 with the body "author_rights"
|
|
And the only roles defined today are "owner" and "admin", so a role-without-author-rights is exercised with a verified account whose role is any other value
|
|
And error responses are plain text from http.Error whose body is exactly the code token: author_not_found, author_slug_taken, author_has_articles, user_already_linked, author_already_linked, user_not_found, author_rights, bio_too_long, invalid_url, too_many_links, invalid_slug, invalid_reassign, image_not_found
|
|
And bio_too_long, invalid_url and too_many_links answer 422; other validate-tag failures answer 400 with the validator error text, a body that cannot be decoded answers 400 with apperr.BodyErrorMessage, and a malformed id answers 400 with apperr.IdUint32ErrorMessage
|
|
And the name has 1 to 100 characters after trimming, the slug matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 1 to 100 characters, the bio has at most 280 characters counted in runes
|
|
And website and every social link URL must be https with a host, at most 2048 characters; an empty or null website means none
|
|
And social_links holds at most 5 items of {"type", "url"} where type is one of x, linkedin, github, mastodon, custom
|
|
And an author has user_email in its JSON only for owner and admin; it is absent for every other caller
|
|
And an author inside an article has only id, name, slug, bio, avatar_id, website and social_links
|
|
And request validation goes through validate tags and validator.Validate.Struct
|
|
And handlers reach authors only through use cases and domain interfaces, and no comment is added anywhere
|
|
|
|
Rule: Reading authors
|
|
|
|
Scenario: The list is newest first with article counts
|
|
Given the authors "Léa Fontaine" (id 1), "Inès Duarte" (id 2) and "Camille Roche" (id 3)
|
|
And "Inès Duarte" is credited on 9 articles and "Léa Fontaine" on none
|
|
When a verified account sends GET /v1/author/
|
|
Then the status is 200
|
|
And the body is {"items": [...], "total": 3, "page": 1, "size": 25}
|
|
And the items are "Camille Roche", "Inès Duarte", "Léa Fontaine" in that order
|
|
And every item carries article_count
|
|
|
|
Scenario Outline: Who can read the list
|
|
Given at least one author exists
|
|
When GET /v1/author/ is sent with <credentials>
|
|
Then the status is <status>
|
|
|
|
Examples:
|
|
| credentials | status |
|
|
| the owner's jwt cookie | 200 |
|
|
| a verified admin's jwt cookie | 200 |
|
|
| a verified account with another role | 200 |
|
|
| a valid API key | 200 |
|
|
| an unverified account's jwt cookie | 403 |
|
|
| no credentials | 401 |
|
|
|
|
Scenario Outline: Search matches the name or the slug
|
|
Given the authors "Camille Roche" (slug "camille-roche") and "Zoe Lambert" (slug "zl")
|
|
When a verified account sends GET /v1/author/?q=<q>
|
|
Then the items are <names>
|
|
And total is <total>
|
|
|
|
Examples:
|
|
| q | names | total |
|
|
| camille | "Camille Roche" | 1 |
|
|
| ROCHE | "Camille Roche" | 1 |
|
|
| zl | "Zoe Lambert" | 1 |
|
|
| zoe | "Zoe Lambert" | 1 |
|
|
| nobody | none | 0 |
|
|
|
|
Scenario Outline: Pagination follows the article list
|
|
Given 30 authors
|
|
When a verified account sends GET /v1/author/?<params>
|
|
Then the status is <status>
|
|
And <result>
|
|
|
|
Examples:
|
|
| params | status | result |
|
|
| size=25&page=2 | 200 | 5 items, total 30, page 2, size 25 |
|
|
| size=5 | 200 | 5 items, total 30, page 1, size 5 |
|
|
| page=3&size=100 | 200 | 0 items, total 30 |
|
|
| size=30 | 400 | the body is apperr.BodyErrorMessage |
|
|
| page=0 | 400 | the body is apperr.BodyErrorMessage |
|
|
| page=abc | 400 | the body is apperr.BodyErrorMessage |
|
|
|
|
Scenario: The account email is shown to owner and admin only
|
|
Given the author "Inès Duarte" is linked to the user ines.duarte@renew.example
|
|
When the owner sends GET /v1/author/
|
|
Then "Inès Duarte" has is_linked true, user_id set and user_email "ines.duarte@renew.example"
|
|
When a verified account with another role sends GET /v1/author/
|
|
Then "Inès Duarte" has is_linked true
|
|
And no "user_email" key appears anywhere in the body
|
|
And the body does not contain "ines.duarte@renew.example"
|
|
|
|
Scenario: A guest author is not linked
|
|
Given the author "Camille Roche" has no user
|
|
When a verified account sends GET /v1/author/
|
|
Then "Camille Roche" has is_linked false, user_id null and no user_email
|
|
|
|
Scenario: An author without avatar or website returns nulls
|
|
When a verified account reads an author with no avatar and no website
|
|
Then avatar_id is null, website is null, bio is "" and social_links is []
|
|
|
|
Rule: Creating an author is owner or admin only
|
|
|
|
Scenario Outline: An owner or admin creates an author
|
|
When <credentials> sends POST /v1/author/ with {"name": "Camille Roche", "slug": "camille-roche", "bio": "Writer.", "website": "https://example.com", "social_links": [{"type": "x", "url": "https://x.com/camille"}]}
|
|
Then the status is 201
|
|
And the body is the author with a new id, article_count 0, is_linked false and the given values
|
|
And the author is stored
|
|
|
|
Examples:
|
|
| credentials |
|
|
| the owner's jwt cookie |
|
|
| a verified admin's jwt cookie |
|
|
|
|
Scenario: Only the name is required
|
|
When the owner sends POST /v1/author/ with {"name": "Zoe Lambert"}
|
|
Then the status is 201
|
|
And the slug is "zoe-lambert", bio is "", avatar_id, website and user_id are null and social_links is []
|
|
|
|
Scenario Outline: The slug is generated from the name
|
|
When the owner sends POST /v1/author/ with {"name": "<name>"}
|
|
Then the slug is "<slug>"
|
|
|
|
Examples:
|
|
| name | slug |
|
|
| Camille Roche | camille-roche |
|
|
| Inès Duarte | ines-duarte |
|
|
| Dr. O'Neil Smith | dr-o-neil-smith |
|
|
| !!! | author |
|
|
|
|
Scenario: A generated slug that is taken is refused
|
|
Given the author with slug "camille-roche" exists
|
|
When the owner sends POST /v1/author/ with {"name": "Camille Roche"}
|
|
Then the status is 409
|
|
And the body is "author_slug_taken"
|
|
And no author is added
|
|
|
|
Scenario: A taken slug is refused
|
|
Given the author with slug "camille-roche" exists
|
|
When the owner sends POST /v1/author/ with {"name": "Other", "slug": "camille-roche"}
|
|
Then the status is 409
|
|
And the body is "author_slug_taken"
|
|
And no author is added
|
|
|
|
Scenario: The slug is unique even if the repository reports a race
|
|
Given the repository reports a unique violation on the slug
|
|
When the owner sends POST /v1/author/ with a free-looking slug
|
|
Then the status is 409 with body "author_slug_taken"
|
|
|
|
Scenario Outline: Fields are validated
|
|
When the owner sends POST /v1/author/ with <field> <value>
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And no author is added
|
|
|
|
Examples:
|
|
| field | value | status | body |
|
|
| name | (empty) | 400 | the validator error text |
|
|
| name | " " | 400 | the validator error text |
|
|
| name | 101 characters | 400 | the validator error text |
|
|
| slug | "Camille" | 400 | invalid_slug |
|
|
| slug | "camille_roche" | 400 | invalid_slug |
|
|
| slug | "-camille" | 400 | invalid_slug |
|
|
| slug | 101 characters | 400 | invalid_slug |
|
|
| bio | 281 characters | 422 | bio_too_long |
|
|
| website | "http://example.com" | 422 | invalid_url |
|
|
| website | "example.com" | 422 | invalid_url |
|
|
| website | "https://" | 422 | invalid_url |
|
|
| social_links | 6 links | 422 | too_many_links |
|
|
| social_links | [{"type": "x", "url": "http://x.com/a"}] | 422 | invalid_url |
|
|
| social_links | [{"type": "facebook", "url": "https://f.com/a"}] | 400 | the validator error text |
|
|
| avatar_id | 999 (no such image) | 400 | image_not_found |
|
|
|
|
Scenario Outline: Boundaries are accepted
|
|
When the owner sends POST /v1/author/ with <field> <value>
|
|
Then the status is 201
|
|
|
|
Examples:
|
|
| field | value |
|
|
| name | 100 characters |
|
|
| slug | 100 characters |
|
|
| bio | 280 characters |
|
|
| bio | 280 multi-byte characters |
|
|
| social_links | 5 links |
|
|
| social_links | 2 links of type "custom" |
|
|
| website | "" |
|
|
|
|
Scenario: A malformed body is refused
|
|
When the owner sends POST /v1/author/ with the body "{not json"
|
|
Then the status is 400
|
|
And the body is apperr.BodyErrorMessage
|
|
And no author is added
|
|
|
|
Scenario: The role is checked before the body
|
|
When an account with another role sends POST /v1/author/ with the body "{not json"
|
|
Then the status is 403 with body "Only owners and admins can do this."
|
|
|
|
Scenario: An author can be created linked to a user
|
|
Given the user 3 has no author
|
|
When the owner sends POST /v1/author/ with {"name": "Marta Alves", "user_id": 3}
|
|
Then the status is 201
|
|
And the author has user_id 3 and is_linked true
|
|
|
|
Scenario Outline: An author cannot be created with an unusable user
|
|
When the owner sends POST /v1/author/ with {"name": "X", "user_id": <user_id>}
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And no author is added
|
|
|
|
Examples:
|
|
| user_id | status | body |
|
|
| 999 (no such user) | 404 | user_not_found |
|
|
| 3 (already has author) | 409 | user_already_linked |
|
|
|
|
Rule: The caller creates their own author
|
|
|
|
Scenario Outline: The author is named from the display name or the username
|
|
Given the verified account has display name "<display>" and username "<username>" and no author
|
|
When it sends POST /v1/author/me
|
|
Then the status is 201
|
|
And the author name is "<name>" and the slug is "<slug>"
|
|
And the author has user_id of the caller and is_linked true
|
|
And slug_adjusted is false
|
|
|
|
Examples:
|
|
| display | username | name | slug |
|
|
| Marta Alves | marta | Marta Alves | marta-alves |
|
|
| (empty) | marta | marta | marta |
|
|
|
|
Scenario Outline: Any verified role may do it
|
|
When <credentials> sends POST /v1/author/me
|
|
Then the status is 201
|
|
|
|
Examples:
|
|
| credentials |
|
|
| the owner's jwt cookie |
|
|
| a verified admin's jwt cookie |
|
|
| a verified account with another role |
|
|
|
|
Scenario: A taken slug is de-duplicated silently
|
|
Given the author with slug "marta-alves" exists and "marta-alves-2" too
|
|
When "Marta Alves" sends POST /v1/author/me
|
|
Then the status is 201
|
|
And the slug is "marta-alves-3"
|
|
And slug_adjusted is true
|
|
And no error is returned
|
|
|
|
Scenario: The suffix still fits in the slug limit
|
|
Given a display name whose slug is 100 characters and that slug is taken
|
|
When the caller sends POST /v1/author/me
|
|
Then the status is 201
|
|
And the slug is at most 100 characters and ends with "-2"
|
|
And slug_adjusted is true
|
|
|
|
Scenario: Only the own-author creation carries slug_adjusted
|
|
When the owner sends POST /v1/author/ with {"name": "Camille Roche"}
|
|
Then the 201 body has no slug_adjusted field
|
|
And GET /v1/author/ items and GET /v1/auth/me author have no slug_adjusted field
|
|
|
|
Scenario: A caller who already has an author is refused
|
|
Given the caller is linked to an author
|
|
When the caller sends POST /v1/author/me
|
|
Then the status is 409
|
|
And the body is "user_already_linked"
|
|
And no author is added
|
|
|
|
Scenario: Two concurrent calls create one author
|
|
Given the repository reports a unique violation on user_id
|
|
When the caller sends POST /v1/author/me
|
|
Then the status is 409 with body "user_already_linked"
|
|
|
|
Scenario Outline: The endpoint is session-only and verified-only
|
|
When POST /v1/author/me is sent with <credentials>
|
|
Then the status is <status>
|
|
And no author is added
|
|
|
|
Examples:
|
|
| credentials | status | body |
|
|
| a valid API key | 403 | This action needs a real session. |
|
|
| an unverified account's jwt cookie | 403 | the existing verification message |
|
|
| no credentials | 401 | the existing unauthorized message |
|
|
|
|
Rule: Editing an author
|
|
|
|
Scenario: The owner edits any author
|
|
Given the author 2 "Camille Roche" with slug "camille-roche"
|
|
When the owner sends PATCH /v1/author/2 with {"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://camille.example", "social_links": [{"type": "github", "url": "https://github.com/camille"}]}
|
|
Then the status is 200
|
|
And the body is the author with the new values
|
|
|
|
Scenario: Absent fields are untouched
|
|
Given the author 2 has a bio, a website and an avatar
|
|
When the owner sends PATCH /v1/author/2 with {"name": "Camille R."}
|
|
Then only the name changed
|
|
|
|
Scenario Outline: Nullable fields are cleared
|
|
Given the author 2 has an avatar 5, a website and 2 social links
|
|
When the owner sends PATCH /v1/author/2 with <body>
|
|
Then the status is 200
|
|
And <result>
|
|
|
|
Examples:
|
|
| body | result |
|
|
| {"avatar_id": null} | avatar_id is null |
|
|
| {"website": null} | website is null |
|
|
| {"website": ""} | website is null |
|
|
| {"social_links": []} | social_links is [] |
|
|
| {"bio": ""} | bio is "" |
|
|
|
|
Scenario: The avatar is set to an existing image
|
|
Given the image 5 exists
|
|
When the owner sends PATCH /v1/author/2 with {"avatar_id": 5}
|
|
Then the author 2 has avatar_id 5
|
|
|
|
Scenario: Social links are replaced as a whole
|
|
Given the author 2 has 3 social links
|
|
When the owner sends PATCH /v1/author/2 with {"social_links": [{"type": "x", "url": "https://x.com/a"}]}
|
|
Then the author 2 has exactly that one link
|
|
|
|
Scenario Outline: Editing is validated like creation
|
|
When the owner sends PATCH /v1/author/2 with <field> <value>
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And the author 2 is unchanged
|
|
|
|
Examples:
|
|
| field | value | status | body |
|
|
| name | (empty) | 400 | the validator error text |
|
|
| slug | "Bad Slug" | 400 | invalid_slug |
|
|
| bio | 281 characters | 422 | bio_too_long |
|
|
| website | "http://example.com" | 422 | invalid_url |
|
|
| social_links | 6 links | 422 | too_many_links |
|
|
| avatar_id | 999 | 400 | image_not_found |
|
|
|
|
Scenario: Keeping the own slug is not a collision
|
|
When the owner sends PATCH /v1/author/2 with {"slug": "camille-roche", "bio": "x"} and the slug is already that author's
|
|
Then the status is 200
|
|
|
|
Scenario: Taking another author's slug is refused
|
|
Given the author 3 has slug "zoe-lambert"
|
|
When the owner sends PATCH /v1/author/2 with {"slug": "zoe-lambert"}
|
|
Then the status is 409
|
|
And the body is "author_slug_taken"
|
|
And the author 2 is unchanged
|
|
|
|
Scenario Outline: An empty or unknown update is refused
|
|
When the owner sends PATCH /v1/author/2 with <body>
|
|
Then the status is 400
|
|
And the body is apperr.BodyErrorMessage
|
|
And the author 2 is unchanged
|
|
|
|
Examples:
|
|
| body |
|
|
| {} |
|
|
| {"colour": "x"} |
|
|
| {"name": null} |
|
|
|
|
Scenario: A missing author is not found
|
|
When the owner sends PATCH /v1/author/999 with {"name": "X"}
|
|
Then the status is 404
|
|
And the body is "author_not_found"
|
|
|
|
Scenario: A malformed id is refused
|
|
When the owner sends PATCH /v1/author/abc with {"name": "X"}
|
|
Then the status is 400
|
|
And the body is apperr.IdUint32ErrorMessage
|
|
|
|
Scenario: A no-op edit records nothing
|
|
When the owner sends PATCH /v1/author/2 with the values it already has
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario Outline: A role without author rights edits only its own author
|
|
Given the verified account with another role is linked to the author 4
|
|
When it sends PATCH /v1/author/<id> with {"bio": "Updated."}
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
|
|
Examples:
|
|
| id | status | body |
|
|
| 4 | 200 | the author |
|
|
| 2 | 403 | author_rights |
|
|
| 999 | 404 | author_not_found |
|
|
|
|
Scenario Outline: Own author edits include name, slug and avatar
|
|
Given the verified account with another role is linked to the author 4
|
|
When it sends PATCH /v1/author/4 with <body>
|
|
Then the status is 200
|
|
|
|
Examples:
|
|
| body |
|
|
| {"name": "Marta A."} |
|
|
| {"slug": "marta-a"} |
|
|
| {"avatar_id": 5} |
|
|
| {"social_links": []} |
|
|
|
|
Scenario: A role without author rights cannot change the link
|
|
Given the verified account with another role is linked to the author 4
|
|
When it sends PATCH /v1/author/4 with {"user_id": null}
|
|
Then the status is 403
|
|
And the body is "author_rights"
|
|
And the author 4 is still linked
|
|
|
|
Scenario: An API key cannot edit an author
|
|
When PATCH /v1/author/2 is sent with a valid API key
|
|
Then the status is 403 with body "This action needs a real session."
|
|
|
|
Rule: Linking and unlinking a user
|
|
|
|
Scenario: The owner links a user to a guest author
|
|
Given the author 2 is a guest and the user 3 has no author
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": 3}
|
|
Then the status is 200
|
|
And the author 2 has user_id 3, is_linked true and user_email of the user 3
|
|
|
|
Scenario: A user cannot have two authors
|
|
Given the user 3 is linked to the author 4
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": 3}
|
|
Then the status is 409
|
|
And the body is "user_already_linked"
|
|
And the author 2 is unchanged
|
|
|
|
Scenario: A linked author must be unlinked before it can be relinked
|
|
Given the author 2 is linked to the user 3 and the user 5 has no author
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": 5}
|
|
Then the status is 409
|
|
And the body is "author_already_linked"
|
|
|
|
Scenario: Linking the user it already has is a no-op
|
|
Given the author 2 is linked to the user 3
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": 3}
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario: An unknown user cannot be linked
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": 999}
|
|
Then the status is 404
|
|
And the body is "user_not_found"
|
|
|
|
Scenario: The owner unlinks a user
|
|
Given the author 2 is linked to the user 3
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": null}
|
|
Then the status is 200
|
|
And the author 2 has user_id null, is_linked false and no user_email
|
|
And the user 3 can create or be linked to an author again
|
|
And the author 2 stays credited on its articles
|
|
|
|
Scenario: Unlinking a guest author is a no-op
|
|
When the owner sends PATCH /v1/author/2 with {"user_id": null} on a guest author
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario: A new article by the unlinked user no longer starts with the author
|
|
Given the user 3 was unlinked from the author 2
|
|
When the user 3 creates an article
|
|
Then the article has no authors
|
|
|
|
Rule: Deleting an author
|
|
|
|
Scenario: An author without articles is deleted
|
|
Given the author 2 credits no article
|
|
When the owner sends DELETE /v1/author/2
|
|
Then the status is 204
|
|
And the author 2 no longer exists
|
|
|
|
Scenario: An author with articles needs a destination
|
|
Given the author 2 is credited on 3 articles
|
|
When the owner sends DELETE /v1/author/2
|
|
Then the status is 409
|
|
And the body is "author_has_articles"
|
|
And the author and the 3 credits are unchanged
|
|
|
|
Scenario: The articles are reassigned to another author
|
|
Given the author 2 is credited on the articles 10, 11 and 12 and the author 3 exists
|
|
When the owner sends DELETE /v1/author/2?reassign_to=3
|
|
Then the status is 204
|
|
And the articles 10, 11 and 12 credit the author 3
|
|
And the author 2 no longer exists
|
|
|
|
Scenario: The reassigned author takes the position of the deleted one
|
|
Given the article 10 credits the authors 5, 2, 6 in that order
|
|
When the owner deletes the author 2 with reassign_to=3
|
|
Then the article 10 credits 5, 3, 6 in that order
|
|
|
|
Scenario: A reassignment never duplicates an author on an article
|
|
Given the article 10 credits the authors 2 then 3
|
|
When the owner deletes the author 2 with reassign_to=3
|
|
Then the article 10 credits only the author 3
|
|
|
|
Scenario: Nobody removes the byline
|
|
Given the article 10 credits only the author 2
|
|
When the owner sends DELETE /v1/author/2?reassign_to=none
|
|
Then the status is 204
|
|
And the article 10 has no authors
|
|
And the article 10 still exists, unchanged otherwise
|
|
|
|
Scenario: Only the deleted author is removed from a shared article
|
|
Given the article 11 credits the authors 5, 2, 6 in that order
|
|
When the owner sends DELETE /v1/author/2?reassign_to=none
|
|
Then the article 11 credits 5, 6 in that order
|
|
|
|
Scenario: Reassign and delete are all-or-nothing
|
|
Given the repository fails while deleting the author 2
|
|
When the owner sends DELETE /v1/author/2?reassign_to=3
|
|
Then the credits of the author 2 are unchanged
|
|
And the author 2 still exists
|
|
|
|
Scenario: The credits are moved by one repository operation
|
|
When the owner deletes an author with articles and a valid reassign_to
|
|
Then the use case calls a single repository operation that moves the credits and deletes the author
|
|
|
|
Scenario Outline: An unusable destination
|
|
Given the author 2 is credited on 3 articles
|
|
When the owner sends DELETE /v1/author/2?reassign_to=<reassign_to>
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And nothing changed
|
|
|
|
Examples:
|
|
| reassign_to | status | body |
|
|
| 999 | 404 | author_not_found |
|
|
| 2 | 400 | invalid_reassign |
|
|
| abc | 400 | invalid_reassign |
|
|
| -1 | 400 | invalid_reassign |
|
|
| (empty) | 400 | invalid_reassign |
|
|
|
|
Scenario: A linked author can be deleted and the account is untouched
|
|
Given the author 2 is linked to the user 3 and credits no article
|
|
When the owner deletes the author 2
|
|
Then the user 3 still exists and can create an author again
|
|
|
|
Scenario: A missing author is not found
|
|
When the owner sends DELETE /v1/author/999
|
|
Then the status is 404
|
|
And the body is "author_not_found"
|
|
|
|
Scenario: An unused reassign_to is ignored
|
|
Given the author 2 credits no article
|
|
When the owner sends DELETE /v1/author/2?reassign_to=3
|
|
Then the status is 204
|
|
And the author 3 is unchanged
|
|
|
|
Scenario Outline: Other roles and keys cannot delete
|
|
When DELETE /v1/author/2 is sent with <credentials>
|
|
Then the status is 403
|
|
And the body is "<body>"
|
|
And no author changed
|
|
|
|
Examples:
|
|
| credentials | body |
|
|
| a verified account with another role | Only owners and admins can do this. |
|
|
| a valid API key | This action needs a real session. |
|
|
|
|
Scenario: A role without author rights cannot delete its own author
|
|
Given the account is linked to the author 4
|
|
When it sends DELETE /v1/author/4
|
|
Then the status is 403 with body "Only owners and admins can do this."
|
|
|
|
Scenario: An admin can delete
|
|
When a verified admin sends DELETE /v1/author/2 on an author without articles
|
|
Then the status is 204
|
|
|
|
Rule: Only owner and admin write other people's authors
|
|
|
|
Scenario Outline: Refused writes change nothing
|
|
When <request> is sent with <credentials>
|
|
Then the status is 403
|
|
And the body is "<body>"
|
|
And no author changed
|
|
|
|
Examples:
|
|
| request | credentials | body |
|
|
| POST /v1/author/ | a verified account with another role | Only owners and admins can do this. |
|
|
| POST /v1/author/ | a valid API key | This action needs a real session. |
|
|
| PATCH /v1/author/2 | a verified account with another role | author_rights |
|
|
|
|
Rule: Articles credit authors in order
|
|
|
|
Scenario: An article is credited with ordered authors
|
|
Given the authors 5, 6, 7 exist and the article 8 has none
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": [6, 5, 7]}
|
|
Then the status is 200
|
|
And the article body's authors are 6, 5, 7 in that order
|
|
And the first is the primary byline
|
|
|
|
Scenario: The author list is replaced and can be emptied
|
|
Given the article 8 credits 5, 6
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": [6]}
|
|
Then the article 8 credits only 6
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": []}
|
|
Then the article 8 has authors []
|
|
|
|
Scenario: Reordering changes the positions
|
|
Given the article 8 credits 5, 6, 7
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": [7, 5, 6]}
|
|
Then GET /v1/article/8 lists the authors 7, 5, 6
|
|
|
|
Scenario Outline: Any account that can edit the article sets its authors
|
|
When PATCH /v1/article/8 is sent with {"author_ids": [5]} and <credentials>
|
|
Then the status is 200
|
|
|
|
Examples:
|
|
| credentials |
|
|
| the owner's jwt cookie |
|
|
| a verified admin's jwt cookie |
|
|
| a verified account with another role |
|
|
| a valid API key |
|
|
|
|
Scenario: The authors and the other fields change together
|
|
When the user sends PATCH /v1/article/8 with {"body": "New", "site_id": 2, "author_ids": [5]}
|
|
Then the body, the site_id and the authors are all updated
|
|
|
|
Scenario: An update without author_ids keeps the authors
|
|
Given the article 8 credits 5, 6
|
|
When the user sends PATCH /v1/article/8 with {"body": "New"}
|
|
Then the article 8 still credits 5, 6 in that order
|
|
|
|
Scenario Outline: An unusable author_ids is refused
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": <value>}
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And the article is unchanged
|
|
|
|
Examples:
|
|
| value | status | body |
|
|
| [999] | 404 | author_not_found |
|
|
| [5, 999] | 404 | author_not_found |
|
|
| [5, 5] | 400 | apperr.BodyErrorMessage |
|
|
| null | 400 | apperr.BodyErrorMessage |
|
|
| "a" | 400 | apperr.BodyErrorMessage |
|
|
| [-1] | 400 | apperr.BodyErrorMessage |
|
|
| [0] | 400 | the validator error text |
|
|
|
|
Scenario: A refused update changes no authors
|
|
Given the article 8 credits 5
|
|
When the user sends PATCH /v1/article/8 with {"body": "New", "author_ids": [999]}
|
|
Then the status is 404
|
|
And the article 8 body is unchanged and it still credits 5
|
|
|
|
Scenario: An unchanged list records nothing
|
|
Given the article 8 credits 5, 6
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": [5, 6]}
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario: An article without authors can be published
|
|
Given the article 8 has no authors and is offline
|
|
When the user sends PATCH /v1/article/8/publish
|
|
Then the status is 200
|
|
And the article is online
|
|
|
|
Scenario Outline: Article reads carry the ordered authors
|
|
Given the article 8 credits the authors 6 then 5
|
|
When <request> is sent
|
|
Then the article in the response has "authors" listing 6 then 5 with only id, name, slug, bio, avatar_id, website and social_links
|
|
|
|
Examples:
|
|
| request |
|
|
| GET /v1/article/8 |
|
|
| GET /v1/article/ (each item of items) |
|
|
| POST /v1/article |
|
|
| PATCH /v1/article/8 |
|
|
| PATCH /v1/article/8/publish |
|
|
| PATCH /v1/article/8/unpublish |
|
|
|
|
Scenario: An article without authors returns an empty list
|
|
When GET /v1/article/8 is sent on an article with no authors
|
|
Then "authors" is []
|
|
|
|
Scenario: Anonymous readers see the authors of online articles
|
|
Given the article 9 is online and credits the linked author 5
|
|
When a caller without credentials sends GET /v1/article/9
|
|
Then the authors are returned
|
|
And the body has no user_id, no user_email and no article_count in any author
|
|
|
|
Scenario: An offline article is still not found publicly
|
|
When a caller without a verified account sends GET /v1/article/{offline id}
|
|
Then the status is 404 with body "Article not found"
|
|
|
|
Scenario: A new article defaults to the creator's author
|
|
Given the caller is linked to the author 5
|
|
When it sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
|
|
Then the article is offline
|
|
And its authors are [5]
|
|
|
|
Scenario: A creator without an author creates an article without authors
|
|
Given the caller has no author
|
|
When it sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
|
|
Then its authors are []
|
|
|
|
Scenario: Deleting an article removes its credits and keeps the author
|
|
Given the article 8 credits the author 5, who credits 2 articles
|
|
When the article 8 is deleted
|
|
Then the author 5 still exists with article_count 1
|
|
|
|
Scenario: article_count counts offline articles too
|
|
Given the author 5 credits one online and one offline article
|
|
Then the author 5 has article_count 2
|
|
|
|
Scenario: The list filter and counts are unaffected
|
|
When GET /v1/article/?status=online&q=spring is sent
|
|
Then the items and counts are as before this slice, with authors on each item
|
|
|
|
Rule: The current user carries the linked author
|
|
|
|
Scenario: GET /v1/auth/me includes the linked author
|
|
Given the caller is linked to the author 5
|
|
When GET /v1/auth/me is sent
|
|
Then the body has the existing user fields and "author" holding the author 5 (id, name, slug, bio, avatar_id, website, social_links, article_count) with no user_email
|
|
|
|
Scenario: A user without an author has author null
|
|
When GET /v1/auth/me is sent by an account without an author
|
|
Then "author" is null
|
|
|
|
Scenario: The field follows link changes
|
|
Given the caller was unlinked by an admin
|
|
When GET /v1/auth/me is sent
|
|
Then "author" is null
|
|
|
|
Scenario: The existing fields are unchanged
|
|
When GET /v1/auth/me is sent
|
|
Then the body still has id, username, email, is_verified, created_at, updated_at, display_name and role
|
|
And no password, token or code is present
|
|
|
|
Scenario: An API key reads the same body
|
|
When GET /v1/auth/me is sent with a valid API key
|
|
Then "author" is present as for a session
|
|
|
|
Rule: Media usage by avatars
|
|
|
|
Scenario: An image used as an avatar is counted as used
|
|
Given the image 5 is the avatar of the author 2 and appears in no article or site
|
|
When a verified account looks up the usage of the image 5
|
|
Then used_in counts the author and the image is not "Unused"
|
|
|
|
Scenario: The media list counts the author
|
|
Given the image 5 is used by an article, a site and an author
|
|
When a verified account sends GET /v1/media/
|
|
Then the used_in of the image 5 is 3
|
|
|
|
Scenario: Deleting the image clears the author's avatar
|
|
When the owner deletes the image 5
|
|
Then the status is 204
|
|
And the author 2 has avatar_id null
|
|
|
|
Scenario: Merging copies repoints the author
|
|
Given the image 6 is a copy of the image 5 and the author 2 uses the image 6
|
|
When the copies are merged into the image 5
|
|
Then the author 2 has avatar_id 5
|
|
And the merge response reports authors_updated 1
|
|
|
|
Scenario: Purge repoints authors the same way
|
|
Given a purge group whose kept image is 5 and whose duplicate 6 is used by the author 2
|
|
When the purge runs
|
|
Then the author 2 has avatar_id 5
|
|
And the purge result reports authors_updated 1
|
|
|
|
Scenario: A failed purge group leaves the author pointing at an existing image
|
|
Given the purge of a group fails midway
|
|
Then every author avatar_id refers to an image that still exists
|
|
|
|
Scenario: The usage lookup stays an array and later gains additive keys
|
|
When a verified account looks up the usage of an image
|
|
Then the response is a JSON array whose article items keep id, title and published
|
|
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
|
|
|
|
Rule: Every author write is recorded in the activity log
|
|
|
|
Scenario Outline: A successful write is recorded once after the change
|
|
When <actor> performs <request>
|
|
Then one event exists with action "<action>", category "authors", target {"type": "author", "id": "<id>", "name": "<name>"}, outcome "success", actor <actor> and source "web"
|
|
And the event was recorded after the change was applied
|
|
|
|
Examples:
|
|
| actor | request | action | id | name |
|
|
| the owner | POST /v1/author/ for "Camille Roche" | author.create | 2 | Camille Roche |
|
|
| the caller | POST /v1/author/me for "Marta Alves" | author.create | 3 | Marta Alves |
|
|
| the owner | PATCH /v1/author/2 renaming it | author.edit | 2 | Camille R. |
|
|
| the owner | PATCH /v1/author/2 linking the user "marta" | author.edit | 2 | Camille R. |
|
|
| the owner | DELETE /v1/author/2 | author.delete | 2 | Camille R. |
|
|
|
|
Scenario: The change fields are limited and carry no email
|
|
When the owner edits an author's name, slug, bio, website, social links, avatar and linked user
|
|
Then the changes use only the fields name, slug, bio, website, social_links, avatar and user
|
|
And the user change holds usernames, never an email
|
|
|
|
Scenario: An own-author edit by a role without author rights is recorded
|
|
When the account with another role edits its own author
|
|
Then one event "author.edit" exists with that account as actor
|
|
|
|
Scenario Outline: A refused write is recorded as failed with a plain reason
|
|
When the actor performs <request> which is refused with <code>
|
|
Then one event exists with action "<action>", outcome "failed" and failure_reason "<reason>"
|
|
|
|
Examples:
|
|
| request | code | action | reason |
|
|
| POST /v1/author/ with a taken slug | author_slug_taken | author.create | That slug is already used by another author. |
|
|
| POST /v1/author/me by a linked caller | user_already_linked | author.create | That user already has an author. |
|
|
| PATCH /v1/author/2 with a taken slug | author_slug_taken | author.edit | That slug is already used by another author. |
|
|
| PATCH /v1/author/2 linking a linked user | user_already_linked | author.edit | That user already has an author. |
|
|
| PATCH /v1/author/2 by a non-admin on it | author_rights | author.edit | Only an owner or admin can change this author. |
|
|
| DELETE /v1/author/2 with articles | author_has_articles | author.delete | The author is still credited on articles. |
|
|
| DELETE /v1/author/2 reassigned to itself | invalid_reassign | author.delete | The destination author is not valid. |
|
|
| POST /v1/author/ when the repository fails with "db is locked" | 500 | author.create | Something went wrong on our side. Please try again. |
|
|
| DELETE /v1/author/2 when the repository fails | 500 | author.delete | Something went wrong on our side. Please try again. |
|
|
|
|
Scenario Outline: Nothing is recorded when there is nothing to attribute
|
|
When <request> is sent
|
|
Then no activity event is recorded
|
|
|
|
Examples:
|
|
| request |
|
|
| PATCH /v1/author/999 as the owner |
|
|
| DELETE /v1/author/999 as the owner |
|
|
| POST /v1/author/ with an unknown avatar (image_not_found) |
|
|
| POST /v1/author/ with a bio over 280 characters |
|
|
| POST /v1/author/ as a role without author rights (403) |
|
|
| POST /v1/author/ with no credentials (401) |
|
|
| PATCH /v1/author/abc as the owner |
|
|
| POST /v1/author/ with a malformed body |
|
|
|
|
Scenario: A failing audit repository never changes the response
|
|
Given the audit repository fails on Append
|
|
When the owner sends POST /v1/author/ with valid values
|
|
Then the response equals that of a healthy repository
|
|
And the author was created
|
|
|
|
Scenario: Reads are never recorded
|
|
When GET /v1/author/ is sent
|
|
Then no activity event is recorded
|
|
|
|
Scenario: An article's authors change is recorded in article.edit
|
|
Given the article 8 "Spring release" credits "Inès Duarte" and "Marta Alves"
|
|
When the user sends PATCH /v1/article/8 with {"author_ids": [Marta, Inès, Zoe]}
|
|
Then one event "article.edit" exists with changes [{"field": "authors", "from": "Inès Duarte, Marta Alves", "to": "Marta Alves, Inès Duarte, Zoe Lambert"}]
|
|
|
|
Scenario: Deleting an author with reassignment records only the author event
|
|
When the owner deletes the author 2 with reassign_to=3 on 3 articles
|
|
Then exactly one event "author.delete" exists
|
|
And no "article.edit" event exists
|
|
|
|
Scenario: The activity API lists author events
|
|
Given author events were recorded
|
|
When a verified account sends GET /v1/activity/?category=authors
|
|
Then the author events are returned with category "authors" and no other category
|
|
|
|
Rule: Persistence and migration
|
|
|
|
Scenario: The new tables are migrated
|
|
When database.Migrate runs on an empty database
|
|
Then the authors and article_authors tables exist
|
|
And the authors table has unique indexes on slug and on user_id, where many NULL user_id values are allowed
|
|
And article_authors has a unique pair (article_id, author_id) and a position
|
|
|
|
Scenario: Existing articles get no authors
|
|
Given a database from before this slice holding 4 articles
|
|
When database.Migrate runs
|
|
Then all 4 articles have no authors and no author is created
|
|
|
|
Scenario: It works on every database driver
|
|
When the repository contract suite runs
|
|
Then the author repository behaves the same on SQLite, MySQL and PostgreSQL
|
|
And the SQLite schema test accounts for the new tables
|
|
|
|
Scenario: Order is stable
|
|
Given the article 8 credits 7, 5, 6
|
|
When it is read back from the repository
|
|
Then the authors come back as 7, 5, 6
|
|
|
|
Rule: Total in X-Total-Count
|
|
|
|
Background:
|
|
Given GET /v1/author/ keeps its {"items","total","page","size"} body and also sends total in the X-Total-Count header on every 200
|
|
And the header equals the body total
|
|
And X-Total-Count is already in the CORS ExposedHeaders
|
|
And a refused request sends no X-Total-Count
|
|
|
|
Scenario: The total is also sent as a header
|
|
Given 30 authors
|
|
When a verified account sends GET /v1/author/?size=5
|
|
Then the body total is 30 and the header X-Total-Count is "30"
|
|
|
|
Scenario: The header follows the search
|
|
Given 30 authors of which 7 match "mar"
|
|
When a verified account sends GET /v1/author/?q=mar
|
|
Then X-Total-Count is "7" and the body total is 7
|
|
|
|
Scenario: A page past the end keeps the total in the header
|
|
Given 30 authors
|
|
When a verified account sends GET /v1/author/?page=3&size=100
|
|
Then items is [], the body total is 30 and X-Total-Count is "30"
|
|
|
|
Scenario: No match
|
|
When a verified account sends GET /v1/author/?q=nobody
|
|
Then items is [], the body total is 0 and X-Total-Count is "0"
|
|
|
|
Scenario Outline: Every reader gets the header
|
|
When GET /v1/author/ is sent with <credentials>
|
|
Then the status is 200 and X-Total-Count equals the body total
|
|
|
|
Examples:
|
|
| credentials |
|
|
| the owner's jwt cookie |
|
|
| a verified admin's jwt cookie |
|
|
| a verified account with another role |
|
|
| a valid API key |
|
|
|
|
Scenario Outline: Refused requests send no header
|
|
When GET /v1/author/?<params> is sent with <credentials>
|
|
Then the status is <status> and no X-Total-Count is sent
|
|
|
|
Examples:
|
|
| params | credentials | status |
|
|
| size=30 | a verified account | 400 |
|
|
| page=0 | a verified account | 400 |
|
|
| page=abc | a verified account | 400 |
|
|
| | an unverified account's jwt cookie | 403 |
|
|
| | no credentials | 401 |
|
|
|
|
Scenario: CORS exposes the header
|
|
Given CORS_ALLOWED_ORIGINS includes "https://app.example.com"
|
|
When a preflighted GET /v1/author/ comes from that origin
|
|
Then Access-Control-Expose-Headers lists X-Total-Count
|
|
|
|
Rule: Documentation
|
|
|
|
Scenario: The docs describe the new surface
|
|
Then docs/api.md names X-Total-Count on the GET /v1/author/ row
|
|
And docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field
|
|
And docs/architecture.md mentions the author aggregate, its repository and its use cases
|