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 Then the status is 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= Then the items are And total is 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/? Then the status is And 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 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": ""} Then the slug is "" 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 Then the status is And the body is "" 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 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": } Then the status is And the body is "" 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 "" and username "" and no author When it sends POST /v1/author/me Then the status is 201 And the author name is "" and the slug is "" And the author has user_id of the caller and is_linked true 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 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 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" 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 Then the status is 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 Then the status is 200 And 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 Then the status is And the body is "" 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 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/ with {"bio": "Updated."} Then the status is And the body is "" 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 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= Then the status is And the body is "" 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 Then the status is 403 And the body is "" 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 is sent with Then the status is 403 And the body is "" 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 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": } Then the status is And the body is "" 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 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 array shape is unchanged When a verified account looks up the usage of an image Then the response keeps the same array shape as before this slice Rule: Every author write is recorded in the activity log Scenario Outline: A successful write is recorded once after the change When performs Then one event exists with action "", category "authors", target {"type": "author", "id": "", "name": ""}, outcome "success", 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 which is refused with Then one event exists with action "", outcome "failed" and failure_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 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