Files
RenewCMS/features/authors_backend.feature
T

873 lines
43 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: Documentation
Scenario: The docs describe the new surface
Then 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