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 Then the status is 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 "" Then the stored base_url is "" 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 "" Then the status is And the body is "" 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 "" Then the status is 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 Then the status is And the body is "" 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 Then the status is 201 And the stored locale is 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": ""} Then the status is And the body is "" 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 Then the status is 400 And the body is "" 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= Then the status is And the body is "" 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 is sent with Then the status is 403 And the body is "" 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 "" Then the seeded base_url is "" 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 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& is sent Then every returned article belongs to "shop" and satisfies 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= is sent Then the status is And 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 When PATCH /v1/article/8 is sent with {"site_id": 2} and 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": } Then the status is And the body is "" 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 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 stays an array and later gains additive keys When a verified account looks up the usage of an image Then the response is a JSON array whose article items keep id, title and published And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key Rule: Every site write is recorded in the activity log Scenario Outline: A successful write is recorded once after the change When the owner performs Then one event exists with action "", category "sites", target {"type": "site", "id": "", "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 which is refused with Then one event exists with action "", outcome "failed" and failure_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 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