Feature: Hardening after the Sites, Authors and SEO slices As the maintainer of a RenewCMS installation I want the SEO panel, the SEO check cache, the chunked lookups and the slug backfill to hold on large data So that behaviour stays correct beyond the first page of media, on every database, and on every boot Background: Given this file covers four items; items 4, 8 and 9 are behaviour-preserving and item 3 adds one read endpoint, GET /v1/media/{id} And every existing scenario stays green without edits to its assertions And no comment is added to any file Rule: The SEO panel names its images by id (item 3) Background: Given GET /v1/media/{id} is mounted in NewMediaRouter behind the same middleware as GET /v1/media/: jwtauth.Verifier, the API key authentication, AuditContext, RequireSignedIn, jwtauth.Authenticator, RequireCurrentToken and RequireVerifiedUser And it reads one image and writes nothing, so it records no activity event And it answers 200 with the same JSON item shape as one element of items in GET /v1/media/ And a missing image answers 404 with apperr.ImageNotFoundErrorMessage ("Image not found") And a malformed id answers 400 with apperr.IdUint32ErrorMessage And the SPA useMediaNames resolves the wanted ids with GET /v1/media/{id} and no longer reads the first 100 items of the list Scenario Outline: Who can read one image Given the image 5 exists When GET /v1/media/5 is sent with Then the status is Examples: | credentials | status | | the owner's jwt cookie | 200 | | a verified admin's jwt cookie | 200 | | a verified account with another role | 200 | | a valid API key | 200 | | an unverified account's jwt cookie | 403 | | no credentials | 401 | Scenario: The item has the shape of the list Given the image 5 exists with a name, a hash, a size, dimensions, a used_in count and variants information When a verified account sends GET /v1/media/5 Then the body is the JSON object equal to the element of items for the image 5 in GET /v1/media/ And no other field is added or removed Scenario: A missing image is not found When a verified account sends GET /v1/media/999 Then the status is 404 and the body is "Image not found" Scenario Outline: A malformed id is refused When a verified account sends GET /v1/media/ Then the status is 400 and the body is apperr.IdUint32ErrorMessage Examples: | id | | abc | | -1 | | 4294967296 | | 1.5 | Scenario: The route does not shadow the existing media routes When GET /v1/media/5/usage and GET /v1/media/5/variants are sent as before Then they answer exactly as before this change Scenario: A non-GET method keeps its meaning When PATCH /v1/media/5 and DELETE /v1/media/5 are sent as before Then they behave exactly as before this change Scenario: Reading one image records nothing When a verified account sends GET /v1/media/5 Then no activity event is recorded Scenario: A selected image outside the first 100 media items is named Given the article's cover image has id 150 and is not among the first 100 media items When the SEO panel opens Then the panel requests GET /v1/media/150 and shows the name and thumbnail of the image 150 And the same holds for the social image Scenario: Images already known are not fetched again Given the panel has resolved the images 150 and 151 When the panel re-renders with the same selection Then no lookup request is sent Scenario: An image that no longer exists Given GET /v1/media/150 answers 404 Then the panel shows its existing "unknown image" fallback and no error notice Scenario: A failing lookup never blocks the panel Given GET /v1/media/150 fails with a 500 or a network error Then the panel stays usable, shows the existing fallback and raises no notice Scenario: Behaviour with fewer than 100 media items is unchanged Given the library holds 12 media items Then the names shown are the same as before Scenario: The list is no longer requested for names When the SEO panel resolves any selected image Then no GET /v1/media/?page=1&size=100&sort=newest request is sent for names Scenario: The docs describe the route Then docs/api.md documents GET /v1/media/{id} with its auth, the 200 shape, the 404 and the 400 And web/src/data/apiCatalogue.ts lists GET /v1/media/{id} in the Media group, with its spec and the Integration reference counts updated Rule: The SEO check answer cache (item 4) Scenario: The same effective input is checked once Given a check was answered for an article body and fields When the same fields are checked again with a different but irrelevant difference in the request (key order or whitespace of the JSON) Then no second POST /v1/article/{id}/seo/check is sent and the cached answer is shown Scenario Outline: A field that affects the result invalidates the cache Given a check was answered When changes and the check runs again Then POST /v1/article/{id}/seo/check is sent again Examples: | field | | the title | | the body | | the description | | the excerpt | | the slug | | the cover image | | the social image | | the noindex switch | | the site | | the canonical URL | Scenario: A field that does not affect the result does not invalidate it Given a check was answered When only a field the check ignores changes Then the cached answer is reused Scenario: A failed check is not cached Given a check failed with a 500 When the same input is checked again Then the request is sent again Scenario: The cache stays bounded Given 200 distinct inputs were checked Then the cache holds no more than its existing limit and a repeated recent input is still a hit Rule: Chunked IN lookups on a real database (item 8) Background: Given the article repository looks ids up in batches of 500 (articles by id, article_authors by article id, authors by id) Scenario: A page-sized lookup keeps every row once and in order Given 1,200 articles, so the lookup spans 3 batches When the repository loads all 1,200 ids Then 1,200 articles come back, each once, in the requested order Scenario Outline: Batch boundaries Given ids are requested and all exist Then exactly rows come back Examples: | n | | 0 | | 1 | | 499 | | 500 | | 501 | | 1000 | | 1001 | Scenario: Missing ids are absent Given 600 ids of which 100 do not exist Then 500 rows come back and none is invented Scenario: Credits keep their order across batches Given 700 articles each crediting 3 authors in a chosen order When the credits are loaded Then every article lists its authors in its stored order (position, then id) Scenario: Soft-deleted rows follow the existing rule Given 3 of the 1,200 articles are soft-deleted Then the lookup returns the same rows as the single-query path would Scenario: It works on every supported database When the tests run against SQLite, MySQL/MariaDB and PostgreSQL Then the results are identical Rule: The article slug backfill (item 9) Scenario: A large database is filled in batches Given a database from before slugs holding 5,000 articles with empty slugs When the backfill runs Then every article has a non-empty slug unique within its site And the writes were made in several transactions of bounded size and not in one And the batch size is a named constant chosen by the implementation Scenario: The second boot changes nothing Given the backfill already ran When it runs again Then no row is written, no slug changes and updated_at is untouched Scenario: A crashed backfill resumes Given the backfill stopped after its first batch When it runs again Then the remaining articles are filled and the already filled ones keep their slugs Scenario: Duplicates get the first free suffix Given 3 articles with the same title in one site and 1 in another site When the backfill runs Then the first keeps the plain slug, the others get -2 and -3, and the other site's article keeps the plain slug Scenario: Soft-deleted rows get their tombstone once Given 2 soft-deleted articles When the backfill runs twice Then each has the tombstone slug after the first run and is not rewritten by the second Scenario: Existing slugs are kept Given an article whose slug was set by the owner When the backfill runs Then its slug is unchanged Scenario: The unique index exists afterwards and a repeat does not fail When the backfill and index creation run on a second boot Then idx_articles_site_slug exists and no error is returned