feat: integration page (PART 3/3)

This commit is contained in:
fsylvain committed 2026-10-05 02:03:24 +02:00
1 parent 514a082c96
commit acd392c29d
20 files changed
+3115 -409

No files matched your search

+182 -52
View File
@@ -1,63 +1,193 @@
Feature: Integration screen
Feature: Integration screen (API reference)
As a verified RenewCMS owner
I want /integration to show my site's base API URL and ready-to-use endpoint examples
So that I, or the developer who builds my public site, can wire it up without reading the
source
As a developer building a frontend or an API-key client for a RenewCMS site
I want /integration to be a complete, searchable API reference
So that I can call every route a key may call without reading the source
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the PageHeader shows title "Connect your website", description "A guide for the developer
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
actions
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
articles but doesn't show them to visitors. Your website fetches published articles from the
two addresses below and displays them in its own design."
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
(icon copy) beside it
And two EndpointCard entries follow, in this order:
| method | path | description |
| GET | /v1/article/ | Lists every online article. |
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
And each EndpointCard's fetch example uses the current origin as the base of its URL:
| path | fetch example |
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
And the curl tab's content is a plain curl command built from the current origin (e.g.
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
content is a JSON sample built from the real domain Article fields; neither tab is active by
default, and neither is visually checked, since the fetch tab is the one shown in the board
And three info notes follow the endpoint cards, in this order:
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
them."
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
base URL before rendering."
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
whoever runs the server to add your site's address."
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
"Everything a developer needs to build on RenewCMS."
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order:
| group | routes | endpoints |
| Articles | 7 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | 6 | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
| Settings | 1 | GET /v1/settings |
And the "Session only" group has 13 non-expandable rows in 3 sub-groups:
| sub-group | rows |
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
| Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset |
| API key management | GET /v1/api-key, POST /v1/api-key, DELETE /v1/api-key/{id} |
And a "Public auth flow" sub-list follows with 6 non-expandable rows and the chip "Public":
| method | path | description |
| GET | /v1/ | Hello world. |
| GET | /v1/auth/registration | Whether registration is open. |
| POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. |
| GET | /v1/auth/invite | Check an invite code. |
| POST | /v1/auth/invite/accept | Accept an invite. |
| GET | /healthz | Plain text "ok". Outside /v1. |
And every catalogue endpoint has a non-empty description, method, path, access chip, key chip,
query-parameter table (or "No query parameters."), request-body table (or "No request body."),
fetch, curl and Response examples, and error captions
Scenario: Default — the page loads with the app's own origin
Given the app is served from origin "{origin}"
Scenario: Default, every card collapsed
When the user navigates to /integration
Then the Base URL row shows exactly "{origin}"
And the Base URL "Copy" button reads "Copy", not "Copied"
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy)
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
Scenario: Copied — the Base URL copy button
Given the user is on /integration, served from origin "{origin}"
When the user activates the Base URL row's "Copy" button
Scenario Outline: Method badge and chips per endpoint
Then <method> <path> shows the <method> badge, access chip "<access>" and key chip "API key OK"
Examples:
| method | path | access |
| GET | /v1/article/ | Public |
| GET | /v1/article/{id} | Public |
| POST | /v1/article/ | Verified user |
| DELETE | /v1/article/{id} | Verified user |
| GET | /v1/media/ | Verified user |
| PATCH | /v1/user/me | Verified user |
| GET | /v1/auth/me | Verified user |
| POST | /v1/user/invite | Owner only |
| DELETE | /v1/user/invite/{id} | Owner only |
| POST | /v1/user/invite/{id}/renew | Owner only |
| GET | /v1/settings | Verified user |
Scenario: Owner-only invite cards, user-approved divergence from the board
Then the three invite cards show "Owner only", where the board shows "Verified user"
And each lists a 403 error "Only the owner can do this."
And each says it is rate limited and that too many requests answer 429, with no number
Scenario: Group scrolled, one card expanded
When the user activates the "POST /v1/article/" row
Then the row's button reports aria-expanded "true"
And a "Query parameters" table lists "format", string, optional, "markdown, html or json. Default markdown."
And a "Request body" table lists:
| Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
| body | string | Yes | Up to 10 000 characters. |
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
And an "Errors" list shows a status chip with a one-line caption for 400, 401
Scenario: Query parameters table, new UI beyond the board, user-approved
Then the "Query parameters" table sits above the "Request body" table in each card
And it lists:
| route | name | type | required | rule |
| every /v1/article route | format | string | no | markdown, html or json. Default markdown. |
| GET /v1/media/ | q | string | no | Search text. |
| GET /v1/media/ | hash | string | no | SHA-256 of the content, lowercased. |
| GET /v1/media/ | cursor | string | no | The next_cursor of the previous page. |
| GET /v1/media/ | filter | string | no | all, unused or duplicates. Default all. |
| GET /v1/media/ | sort | string | no | newest, oldest, name or largest. Default newest. |
| GET /v1/media/ | limit | number | no | 1 to 100. Default 24. |
And a card without query parameters shows "No query parameters."
Scenario Outline: Request body fields come from the handler validate tags
Then <method> <path> lists these fields: <fields>
Examples:
| method | path | fields |
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
| PATCH | /v1/article/{id} | body string required max 10000 |
| PATCH | /v1/media/{id} | filename string required |
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
| PATCH | /v1/user/me | display_name string required max 60 |
| POST | /v1/user/invite | email string required valid email |
| GET | /v1/article/ | "No request body." |
Scenario: Response samples use the real domain JSON field names
Then the GET /v1/article/{id} Response sample has exactly the keys id, title, body, body_format, is_online, created_at, updated_at
And the sample has no "images" key and no "status" key
And POST /v1/media/merge has the keys articles_updated and merged
And GET /v1/media/ has the keys items, next_cursor and counts
And GET /v1/media/{id}/usage items have the keys id, title, published
And PATCH /v1/article/{id}/publish and /unpublish samples are articles with is_online true and false
Scenario: Article body format
Then a note reads "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
And every article card's Errors list includes 400 "The format must be one of: markdown, html, json."
Scenario: Authentication section
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
And card "API key" reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator."
And its CodeBlock has tabs curl and fetch for GET /v1/auth/me, using the current origin
And it says "Keys start with `rcms_` and are shown in full once, when you create them."
And a "Manage API keys" link navigates to /integration/api-keys
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
Scenario: Session only group
Then the banner reads "These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session.""
And each row has a dashed border and muted fill, uses no method colour and cannot be expanded
And rows that answer 403 to a key show the lock icon and the chip "Session only"
And Log in, Log out, Register and Reset password show the caption "Made for the browser. A key is ignored here, with no 403." and no 403 claim
And no /v1/system route appears in any expandable group
And the "Public auth flow" rows carry the chip "Public" and no lock icon
Scenario: Errors section
Then the intro reads "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain."
And the table lists:
| status | message |
| 400 | The request cannot be processed due to a mismatch in the format of the body. / The expiry must be a date in the future. |
| 401 | You must be logged in to access this resource. |
| 403 | This action needs a real session. / Only the owner can do this. / You must verify your account to access this resource. |
| 404 | Article not found / Image not found / Invite not found / API key not found |
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
| 429 | Too many requests. Please try again later. |
| 500 | Internal server error |
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
And the table does not use the SPA copy of docs/design/states.md
Scenario Outline: Per-endpoint error captions use the real strings
Then <method> <path> lists <status> "<caption>"
Examples:
| method | path | status | caption |
| POST | /v1/media/ | 400 | the image could not be read |
| POST | /v1/media/ | 413 | the file exceeds the maximum upload size |
| POST | /v1/media/ | 415 | the file must be a PNG, JPEG, WEBP, or SVG image |
| GET | /v1/media/ | 400 | The media query is invalid. |
| POST | /v1/media/merge | 400 | Choose an image to keep and at least one other image to merge into it. |
| POST | /v1/media/merge | 409 | Some of the images to merge changed. Refresh and try again. |
| PATCH | /v1/media/{id} | 400 | Enter a filename. |
| POST | /v1/article/ | 400 | Article title must be between 3 and 200 characters. |
| GET | /v1/article/{id} | 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
| PATCH | /v1/user/me | 400 | Display name must be 60 characters or fewer. |
| POST | /v1/user/invite | 409 | This address already has an account. |
| POST | /v1/user/invite | 409 | There's already a pending invite for this address. |
| POST | /v1/user/invite/{id}/renew | 409 | This invite has already been accepted. |
| POST | /v1/user/invite | 403 | Only the owner can do this. |
Scenario: Notes
Then the image-prefix note reads "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering."
And the CORS note reads "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address."
Scenario: Copied, Base URL
Given the app is served from origin "{origin}"
When the user activates the Base URL "Copy" button
Then "{origin}" is written to the clipboard
And the button's icon changes to check and its text changes to "Copied"
And the change is announced to assistive technology
When 2 seconds elapse without a further click
Then the button reverts to icon copy and text "Copy"
And the button shows icon check and "Copied", announced to assistive technology
When 2 seconds elapse
Then the button reverts to icon copy and "Copy"
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
Given the user is on /integration
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
button
Then that card's own code is written to the clipboard, and only that button reads "Copied"
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
Scenario: Copied, CodeBlock is independent of Base URL
Given a card is expanded
When the user activates that CodeBlock's "Copy JavaScript example" button
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
And the Base URL button and other CodeBlocks are unaffected
Scenario: Examples use the current origin and the API key header
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
And examples for non-public routes carry "Authorization: Bearer rcms_…"
And examples for public routes carry no Authorization header
Scenario: Navigation
When the user activates a section in the index (desktop)
Then the page scrolls to that anchor and the item reports aria-current "true"
And on mobile the "Jump to" select opens and lists the same sections
Scenario: The page stays static
Then no /v1 request is made while the page renders or while any card is expanded
+86 -54
View File
@@ -1,66 +1,98 @@
# QA: Integration screen
# QA: Integration screen (API reference)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
`/v1` mocks are needed; the screen makes no calls.
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
is made.
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
"Connect your website" and description "A guide for the developer who builds your public
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
the three info notes render in order.
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
button reverts to icon copy and text "Copy".
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
Vitest-only; it has no visual-check state of its own (see Visual below).
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
content is a JSON sample built from the real domain Article fields. Neither tab's content is
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
1. The catalogue holds exactly the 20 expected key-callable routes from an explicit hand-maintained
list in the spec (Articles 7, Media 6, Users and invites 6, Settings 1), each once, with no other
expandable route. Session only has the 13 rows and Public auth flow the 6 rows listed in the
feature, from the same kind of explicit list.
2. Every catalogue route has a non-empty `description`. An empty or missing one fails the spec and
names the route.
3. Every route has method, path, access chip, key chip, a query-parameter list and a body-field list
(possibly empty), and fetch, curl and Response examples.
4. The Response sample of each route parses as JSON and uses only keys from a fixture of the real
fields:
- article: id, title, body, body_format, is_online, created_at, updated_at;
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate;
- media list: items, next_cursor, counts (all, unused, duplicates);
- merge: articles_updated, merged;
- usage: id, title, published;
- user list item: id, kind, username, display_name, email, role, status, created_at;
- invite: id, kind, email, status, created_at, expires_at;
- settings: version.
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
rate-limit sentence. No /v1/system route is in an expandable group. Login, Log out, Register and
Reset password rows carry no 403 claim. Verify, DELETE /auth/register, /api-key/* and
/system/* are the only rows that claim 403.
7. Error captions equal fixtures copied from api/pkg/apperr/messages.go and the image domain
errors (413, 415, 400 on upload).
Component specs, one per design state:
1. **Default**: breadcrumb, title "API reference", description, intro, Base URL equals the stubbed
origin, all cards collapsed (aria-expanded "false"), section index entries present, the three
invite rows show "Owner only".
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
announced; reverts after 2 s.
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
only that button reads "Copied", and it reverts after 2 s.
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
href /integration/api-keys, Note 1, Note 2.
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
the "Public" chip.
7. **Errors**: table rows 400, 401, 403, 404, 409, 429, 500 with the real strings and the 403
sample block.
8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile
"Jump to" lists the same sections.
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
Authorization header only on non-public routes. Not visually checked.
10. **Notes**: format note, image-prefix note, CORS note.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Reference: `docs/design/screens/integration.md` and
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
Reference: docs/design/screens/integration.md; board "Integration" drawn by
docs/design/source/IntegrationPage.dc.html. Serve the SPA under `https://cms.renew.example`. Mocks
reuse the board's script data: BASE, KEY, group and endpoint descriptions, the request bodies
(for example the "Choosing tiles for a small bathroom" article). Response samples are not compared;
the Response tab is never active in a board frame.
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
Playwright request routing, so the real `window.location.origin` the running app reads matches the
board's sample text exactly; no production test hook or origin stub is needed in app code.
Pre-approved exceptions: the user pre-approved visual exceptions for Session only, Group scrolled
one card expanded, Errors and Default. For each of these four states the coder measures the real
diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and records
`approvedBy` "Florian Sylvain". The other states must still pass 10 %.
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
`https://cms.renew.example`, served as above.
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
color, matching the board's `copied: true` frame. This state is timer-driven, not
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
elsewhere in the app (UserMenu, ArticleTable row menu).
- [ ] Default (exception: owner-only chips and rate-limit line)
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
- [ ] Copied: base URL (must pass 10 %)
- [ ] Copied: CodeBlock (must pass 10 %)
- [ ] Authentication (must pass 10 %; Note 1 text changed)
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
- [ ] Errors (exception: 409 row)
- [ ] Navigation (must pass 10 %)
## Manual verification
None of these states is clock-sensitive, so none is skipped.
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
## Manual
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
- Hover and motion on ghost Copy buttons and rows.
- Find-in-page and a deep link to an `#ep-...` anchor.
- Real origin in a production build and under `task dev`.
- Run each documented example against a running server with a real `rcms_` key, including the
403 on a session-only route and the owner-only 403 for an admin key on the invite routes. This
needs a real server and is not automated.
- docs/api.md matches the catalogue (hand check).