640 lines
30 KiB
Gherkin
640 lines
30 KiB
Gherkin
Feature: Sites backend, the owner manages sites and every article belongs to exactly one
|
|
|
|
As the owner of a RenewCMS installation
|
|
I want to create, edit, delete and choose a default among several sites, and assign each article to one
|
|
So that a single back-office can hold the content of more than one website
|
|
|
|
Background:
|
|
Given the spec of record is docs/design/screens/sites.md, docs/design/states.md (site 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 a Site has id (uint32), name, key, base_url, default_social_image_id (nullable), default_robots ("index" or "noindex"), locale (nullable), is_default and article_count, in snake_case JSON
|
|
And exactly one site is the default at any time, enforced by the use case inside a transaction and not by a partial index
|
|
And every article has a site_id referencing an existing site
|
|
And the routes live under /v1/site, mounted inside the JWT group: GET /, POST /, PATCH /{id}, PATCH /{id}/default, DELETE /{id}?reassign_to={id}
|
|
And GET /v1/site/ accepts a session or an API key; every write is session-only and goes through RequireOwner, like PATCH /v1/settings
|
|
And PATCH /v1/article/{id} with site_id stays callable with an API key
|
|
And error responses are plain text from http.Error whose body is exactly the code token: site_key_taken, site_has_articles, default_site, key_immutable, invalid_site_url, invalid_locale, site_not_found, image_not_found, use_set_default, invalid_reassign
|
|
And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.OwnerRequiredErrorMessage), with no owner_only token
|
|
And a request body that cannot be decoded answers 400 with apperr.BodyErrorMessage ("The request cannot be processed due to a mismatch in the format of the body."), a malformed id answers 400 with apperr.IdUint32ErrorMessage, and a validate-tag failure answers 400 with the validator error text, like POST /v1/article
|
|
And a missing article answers 404 "Article not found" (apperr.ArticleNotFoundErrorMessage)
|
|
And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true
|
|
And success responses are JSON with snake_case fields
|
|
And request validation goes through validate tags and validator.Validate.Struct
|
|
And the key matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 2 to 32 characters, the name has 1 to 100 characters, the locale matches ^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$ and an empty or null locale means none
|
|
And base_url must be https, except http for localhost, 127.0.0.1 and [::1], with no path, query or userinfo; a trailing slash is stripped; uniqueness is not enforced
|
|
And handlers reach sites only through use cases and domain interfaces, and no comment is added anywhere
|
|
|
|
Rule: Reading sites
|
|
|
|
Scenario: The list puts the default site first and then orders by name
|
|
Given the sites "Zeta" (key "zeta"), "Alpha" (key "alpha") and "Main" (key "main", default)
|
|
When a verified account sends GET /v1/site/
|
|
Then the status is 200
|
|
And the body lists "Main", "Alpha", "Zeta" in that order
|
|
And every site carries article_count
|
|
|
|
Scenario Outline: Who can read the list
|
|
Given at least one site exists
|
|
When GET /v1/site/ 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 valid API key | 200 |
|
|
| an unverified account's jwt cookie | 403 |
|
|
| no credentials | 401 |
|
|
|
|
Scenario: A site without image or locale returns nulls
|
|
Given the site "Blog" has no default social image and no locale
|
|
When a verified account sends GET /v1/site/
|
|
Then "Blog" has default_social_image_id null and locale null
|
|
|
|
Rule: Creating a site is owner only
|
|
|
|
Scenario: The owner creates a site
|
|
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}
|
|
Then the status is 201
|
|
And the body is the site with a new id, is_default false, article_count 0 and the given values
|
|
And the site is stored
|
|
|
|
Scenario: Optional fields default
|
|
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
|
|
Then the site has default_robots "index", locale null, default_social_image_id null and is_default false
|
|
|
|
Scenario: A new site can become the default at creation
|
|
Given the site "Main" is the default
|
|
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}
|
|
Then "Shop" is the default and "Main" is not
|
|
And exactly one site is the default
|
|
|
|
Scenario Outline: The base URL is normalised
|
|
When the owner creates a site with base_url "<sent>"
|
|
Then the stored base_url is "<stored>"
|
|
|
|
Examples:
|
|
| sent | stored |
|
|
| https://shop.example.com/ | https://shop.example.com |
|
|
| https://Shop.Example.com | https://shop.example.com |
|
|
| http://localhost:5173/ | http://localhost:5173 |
|
|
|
|
Scenario Outline: The base URL must be https except loopback and carry no path
|
|
When the owner creates a site with base_url "<sent>"
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
|
|
Examples:
|
|
| sent | status | body |
|
|
| https://shop.example.com | 201 | |
|
|
| http://localhost:8080 | 201 | |
|
|
| http://127.0.0.1:8080 | 201 | |
|
|
| http://[::1]:8080 | 201 | |
|
|
| http://shop.example.com | 400 | invalid_site_url |
|
|
| https://shop.example.com/blog | 400 | invalid_site_url |
|
|
| https://shop.example.com/?a=1 | 400 | invalid_site_url |
|
|
| https://user@shop.example.com | 400 | invalid_site_url |
|
|
| ftp://shop.example.com | 400 | invalid_site_url |
|
|
| shop.example.com | 400 | invalid_site_url |
|
|
|
|
Scenario Outline: The key format
|
|
When the owner creates a site with key "<key>"
|
|
Then the status is <status>
|
|
|
|
Examples:
|
|
| key | status |
|
|
| shop | 201 |
|
|
| my-shop-2 | 201 |
|
|
| ab | 201 |
|
|
| a | 400 |
|
|
| Shop | 400 |
|
|
| -shop | 400 |
|
|
| shop- | 400 |
|
|
| my--shop | 400 |
|
|
| my_shop | 400 |
|
|
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 400 |
|
|
| (empty) | 400 |
|
|
|
|
Scenario: A taken key is refused
|
|
Given the site with key "shop" exists
|
|
When the owner sends POST /v1/site/ with key "shop"
|
|
Then the status is 409
|
|
And the body is "site_key_taken"
|
|
And no site is added
|
|
|
|
Scenario Outline: Other fields are validated
|
|
When the owner sends POST /v1/site/ with <field> <value>
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And no site is added
|
|
|
|
Examples:
|
|
| field | value | status | body |
|
|
| name | (empty) | 400 | the validator error text |
|
|
| name | 101 characters | 400 | the validator error text |
|
|
| default_robots | "follow" | 400 | the validator error text |
|
|
| locale | "english" | 400 | invalid_locale |
|
|
| locale | "fr_FR" | 400 | invalid_locale |
|
|
| default_social_image_id | 999 (no such image) | 400 | image_not_found |
|
|
|
|
Scenario: A malformed body is refused
|
|
When the owner sends POST /v1/site/ with the body "{not json"
|
|
Then the status is 400
|
|
And the body is apperr.BodyErrorMessage
|
|
And no site is added
|
|
|
|
Scenario Outline: A valid locale is accepted and an empty one clears it
|
|
When the owner creates a site with locale <locale>
|
|
Then the status is 201
|
|
And the stored locale is <stored>
|
|
|
|
Examples:
|
|
| locale | stored |
|
|
| "en" | "en" |
|
|
| "fr-CA" | "fr-CA" |
|
|
| "zh-Hant-TW" | "zh-Hant-TW" |
|
|
| "" | null |
|
|
| null | null |
|
|
|
|
Rule: Editing a site is owner only
|
|
|
|
Scenario: The owner edits a site
|
|
Given the site 2 "Shop" with key "shop"
|
|
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}
|
|
Then the status is 200
|
|
And the body is the site with the new values and the key "shop"
|
|
|
|
Scenario: Absent fields are untouched
|
|
Given the site 2 has locale "fr" and default_robots "noindex"
|
|
When the owner sends PATCH /v1/site/2 with {"name": "Boutique"}
|
|
Then only the name changed
|
|
|
|
Scenario: The default social image is set and cleared
|
|
Given the image 5 exists
|
|
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5}
|
|
Then the site 2 has default_social_image_id 5
|
|
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": null}
|
|
Then the site 2 has default_social_image_id null
|
|
|
|
Scenario Outline: The key never changes
|
|
Given the site 2 has key "shop"
|
|
When the owner sends PATCH /v1/site/2 with {"key": "<key>"}
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And the key of site 2 is still "shop"
|
|
|
|
Examples:
|
|
| key | status | body |
|
|
| shop | 200 | |
|
|
| store | 400 | key_immutable |
|
|
|
|
Scenario: A rejected key change leaves every other field unchanged
|
|
Given the site 2 "Shop"
|
|
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "key": "store"}
|
|
Then the status is 400 with body "key_immutable"
|
|
And the name of site 2 is still "Shop"
|
|
|
|
Scenario: The default flag is not editable here
|
|
When the owner sends PATCH /v1/site/2 with {"is_default": true}
|
|
Then the status is 400
|
|
And the body is "use_set_default"
|
|
And the default site did not change
|
|
|
|
Scenario Outline: Editing is validated like creation
|
|
When the owner sends PATCH /v1/site/2 with <field> <value>
|
|
Then the status is 400
|
|
And the body is "<body>"
|
|
And the site 2 is unchanged
|
|
|
|
Examples:
|
|
| field | value | body |
|
|
| base_url | "http://shop.example.com" | invalid_site_url |
|
|
| base_url | "https://shop.example.com/x" | invalid_site_url |
|
|
| locale | "fr_FR" | invalid_locale |
|
|
| default_social_image_id | 999 | image_not_found |
|
|
| name | (empty) | the validator error text |
|
|
| default_robots | "follow" | the validator error text |
|
|
|
|
Scenario: A missing site is not found
|
|
When the owner sends PATCH /v1/site/999 with {"name": "X"}
|
|
Then the status is 404
|
|
And the body is "site_not_found"
|
|
|
|
Scenario: A no-op edit records nothing
|
|
When the owner sends PATCH /v1/site/2 with the values it already has
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario: A malformed id is refused
|
|
When the owner sends PATCH /v1/site/abc with {"name": "X"}
|
|
Then the status is 400
|
|
And the body is the uint32 id format error
|
|
|
|
Rule: The default site
|
|
|
|
Scenario: The owner makes another site the default
|
|
Given "Main" is the default and "Shop" is not
|
|
When the owner sends PATCH /v1/site/2/default
|
|
Then the status is 200
|
|
And the body is "Shop" with is_default true
|
|
And "Main" is no longer the default
|
|
|
|
Scenario: Setting the current default is a no-op
|
|
When the owner sends PATCH /v1/site/1/default on the current default
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario: A missing site cannot become the default
|
|
When the owner sends PATCH /v1/site/999/default
|
|
Then the status is 404
|
|
And the body is "site_not_found"
|
|
|
|
Scenario: The swap is atomic
|
|
Given the repository fails while flagging the new default
|
|
When the owner sends PATCH /v1/site/2/default
|
|
Then the previous default is still the only default
|
|
|
|
Scenario: New articles start on the new default
|
|
Given "Shop" became the default
|
|
When an article is created without a site
|
|
Then its site_id is the id of "Shop"
|
|
|
|
Rule: Deleting a site is owner only
|
|
|
|
Scenario: A site without articles is deleted
|
|
Given the site 2 has no articles and is not the default
|
|
When the owner sends DELETE /v1/site/2
|
|
Then the status is 204
|
|
And the site 2 no longer exists
|
|
|
|
Scenario: A site with articles needs a destination
|
|
Given the site 2 has 3 articles
|
|
When the owner sends DELETE /v1/site/2
|
|
Then the status is 409
|
|
And the body is "site_has_articles"
|
|
And the site and its articles are unchanged
|
|
|
|
Scenario: The articles move in one transaction
|
|
Given the site 2 has 3 articles and the site 3 exists
|
|
When the owner sends DELETE /v1/site/2?reassign_to=3
|
|
Then the status is 204
|
|
And the 3 articles now have site_id 3
|
|
And the site 2 no longer exists
|
|
|
|
Scenario: Reassign and delete are all-or-nothing
|
|
Given the repository fails while deleting the site 2
|
|
When the owner sends DELETE /v1/site/2?reassign_to=3
|
|
Then the articles of the site 2 still have site_id 2
|
|
And the site 2 still exists
|
|
|
|
Scenario: The articles are moved by one repository operation
|
|
When the owner deletes a site with articles and a valid reassign_to
|
|
Then the use case calls a single repository operation that moves the articles and deletes the site
|
|
|
|
Scenario Outline: An unusable destination
|
|
Given the site 2 has 3 articles and is not the default
|
|
When the owner sends DELETE /v1/site/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 | site_not_found |
|
|
| 2 | 400 | invalid_reassign |
|
|
| abc | 400 | invalid_reassign |
|
|
| -1 | 400 | invalid_reassign |
|
|
|
|
Scenario: The default site cannot be deleted
|
|
When the owner sends DELETE /v1/site/1 on the default site
|
|
Then the status is 403
|
|
And the body is "default_site"
|
|
|
|
Scenario: The default check comes before the articles check
|
|
Given the default site has 5 articles
|
|
When the owner sends DELETE /v1/site/1 without reassign_to
|
|
Then the status is 403 with body "default_site"
|
|
|
|
Scenario: A missing site is not found
|
|
When the owner sends DELETE /v1/site/999
|
|
Then the status is 404
|
|
And the body is "site_not_found"
|
|
|
|
Scenario: An unused reassign_to is ignored
|
|
Given the site 2 has no articles
|
|
When the owner sends DELETE /v1/site/2?reassign_to=3
|
|
Then the status is 204
|
|
And the site 3 is unchanged
|
|
|
|
Rule: Only the owner writes
|
|
|
|
Scenario Outline: Other roles and keys are refused
|
|
When <request> is sent with <credentials>
|
|
Then the status is 403
|
|
And the body is "<body>"
|
|
And no site changed
|
|
|
|
Examples:
|
|
| request | credentials | body |
|
|
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
|
|
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
|
|
| PATCH /v1/site/2/default | a verified admin's jwt cookie | Only the owner can do this. |
|
|
| DELETE /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
|
|
| POST /v1/site/ | a valid API key | This action needs a real session. |
|
|
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
|
|
| PATCH /v1/site/2/default | a valid API key | This action needs a real session. |
|
|
| DELETE /v1/site/2 | a valid API key | This action needs a real session. |
|
|
|
|
Scenario: The role is checked before the body
|
|
When an admin sends POST /v1/site/ with the body "{not json"
|
|
Then the status is 403 with body "Only the owner can do this."
|
|
|
|
Rule: Seed and migration
|
|
|
|
Scenario: A fresh database gets one default site from PUBLIC_URL
|
|
Given an empty database and PUBLIC_URL "https://www.example.com/blog"
|
|
When database.Migrate runs with that PUBLIC_URL
|
|
Then one site exists with name "Default", key "default", base_url "https://www.example.com", default_robots "index", locale null and is_default true
|
|
|
|
Scenario Outline: The seed follows the base URL rule
|
|
Given an empty database
|
|
When database.Migrate runs with PUBLIC_URL "<public_url>"
|
|
Then the seeded base_url is "<base_url>"
|
|
|
|
Examples:
|
|
| public_url | base_url |
|
|
| https://www.example.com/ | https://www.example.com |
|
|
| https://www.example.com/a/b | https://www.example.com |
|
|
| http://www.example.com | https://www.example.com |
|
|
| http://localhost:8080 | http://localhost:8080 |
|
|
| http://127.0.0.1:8080/x | http://127.0.0.1:8080 |
|
|
|
|
Scenario: An unparseable PUBLIC_URL stops the migration
|
|
Given an empty database
|
|
When database.Migrate runs with PUBLIC_URL "://not a url"
|
|
Then it returns an error naming PUBLIC_URL
|
|
And no site is created
|
|
|
|
Scenario: Existing articles are backfilled
|
|
Given a database from before this slice holding 4 articles
|
|
When database.Migrate runs
|
|
Then all 4 articles have the seeded default site as site_id
|
|
|
|
Scenario: The seed runs once
|
|
Given the sites table already holds a site
|
|
When database.Migrate runs again with another PUBLIC_URL
|
|
Then no site is added and no site is changed
|
|
|
|
Scenario: PUBLIC_URL is not used afterwards
|
|
Given the owner edited the default site base_url
|
|
When the process restarts with the same PUBLIC_URL
|
|
Then the default site keeps the owner's base_url
|
|
|
|
Scenario: It works on every database driver
|
|
When the repository contract suite runs
|
|
Then the site repository behaves the same on SQLite, MySQL and PostgreSQL
|
|
And the SQLite schema test accounts for the new table and column
|
|
|
|
Rule: Articles belong to a site
|
|
|
|
Scenario: A new article starts on the default site
|
|
Given "Main" is the default site with id 1
|
|
When the owner sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
|
|
Then the stored article has site_id 1
|
|
And the article is offline
|
|
|
|
Scenario Outline: Article reads carry site_id
|
|
Given the article 8 belongs to the site 2
|
|
When <request> is sent
|
|
Then the article in the response has "site_id": 2
|
|
|
|
Examples:
|
|
| request |
|
|
| GET /v1/article/8 |
|
|
| GET /v1/article/ (each item of items) |
|
|
| POST /v1/article |
|
|
| PATCH /v1/article/8/publish |
|
|
| PATCH /v1/article/8 |
|
|
|
|
Scenario: The list filters by site key
|
|
Given the site "shop" has 2 articles and "main" has 3
|
|
When a verified account sends GET /v1/article/?site=shop
|
|
Then only the 2 articles of "shop" are listed
|
|
|
|
Scenario Outline: The filter composes with the other parameters
|
|
Given the site "shop" has articles of mixed status and titles
|
|
When GET /v1/article/?site=shop&<params> is sent
|
|
Then every returned article belongs to "shop" and satisfies <params>
|
|
|
|
Examples:
|
|
| params |
|
|
| status=online |
|
|
| status=draft |
|
|
| q=spring |
|
|
| sort=title:asc |
|
|
| size=5&page=2 |
|
|
| status=draft&q=launch |
|
|
|
|
Scenario Outline: Unknown or empty site keys
|
|
When GET /v1/article/?site=<key> is sent
|
|
Then the status is <status>
|
|
And <result>
|
|
And the counts stay global
|
|
|
|
Examples:
|
|
| key | status | result |
|
|
| nope | 200 | the list is empty |
|
|
| (empty) | 400 | the body is apperr.BodyErrorMessage |
|
|
|
|
Scenario: An anonymous or unverified list is filtered by site and shows online articles only
|
|
Given "shop" has one online and one offline article
|
|
When a caller without a verified account sends GET /v1/article/?site=shop
|
|
Then only the online article is returned
|
|
|
|
Scenario: An offline article is still not found publicly
|
|
Given the article 9 is offline on any site
|
|
When a caller without a verified account sends GET /v1/article/9
|
|
Then the status is 404
|
|
And the body is "Article not found"
|
|
|
|
Scenario: An article moves to another site
|
|
Given the article 8 is on the site 1 and the site 2 exists
|
|
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
|
Then the status is 200
|
|
And the article has site_id 2
|
|
|
|
Scenario Outline: Any role that can edit the article may choose its site
|
|
Given the article 8 is editable by <credentials>
|
|
When PATCH /v1/article/8 is sent with {"site_id": 2} and <credentials>
|
|
Then the status is 200
|
|
|
|
Examples:
|
|
| credentials |
|
|
| the owner's jwt cookie |
|
|
| a verified admin's jwt cookie |
|
|
| a valid API key |
|
|
|
|
Scenario: The body and the site change together
|
|
When the user sends PATCH /v1/article/8 with {"title": "New", "site_id": 2}
|
|
Then both the title and the site_id are updated
|
|
|
|
Scenario: An update without site_id keeps the site
|
|
Given the article 8 is on the site 2
|
|
When the user sends PATCH /v1/article/8 with {"title": "New"}
|
|
Then the article is still on the site 2
|
|
|
|
Scenario Outline: An unusable site_id is refused
|
|
When the user sends PATCH /v1/article/8 with {"site_id": <value>}
|
|
Then the status is <status>
|
|
And the body is "<body>"
|
|
And the article is unchanged
|
|
|
|
Examples:
|
|
| value | status | body |
|
|
| 999 | 404 | site_not_found |
|
|
| 0 | 400 | the validator error text |
|
|
| -1 | 400 | apperr.BodyErrorMessage |
|
|
| "a" | 400 | apperr.BodyErrorMessage |
|
|
|
|
Scenario: A site cannot be removed from an article
|
|
When the user sends PATCH /v1/article/8 with {"site_id": null}
|
|
Then the status is 400
|
|
And the body is apperr.BodyErrorMessage
|
|
And the article is unchanged
|
|
|
|
Scenario: Moving to the same site records nothing
|
|
Given the article 8 is on the site 2
|
|
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
|
Then the status is 200
|
|
And no activity event is recorded
|
|
|
|
Scenario Outline: An empty or unknown update is refused
|
|
When the user sends PATCH /v1/article/8 with <body>
|
|
Then the status is 400
|
|
And the body is apperr.BodyErrorMessage
|
|
And the article is unchanged
|
|
|
|
Examples:
|
|
| body |
|
|
| {} |
|
|
| {"colour": "x"} |
|
|
|
|
Scenario: is_online is independent of the site
|
|
Given the article 8 is online
|
|
When the user moves it to the site 2
|
|
Then it is still online
|
|
|
|
Scenario: Articles keep working after a site rename
|
|
Given the article 8 is on the site 2
|
|
When the owner renames the site 2
|
|
Then GET /v1/article/8 still returns the article with site_id 2
|
|
|
|
Scenario: A site change is recorded in article.edit with site names
|
|
Given the article 8 "Spring release" is on "Main" and "Shop" exists
|
|
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
|
Then one event "article.edit" exists with changes [{"field": "site", "from": "Main", "to": "Shop"}]
|
|
|
|
Rule: Media usage by default social images
|
|
|
|
Scenario: An image used as a default social image is counted as used
|
|
Given the image 5 is the default social image of the site 2 and appears in no article
|
|
When a verified account looks up the usage of the image 5
|
|
Then used_in counts the site and the image is not "Unused"
|
|
|
|
Scenario: Deleting the image clears the site's field
|
|
When the owner deletes the image 5
|
|
Then the status is 204
|
|
And the site 2 has default_social_image_id null
|
|
|
|
Scenario: Merging copies repoints the site
|
|
Given the image 6 is a copy of the image 5 and the site 2 uses the image 6
|
|
When the copies are merged into the image 5
|
|
Then the site 2 has default_social_image_id 5
|
|
And the merge response reports sites_updated 1
|
|
|
|
Scenario: Purge repoints sites the same way
|
|
Given a purge group whose kept image is 5 and whose duplicate 6 is used by the site 2
|
|
When the purge runs
|
|
Then the site 2 has default_social_image_id 5
|
|
And the purge result reports sites_updated 1
|
|
|
|
Scenario: A failed purge group leaves the site pointing at an existing image
|
|
Given the purge of a group fails midway
|
|
Then every site default_social_image_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 site write is recorded in the activity log
|
|
|
|
Scenario Outline: A successful write is recorded once after the change
|
|
When the owner performs <request>
|
|
Then one event exists with action "<action>", category "sites", target {"type": "site", "id": "<id>", "name": "<name>"}, outcome "success", actor the owner and source "web"
|
|
And the event was recorded after the change was applied
|
|
|
|
Examples:
|
|
| request | action | id | name |
|
|
| POST /v1/site/ for "Shop" | site.create | 2 | Shop |
|
|
| PATCH /v1/site/2 renaming it | site.edit | 2 | Shop |
|
|
| PATCH /v1/site/2/default | site.set_default | 2 | Shop |
|
|
| DELETE /v1/site/2 | site.delete | 2 | Shop |
|
|
|
|
Scenario Outline: A refused write is recorded as failed with a plain reason
|
|
When the owner 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/site/ with a taken key | site_key_taken | site.create | A site with that key already exists. |
|
|
| PATCH /v1/site/2 changing the key | key_immutable | site.edit | The key of a site cannot be changed. |
|
|
| DELETE /v1/site/1 on the default site | default_site | site.delete | The default site cannot be deleted. |
|
|
| DELETE /v1/site/2 with articles | site_has_articles | site.delete | The site still has articles. |
|
|
| POST /v1/site/ with an invalid base URL | invalid_site_url | site.create | The site address is not valid. |
|
|
| PATCH /v1/site/2 with an invalid locale | invalid_locale | site.edit | The locale is not valid. |
|
|
| PATCH /v1/site/2 with is_default | use_set_default | site.edit | Use the default site action to change the default. |
|
|
| DELETE /v1/site/2 reassigned to itself | invalid_reassign | site.delete | The destination site is not valid. |
|
|
| POST /v1/site/ when the repository fails with "db is locked" | 500 | site.create | Something went wrong on our side. Please try again. |
|
|
| PATCH /v1/site/2/default when the repository fails | 500 | site.set_default | 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/site/999 as the owner |
|
|
| DELETE /v1/site/999 as the owner |
|
|
| POST /v1/site/ with an unknown image (image_not_found) |
|
|
| POST /v1/site/ as an admin (403) |
|
|
| POST /v1/site/ with no credentials (401) |
|
|
| PATCH /v1/site/abc as the owner |
|
|
| POST /v1/site/ 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/site/ with valid values
|
|
Then the response equals that of a healthy repository
|
|
And the site was created
|
|
|
|
Scenario: Reads are never recorded
|
|
When GET /v1/site/ is sent
|
|
Then no activity event is recorded
|
|
|
|
Scenario: The key is never a change field
|
|
When the owner edits a site name, base URL, robots, locale and image
|
|
Then the changes use only the fields name, base_url, default_robots, locale and default_social_image
|
|
And no change has the field "key"
|
|
|
|
Scenario: The activity API lists site events
|
|
Given site events were recorded
|
|
When a verified account sends GET /v1/activity/?category=sites
|
|
Then the site events are returned with category "sites"
|
|
|
|
Rule: Documentation
|
|
|
|
Scenario: The docs describe the new surface
|
|
Then docs/api.md documents the /v1/site routes, their errors, article site_id, the ?site= filter and the media sites_updated field
|
|
And docs/configuration.md states that PUBLIC_URL seeds the first site once at migration
|
|
And docs/architecture.md mentions the site aggregate, its repository and its use cases
|