feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -0,0 +1,639 @@
|
||||
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
|
||||
@@ -0,0 +1,97 @@
|
||||
# QA procedure: Sites backend
|
||||
|
||||
Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token.
|
||||
|
||||
## Setup
|
||||
|
||||
- New files `test/site_*_test.go`. Fakes have unique names: `mockSiteRepo` (sites, default flag, article counts, transactional reassign-and-delete as one operation, failure injection), `mockSiteImageRepo` (images and usage), and an extended `mockArticleRepo` (site_id, site filter, move). A fake audit repository with fail and panic switches is reused.
|
||||
- Routers mirror `api/router.go`: a new `NewSiteRouter`, plus the article and media routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Requests carry real signed `jwt` cookies carrying the `user_id` claim for an owner, an admin and an unverified account (roles are only owner and admin); an API key request uses the key authentication path.
|
||||
|
||||
## A. Reading (Rule: Reading sites)
|
||||
|
||||
1. GET /v1/site/ with three sites: 200, order default first then name, `article_count` on each.
|
||||
2. GET /v1/site/ credentials table: owner, admin, API key give 200; unverified gives 403; none gives 401.
|
||||
3. A site without image and locale returns `default_social_image_id` null and `locale` null.
|
||||
|
||||
## B. Creating
|
||||
|
||||
4. POST creates a site: 201, full body, stored, `is_default` false, `article_count` 0.
|
||||
5. Optional fields default to `default_robots` "index", locale null, image null.
|
||||
6. POST with `is_default` true: the new site is the only default.
|
||||
7. Base URL normalisation table (trailing slash, host case, loopback http).
|
||||
8. Base URL rule table (all rows, `invalid_site_url`).
|
||||
9. Key format table (valid and invalid rows) and `site_key_taken` 409 with nothing stored.
|
||||
10. Other fields table: name, robots, locale (`invalid_locale`), unknown image (`image_not_found`), malformed JSON gives 400 and nothing stored.
|
||||
11. Locale table: valid values stored, empty and null stored as null.
|
||||
|
||||
## C. Editing
|
||||
|
||||
12. PATCH edits name, base_url, robots, locale and returns the site with the unchanged key.
|
||||
13. Absent fields stay untouched.
|
||||
14. Image set to 5 then cleared with null.
|
||||
15. Key table: same key 200, different key 400 `key_immutable`, key unchanged.
|
||||
16. A rejected key change leaves the name unchanged.
|
||||
17. `is_default` in the body: 400 `use_set_default`, default unchanged.
|
||||
18. Validation table (base_url, locale, image, name, robots), site unchanged.
|
||||
19. Missing site 404 `site_not_found`; malformed id gives the uint32 format error.
|
||||
20. No-op edit: 200 and no audit event.
|
||||
|
||||
## D. Default site
|
||||
|
||||
21. PATCH /{id}/default swaps the default; exactly one default remains.
|
||||
22. Current default: 200, no event. Missing site: 404.
|
||||
23. Fault injected on the second step of the swap: the previous default is still the only default (the fake mimics the transaction rollback).
|
||||
24. A subsequently created article gets the new default's id.
|
||||
|
||||
## E. Deleting
|
||||
|
||||
25. Empty site: 204, gone.
|
||||
26. Site with articles and no `reassign_to`: 409 `site_has_articles`, unchanged.
|
||||
27. With a valid `reassign_to`: 204, articles moved, site gone, via a single repository operation (assert the fake records one call).
|
||||
28. Failure injected in that operation: articles and site unchanged.
|
||||
29. `reassign_to` table: 999 gives 404 `site_not_found`; the site's own id and a non-numeric value give 400 `invalid_reassign`.
|
||||
30. Default site: 403 `default_site`, also when it has articles and no `reassign_to`. Missing site: 404. Unused `reassign_to` on an empty site: 204, destination untouched.
|
||||
|
||||
## F. Owner-only writes
|
||||
|
||||
31. Role table for POST, PATCH, PATCH default, DELETE: admin gives 403 "Only the owner can do this."; API key gives 403 "This action needs a real session."; nothing changes.
|
||||
32. Admin with a malformed body still gets 403 "Only the owner can do this.".
|
||||
|
||||
## G. Articles
|
||||
|
||||
33. New article is offline and on the default site; reads (GET one, GET list, publish, PATCH) carry `site_id`.
|
||||
34. `GET /v1/article/?site=shop` filters; composes with status (online, draft), q, sort (for example `title:asc`), size and page; `?site=nope` gives 200 empty with global `counts`; `?site=` gives 400 `apperr.BodyErrorMessage`.
|
||||
35. Without a verified account, `GET /v1/article/?site=shop` returns online articles only (there is no separate public route); `GET /v1/article/{offline id}` is 404 "Article not found".
|
||||
36. PATCH `site_id` moves the article for owner, admin and an API key; title plus `site_id` change together; omission keeps the site; `is_online` unaffected; rename of the site leaves the article readable.
|
||||
37. Unusable `site_id` table, one case each: 999 gives 404 `site_not_found`; 0 gives 400 with the validator error text; -1 and "a" give 400 `apperr.BodyErrorMessage` (decode failure); null gives 400 `apperr.BodyErrorMessage`; `{}` and an unknown-only object give 400 `apperr.BodyErrorMessage`; in every case the article is unchanged and no event is recorded. A same-site move returns 200 and records nothing.
|
||||
|
||||
## H. Media
|
||||
|
||||
38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused".
|
||||
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
|
||||
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
|
||||
|
||||
## I. Activity log
|
||||
|
||||
41. Success table: one event per write with action, category "sites", target, actor, source, recorded after the change.
|
||||
42. Failed table with the plain reasons; unknown ids, 403, 401, malformed requests record nothing; reads record nothing.
|
||||
43. Failing and panicking audit repository leave the response and the change as with a healthy one.
|
||||
44. After recording site events, a verified session GET /v1/activity/?category=sites returns the create, edit, set default and delete events with category "sites" and no events of other categories (an API key gets the route's existing session refusal). Failure reasons use the style of `auditFailures.go`: the four site sentinels map to the plain sentences of the feature and any other error gives `GenericFailureReason`. The change fields are limited to name, base_url, default_robots, locale, default_social_image, site and never "key"; article.edit with a site change holds `{"field":"site","from":"Main","to":"Shop"}`.
|
||||
|
||||
## J. Persistence and migration
|
||||
|
||||
45. Repository-contract harness (temp-dir SQLite, MySQL and Postgres opt-in through the existing env switches): site repository contract, transactional default swap, reassign-and-delete, backfill. `database.Migrate` is called with PUBLIC_URL at every caller, including `test/sqlite_schema_unchanged_test.go`, which is updated for the new table and column. Seed table (path dropped, http rewritten to https, loopback kept), unparseable value returns an error naming PUBLIC_URL and creates no site, backfill of existing articles, second run changes nothing, owner-edited base URL survives a restart.
|
||||
|
||||
## K. Quality gates
|
||||
|
||||
46. Layering guard: the use cases import no persistence, mailer, models, gorm, net/http or chi; the domain site package imports no GORM or HTTP; handlers import no persistence; no comments added.
|
||||
47. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES=<site and article use case files> MUTATE_RUN='TestSite' MUTATE_WORKERS=2 task mutate -- <use case package path>`; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
||||
48. Docs (Rule: Documentation), checked by reading the files: docs/api.md documents the five /v1/site routes with auth, error tokens, article `site_id`, the `?site=` filter, `sites_updated` on media merge and purge; docs/configuration.md says PUBLIC_URL seeds the first site once at migration; docs/architecture.md names the site aggregate, repository and use cases. No comment is added to any file.
|
||||
|
||||
## Manual
|
||||
|
||||
- MySQL and Postgres: run the contract suite with the opt-in env vars against real servers.
|
||||
- Real boot with an old database copy and PUBLIC_URL set: one "Default" site appears, articles backfilled, second boot changes nothing.
|
||||
- Docker with the `/data` volume: boot an existing database and confirm the same.
|
||||
- Read the activity log screen and the CSV export: site events show names and failure reasons, and no key change.
|
||||
- Not covered here: the SPA (a later slice).
|
||||
Reference in new issue
Block a user