feat: updated integration page with missing API routes

This commit is contained in:
Florian Sylvain committed 2026-10-26 02:18:35 +01:00
1 parent 25d9c30b8a
commit 7c0ee21906
25 files changed
+2402 -350

No files matched your search

+181 -47
View File
@@ -22,12 +22,15 @@ import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import {
API_GROUPS,
ERROR_ROWS,
FIELD_TYPES_ANCHOR,
ERROR_SAMPLE,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
authExamples,
type ApiGroup,
type ErrorRow,
} from '@/data/apiCatalogue'
import { SECTION_TITLE, endpointAnchor } from '@/lib/apiReference'
import { SECTION_TITLE, endpointAnchor, groupsAroundImages } from '@/lib/apiReference'
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
const SPY_OFFSET_PX = 120
@@ -45,13 +48,17 @@ const errorSampleTabs = computed(() => [
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
function groupSection(group: ApiGroup) {
return { id: group.key, label: translate(group.title) }
}
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({
id: group.key,
label: translate(group.title),
})),
...groupsBeforeImages.map(groupSection),
{ id: 'images', label: t('Images and variants') },
...groupsAfterImages.map(groupSection),
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
@@ -67,7 +74,7 @@ const authItems = computed(() => {
const authNotes = computed(() => [
t(
'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.',
'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. Owner and admin routes answer 403 to a key whose creator is neither.',
),
t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
@@ -110,8 +117,64 @@ const errorColumns = computed(() => [
},
])
function quoteMessages(messages: string[]) {
return messages.map((message) => `"${message}"`).join(' / ')
interface MessagePart {
text: string
code: boolean
}
function messageParts(row: ErrorRow) {
const parts: MessagePart[] = []
const push = (text: string, code: boolean) => {
if (parts.length > 0) {
parts.push({ text: ' / ', code: false })
}
parts.push({ text, code })
}
row.messages.forEach((message) => push(`"${message}"`, false))
row.codes?.forEach((code) => push(code, true))
return parts
}
const fieldTypeColumns = computed(() => [
{
accessorKey: 'type',
get header() {
return t('Type')
},
meta: { class: { th: 'w-22', td: 'w-22' } },
},
{
accessorKey: 'value',
get header() {
return t('Value in an entry')
},
meta: { class: { th: 'lg:w-1/2', td: 'lg:w-1/2' } },
},
{
accessorKey: 'rules',
get header() {
return t('Rules')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
const pageBlocks = [
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
{ kind: 'images' as const, key: 'images', group: null },
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
]
function snippetTabs(group: ApiGroup) {
return group.snippet
? [
{
k: group.snippet.key,
label: translate(group.snippet.label),
code: group.snippet.code,
},
]
: []
}
function routeCount(count: number) {
@@ -371,7 +434,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<p class="max-w-180 text-base text-pretty text-toned">
{{
t(
'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.',
'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, and manage content types and entries, without the back-office.',
)
}}
</p>
@@ -458,33 +521,88 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</div>
</section>
<section
v-for="group in API_GROUPS"
:key="group.key"
:data-anchor="group.key"
:aria-labelledby="`h-${group.key}`"
:class="SECTION_CLASS"
>
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 :id="`h-${group.key}`" :class="SECTION_TITLE">
{{ translate(group.title) }}
</h2>
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span>
</div>
<span class="text-pretty text-toned">{{ translate(group.description) }}</span>
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in group.endpoints"
:key="endpoint.id"
:ref="(card) => setCard(endpoint.id, card)"
:endpoint="endpoint"
:origin="origin"
:flash="flashedCard === endpoint.id ? flashMode : null"
/>
</div>
</section>
<IntegrationImages :origin="origin" />
<template v-for="block in pageBlocks" :key="block.key">
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
<section
v-else
:data-anchor="block.group.key"
:aria-labelledby="`h-${block.group.key}`"
:class="SECTION_CLASS"
>
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 :id="`h-${block.group.key}`" :class="SECTION_TITLE">
{{ translate(block.group.title) }}
</h2>
<span class="text-muted">{{
routeCount(block.group.endpoints.length)
}}</span>
</div>
<span class="text-pretty text-toned">{{
translate(block.group.description)
}}</span>
<div v-if="block.group.note" class="flex gap-2.5 text-base text-toned">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"
><CodeText :text="translate(block.group.note)"
/></span>
</div>
<div
v-if="block.group.fieldTypes"
:data-anchor="FIELD_TYPES_ANCHOR"
class="flex scroll-mt-16 flex-col gap-1.5 lg:scroll-mt-8"
>
<span class="font-semibold text-highlighted">{{
t('Field types')
}}</span>
<UTable
:data="block.group.fieldTypes"
:columns="fieldTypeColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #type-cell="{ row }">
<span class="font-mono text-xs font-medium text-highlighted">{{
row.original.type
}}</span>
</template>
<template #value-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty"
><CodeText :text="translate(row.original.value)"
/></span>
<span class="text-pretty lg:hidden"
><CodeText :text="translate(row.original.rules)"
/></span>
</div>
</template>
<template #rules-cell="{ row }">
<span class="text-pretty"
><CodeText :text="translate(row.original.rules)"
/></span>
</template>
</UTable>
</div>
<CodeSnippet v-if="block.group.snippet" :tabs="snippetTabs(block.group)" />
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in block.group.endpoints"
:key="endpoint.id"
:ref="(card) => setCard(endpoint.id, card)"
:endpoint="endpoint"
:origin="origin"
:flash="flashedCard === endpoint.id ? flashMode : null"
/>
</div>
</section>
</template>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
@@ -492,7 +610,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
{{ t('Session only') }}
</h2>
<span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount])
t('{0} routes, not available to API keys', [sessionRouteCount])
}}</span>
</div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
@@ -532,29 +650,45 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
translate(row.original.meaning)
}}</span>
<div class="flex flex-col gap-0.5 lg:hidden">
<span class="text-pretty text-toned">{{
quoteMessages(row.original.messages)
}}</span>
<span class="text-pretty text-toned"
><template
v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
>{{ translate(row.original.note) }}</span
>
><CodeText :text="translate(row.original.note)"
/></span>
</div>
</div>
</template>
<template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty">{{
quoteMessages(row.original.messages)
}}</span>
<span class="text-pretty"
><template
v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
>{{ translate(row.original.note) }}</span
>
><CodeText :text="translate(row.original.note)"
/></span>
</div>
</template>
</UTable>
@@ -19,7 +19,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
const ORIGIN = 'https://cms.renew.example'
const LIST_PATH = '/v1/image-variants'
const BASE = `${ORIGIN}/media/north-wall.jpg`
const SECTION_ORDER = ['settings', 'images', 'session', 'errors']
const SECTION_ORDER = ['images', 'settings', 'session', 'errors']
const BODY_UNKNOWN =
'{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'
const BODY_UNSUPPORTED =
@@ -269,7 +269,7 @@ describe('I1. Many variants', () => {
const labels = Array.from(
document.body.querySelectorAll('nav[aria-label="On this page"] button'),
).map((item) => item.textContent?.trim())
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Session only') - 1)
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Sites and authors') + 1)
const request = requestSnippet()
expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([
@@ -344,13 +344,20 @@ describe('8c Typing media', () => {
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
const rows = optionRoutes()
expect(rows.slice(0, 6).every((route) => route!.includes('/v1/media'))).toBe(true)
expect(rows.slice(0, 6)).toEqual([
'GET /v1/builtin-type/',
'GET /v1/media/',
'GET /v1/media/{id}',
'POST /v1/media/',
'PATCH /v1/media/{id}',
'DELETE /v1/media/{id}',
])
expect(rows.at(-1)).toBe('Section Media')
expect(options().at(-1)!.textContent).toContain('11 routes')
expect(document.body.textContent).toContain('12 results')
expect(liveRegion()).toBe('12 results, showing 7')
expect(document.body.textContent).toContain('17 results')
expect(liveRegion()).toBe('17 results, showing 7')
expect(document.body.textContent).toContain(
'5 more endpoints. Keep typing to narrow the list.',
'10 more endpoints. Keep typing to narrow the list.',
)
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
expect(marks.length).toBeGreaterThan(0)
@@ -374,12 +381,11 @@ describe('8d More endpoints than the cap', () => {
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages',
)
const total = Number(/(\d+) results/.exec(document.body.textContent!)![1])
expect(total).toBeGreaterThan(7)
expect(document.body.textContent).toContain('24 results')
expect(document.body.textContent).toContain(
`${total - 7} more endpoints. Keep typing to narrow the list.`,
'17 more endpoints. Keep typing to narrow the list.',
)
expect(liveRegion()).toBe(`${total} results, showing 7`)
expect(liveRegion()).toBe('24 results, showing 7')
})
})
@@ -434,7 +440,7 @@ describe('8f Session-only route', () => {
expect(path.classList.contains('font-normal')).toBe(true)
expect(route!.innerHTML).toContain('border-dashed')
expect(section!.getAttribute('aria-label')).toBe(
'Section Session only. 25 routes, not available to API keys',
'Section Session only. 37 routes, not available to API keys',
)
expect(section!.querySelector('svg')).not.toBeNull()
expect(document.body.textContent).toContain('2 results')
@@ -551,6 +557,91 @@ describe('Roles and live region', () => {
})
})
describe('9f to 9i Content types and entries', () => {
async function searchFor(query: string) {
const wrapper = await mountIntegration()
await openWithSlash()
await type(query)
return wrapper
}
it('9f lists the entry routes, matches "entries" and caps with the plural line', async () => {
await searchFor('entry')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
expect(optionRoutes()).toEqual([
'GET /v1/content-type/{key}/entry',
'POST /v1/content-type/{key}/entry',
'GET /v1/content-type/{key}/entry/{id}',
'PUT /v1/content-type/{key}/entry/{id}',
'DELETE /v1/content-type/{key}/entry/{id}',
'PUT /v1/content-type/{key}',
'Section Entries',
])
expect(document.body.textContent).toContain('8 results')
expect(liveRegion()).toBe('8 results, showing 7')
expect(document.body.textContent).toContain(
'1 more endpoint. Keep typing to narrow the list.',
)
expect(document.body.textContent).not.toContain('1 more endpoints')
})
it('9g labels the capacity key as a Body field on POST and PUT entry', async () => {
await searchFor('capacity')
expect(optionRoutes()).toEqual([
'POST /v1/content-type/{key}/entry',
'PUT /v1/content-type/{key}/entry/{id}',
])
for (const row of options()) {
expect(row.textContent).toContain('Body field')
expect(row.querySelector('mark')!.textContent).toBe('capacity')
}
expect(document.body.textContent).toContain('2 results')
expect(liveRegion()).toBe('2 results')
})
it('9h labels schema_incompatible as an Error on PUT /v1/content-type/{key}', async () => {
await searchFor('schema_incompatible')
expect(optionRoutes()).toEqual(['PUT /v1/content-type/{key}'])
expect(options()[0]!.textContent).toContain('Error')
expect(options()[0]!.textContent).toContain('409 schema_incompatible')
expect(liveRegion()).toBe('1 result')
})
it('9i lists Content types and Entries in the Sections group', async () => {
await searchFor('content type')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
expect(optionRoutes().slice(-2)).toEqual(['Section Content types', 'Section Entries'])
expect(options()).toHaveLength(8)
expect(document.body.textContent).toContain('12 results')
expect(liveRegion()).toBe('12 results, showing 8')
})
it('selects PUT /v1/content-type/{key}, expands it, flashes it and reads Content types', async () => {
const wrapper = await searchFor('schema_incompatible')
await pressInPalette('Enter')
await wrapper.vm.$nextTick()
await settle()
const button = cardFor(wrapper, 'ct-update').get('button[aria-expanded]')
expect(paletteOpen()).toBe(false)
expect(button.attributes('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(button.element)
expect(button.classes()).toContain('endpoint-flash')
expect(window.location.hash).toBe('#ep-ct-update')
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.get('button[aria-current="true"]').text()).toBe('Content types')
await vi.advanceTimersByTimeAsync(FLASH_MS)
await flushPromises()
expect(button.classes()).not.toContain('endpoint-flash')
})
})
describe('8h Selecting an endpoint', () => {
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
await openWithSlash()
+562 -26
View File
@@ -20,12 +20,14 @@ const SECTION_LABELS = [
'Authentication',
'Articles',
'Search and sharing',
'Content types',
'Entries',
'Built-in types',
'Media',
'Sites and authors',
'Images and variants',
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
'Notes',
@@ -47,7 +49,7 @@ function stubClipboard() {
return writeText
}
async function mountIntegration() {
async function mountIntegration(host?: HTMLElement) {
stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
@@ -57,7 +59,7 @@ async function mountIntegration() {
session.status = 'ready'
await router.push('/integration')
const wrapper = mount(IntegrationView, {
attachTo: document.body,
attachTo: host ?? document.body,
global: {
plugins: [router, uiPlugin],
},
@@ -130,7 +132,7 @@ describe('Default, every card collapsed', () => {
expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference')
expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.')
expect(wrapper.text()).toContain(
'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.',
'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, and manage content types and entries, without the back-office.',
)
expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
@@ -149,6 +151,7 @@ describe('Default, every card collapsed', () => {
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button')).toHaveLength(14)
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to')
@@ -171,18 +174,150 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
it.each([
['articles', 'Articles', '8 routes', 8],
['seo', 'Search and sharing', '2 routes', 2],
['ctypes', 'Content types', '5 routes', 5],
['entries', 'Entries', '5 routes', 5],
['builtin', 'Built-in types', '2 routes', 2],
['media', 'Media', '11 routes', 11],
['sites', 'Sites and authors', '2 routes', 2],
['users', 'Users and invites', '6 routes', 6],
['settings', 'Settings', '1 route', 1],
])('titles the %s group %s with %s', async (id, title, count, cards) => {
const wrapper = await mountIntegration()
const group = section(wrapper, id)
expect(group.get('h2').text()).toBe(title)
expect(group.text()).toContain(count)
expect(group.findAllComponents(EndpointCard)).toHaveLength(cards)
group.findAllComponents(EndpointCard).forEach((item) => {
expect(item.text()).toContain('API key OK')
})
})
it('lists the groups on the page in the order of the index', async () => {
const wrapper = await mountIntegration()
const ids = wrapper
.findAll('section[data-anchor]')
.map((item) => item.attributes('data-anchor'))
expect(ids).toEqual([
'auth',
'articles',
'seo',
'ctypes',
'entries',
'builtin',
'media',
'sites',
'images',
'users',
'settings',
'session',
'errors',
'notes',
])
})
it.each([
['GET /v1/content-type/', 'Verified user'],
['GET /v1/content-type/{key}', 'Verified user'],
['POST /v1/content-type/', 'Owner or admin'],
['PUT /v1/content-type/{key}', 'Owner or admin'],
['DELETE /v1/content-type/{key}', 'Owner or admin'],
['GET /v1/content-type/{key}/entry', 'Verified user'],
['POST /v1/content-type/{key}/entry', 'Verified user'],
['GET /v1/content-type/{key}/entry/{id}', 'Verified user'],
['PUT /v1/content-type/{key}/entry/{id}', 'Verified user'],
['DELETE /v1/content-type/{key}/entry/{id}', 'Verified user'],
['GET /v1/builtin-type/', 'Owner or admin'],
['GET /v1/builtin-type/{key}', 'Owner or admin'],
['GET /v1/media/purge-settings', 'Owner or admin'],
['PATCH /v1/media/purge-settings', 'Owner or admin'],
['GET /v1/media/purge-preview', 'Owner or admin'],
['POST /v1/media/purge', 'Owner or admin'],
['GET /v1/site/', 'Verified user'],
['GET /v1/author/', 'Verified user'],
])('shows %s with the chip %s and API key OK', async (route, access) => {
const wrapper = await mountIntegration()
const text = row(wrapper, route).text()
expect(text).toContain(route.split(' ')[0]!)
expect(text).toContain(access)
expect(text).toContain('API key OK')
})
it('reads the descriptions of the Content types, Entries and Built-in types groups', async () => {
const wrapper = await mountIntegration()
expect(section(wrapper, 'ctypes').text()).toContain(
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
)
expect(section(wrapper, 'entries').text()).toContain(
'Read and write the content of a custom type. Any verified user can write entries.',
)
const builtin = section(wrapper, 'builtin').text()
expect(builtin).toContain('Built-in types are read-only')
expect(builtin).toContain('Keys are article, author, site, media and user.')
})
it('puts the ct-fields anchor above the first Content types route, outside the index', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'ctypes')
const anchor = group.get('[data-anchor="ct-fields"]')
const firstRoute = group.get('[data-anchor="ep-ct-list"]')
expect(
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([8, 2, 11, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('8 routes')
expect(section(wrapper, 'seo').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('1 route')
expect(section(wrapper, 'settings').text()).toContain('1 route')
anchor.element.compareDocumentPosition(firstRoute.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.text()).not.toContain('Field types')
expect(anchor.text()).toContain('Field types')
const headers = anchor.findAll('th').map((item) => item.text())
expect(headers).toEqual(['Type', 'Value in an entry', 'Rules'])
expect(anchor.findAll('tbody tr').map((item) => item.find('td').text())).toEqual([
'text',
'richtext',
'number',
'boolean',
'date',
'select',
'media',
'relation',
])
})
it('shows the sample field object above the routes', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'ctypes')
expect(group.get('[role="tablist"]').text()).toBe('Field object')
const sample = JSON.parse(group.get('pre').text())
expect(sample).toEqual({
key: 'capacity',
label: 'Capacity',
type: 'number',
required: true,
unique: false,
default: null,
min: 1,
max: 5000,
options: null,
relation: null,
})
})
it('draws the PUT badge with the PATCH colours', async () => {
const wrapper = await mountIntegration()
const classesOf = (route: string) =>
row(wrapper, route)
.get('span.font-mono')
.element.parentElement!.querySelector('[class*="h-5.5"]')!.className
expect(classesOf('PUT /v1/content-type/{key}')).toBe(classesOf('PATCH /v1/media/{id}'))
expect(classesOf('PUT /v1/content-type/{key}')).toContain('text-warning')
})
it('makes no /v1 request for a session without the owner or admin role', async () => {
@@ -370,16 +505,17 @@ describe('Authentication', () => {
})
describe('Session only', () => {
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => {
it('renders the banner, 37 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('25 routes')
expect(session.get('h2').text()).toBe('Session only')
expect(session.text()).toContain('37 routes, not available to API keys')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(25)
expect(rows).toHaveLength(37)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -389,8 +525,12 @@ describe('Session only', () => {
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'PATCH /v1/settings',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
'POST /v1/site/',
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
@@ -399,13 +539,21 @@ describe('Session only', () => {
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'PATCH /v1/settings',
'GET /v1/image-variants/',
'POST /v1/image-variants/',
'PUT /v1/image-variants/{name}',
'PATCH /v1/image-variants/{name}',
'DELETE /v1/image-variants/{name}',
'POST /v1/image-variants/{name}/purge',
'GET /v1/image-variants/cache',
'POST /v1/image-variants/cache/purge',
'GET /v1/media/{id}/variants',
'POST /v1/media/{id}/variants',
'POST /v1/media/{id}/variants/{name}',
'DELETE /v1/media/{id}/variants/{name}',
'GET /v1/activity',
'GET /v1/activity/summary',
'GET /v1/activity/export',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
chipRows.forEach((item) =>
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
@@ -432,13 +580,24 @@ describe('Session only', () => {
}
})
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => {
it('lists the six sub-groups in order and every /v1/system route in System', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('System · owner only')
expect(session.text()).toContain('Sign in and account')
expect(session.text()).toContain('API key management')
const titles = [
'System · owner only',
'Sign in and account',
'API key management',
'Sites and authors writes',
'Image variants',
'Activity',
]
const text = session.text()
const positions = titles.map((title) => text.indexOf(title))
expect(positions.every((position) => position > -1)).toBe(true)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
const systemGroup = session.findAll('[data-session-row]').slice(0, 5).map(rowRoute)
expect(systemGroup.filter((route) => route.includes('/v1/system'))).toHaveLength(4)
const rowLabels = wrapper
.findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label'))
@@ -483,6 +642,7 @@ describe('Errors', () => {
'403',
'404',
'409',
'422',
'429',
'500',
])
@@ -492,6 +652,7 @@ describe('Errors', () => {
'The expiry must be a date in the future.',
'You must be logged in to access this resource.',
'This action needs a real session.',
'Only owners and admins can do this.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
'Article not found',
@@ -500,6 +661,8 @@ describe('Errors', () => {
'API key not found',
"There's already a pending invite for this address.",
'This invite has already been accepted.',
'Some of the images to merge changed. Refresh and try again.',
'This address already has an account.',
'Too many requests. Please try again later.',
'Internal server error',
]) {
@@ -516,6 +679,69 @@ describe('Errors', () => {
})
})
describe('Errors table rows of the custom content types round', () => {
function rowOf(wrapper: VueWrapper, status: string) {
return section(wrapper, 'errors')
.findAll('tbody tr')
.find((item) => item.find('td').text() === status)!
}
function codes(item: ReturnType<VueWrapper['get']>) {
return item.findAll('td:nth-child(3) code').map((code) => code.text())
}
it('lists the four 403 texts in order and keeps the SEO defaults note', async () => {
const wrapper = await mountIntegration()
const forbidden = rowOf(wrapper, '403')
const text = forbidden.find('td:nth-child(3)').text()
const order = [
'This action needs a real session.',
'Only owners and admins can do this.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
].map((message) => text.indexOf(message))
expect(order.every((position) => position > -1)).toBe(true)
expect(order).toEqual([...order].sort((a, b) => a - b))
expect(forbidden.text()).toContain(
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
)
})
it('adds the new codes to the 404, 409 and 422 rows and explains code style', async () => {
const wrapper = await mountIntegration()
expect(codes(rowOf(wrapper, '404'))).toEqual([
'content_type_not_found',
'entry_not_found',
'builtin_type_not_found',
])
expect(rowOf(wrapper, '404').text()).toContain(
'Text in code style is the literal body of the response, not a sentence.',
)
expect(codes(rowOf(wrapper, '409'))).toEqual([
'content_type_key_taken',
'content_type_has_entries',
'content_type_referenced',
'schema_incompatible',
'invalid_entry: <reason>',
'slug_taken',
'invalid_entry: <reason>',
])
expect(rowOf(wrapper, '409').text()).toContain('only when a unique field clashes')
expect(codes(rowOf(wrapper, '422'))).toContain('invalid_entry: <reason>')
expect(rowOf(wrapper, '422').text()).toContain(
'invalid_entry: capacity must be at most 5000',
)
})
it('no longer says there is no 409 or 422 row', async () => {
const wrapper = await mountIntegration()
expect(section(wrapper, 'errors').text()).not.toMatch(/no 409|409 or 422 row|no .*422/i)
})
})
describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn()
@@ -673,3 +899,313 @@ describe('CodeSnippet migration', () => {
expect(importers).toEqual([])
})
})
describe('Expanded cards of the custom content types round', () => {
const MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
it.each([
[
'POST /v1/content-type/',
['key', 'name', 'icon', 'fields'],
['400', '401', 'Only owners and admins can do this.', 'content_type_key_taken'],
],
[
'PUT /v1/content-type/{key}',
['name', 'icon', 'fields'],
['400', '401', '403', 'content_type_not_found', 'schema_incompatible'],
],
[
'DELETE /v1/content-type/{key}',
[],
[
'401',
'403',
'content_type_not_found',
'content_type_has_entries',
'content_type_referenced',
],
],
[
'GET /v1/content-type/{key}/entry',
['page', 'size', 'sort'],
['Invalid page, size or sort.', '401', 'content_type_not_found'],
],
[
'POST /v1/content-type/{key}/entry',
['data'],
[
'400',
'401',
'content_type_not_found',
'invalid_entry: title must be unique',
'invalid_entry: capacity must be at most 5000',
],
],
[
'GET /v1/content-type/{key}/entry/{id}',
[],
[ID_FORMAT, '401', 'entry_not_found', 'content_type_not_found'],
],
[
'PUT /v1/content-type/{key}/entry/{id}',
['data'],
[
'400',
'401',
'entry_not_found',
'content_type_not_found',
'invalid_entry: title must be unique',
'invalid_entry: capacity must be at most 5000',
],
],
['GET /v1/builtin-type/{key}', [], ['401', '403', 'builtin_type_not_found']],
])('%s shows its fields and its errors', async (route, fields, errors) => {
const wrapper = await mountIntegration()
const opened = await expand(wrapper, route)
const text = opened.text()
if (fields.length === 0) {
expect(text).toContain('No request body.')
}
for (const name of fields) {
expect(opened.findAll('td').map((cell) => cell.text())).toContain(name)
}
for (const error of errors) {
expect(text).toContain(error)
}
const literalCodes = opened.findAll('[title] code').map((code) => code.text())
for (const error of errors.filter((item) => /^[a-z_]+(: .*)?$/.test(item))) {
expect(literalCodes).toContain(error)
}
})
it('shows the 400 sentence and the schema_incompatible tail as captions', async () => {
const wrapper = await mountIntegration()
const update = await expand(wrapper, 'PUT /v1/content-type/{key}')
const create = await expand(wrapper, 'POST /v1/content-type/')
expect(update.text()).toContain(
"Some existing entries don't fit the new fields. Nothing was changed.",
)
expect(create.text()).toContain(MISMATCH)
})
it('explains the request table of the entry data', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/content-type/{key}/entry')
expect(created.text()).toContain(
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover.",
)
})
})
describe('Response samples follow the real API', () => {
async function response(route: string) {
const wrapper = await mountIntegration()
const opened = await expand(wrapper, route)
await activateTab(opened.element as HTMLElement, 'Response')
return { opened, body: opened.get('pre').text() }
}
it('answers the entries list with items, total, page and size', async () => {
const { body } = await response('GET /v1/content-type/{key}/entry')
expect(Object.keys(JSON.parse(body))).toEqual(['items', 'total', 'page', 'size'])
})
it('gives an entry its keys and a media object keyed by field key', async () => {
const { body } = await response('GET /v1/content-type/{key}/entry/{id}')
const entry = JSON.parse(body)
expect(Object.keys(entry)).toEqual([
'id',
'content_type',
'data',
'created_by',
'updated_by',
'created_at',
'updated_at',
'media',
'relations',
])
expect(Object.keys(entry.media)).toEqual(['cover'])
})
it('gives a built-in type only key, name and fields', async () => {
const { body } = await response('GET /v1/builtin-type/{key}')
expect(Object.keys(JSON.parse(body))).toEqual(['key', 'name', 'fields'])
expect(body).not.toContain('"icon"')
})
it('gives the purge exactly the seven real keys and says what failed_groups counts', async () => {
const { opened, body } = await response('POST /v1/media/purge')
expect(Object.keys(JSON.parse(body))).toEqual([
'groups_total',
'groups_purged',
'merged',
'articles_updated',
'sites_updated',
'authors_updated',
'failed_groups',
])
expect(body).not.toContain('articlesUpdated')
expect(body).not.toContain('"count"')
expect(opened.text()).toContain('fingerprint')
expect(opened.text()).toContain(
'failed_groups counts groups that could not be purged; the others were still purged.',
)
})
it('names the purge settings keys', async () => {
const { body } = await response('GET /v1/media/purge-settings')
for (const key of [
'auto_purge_enabled',
'auto_purge_interval_days',
'last_purge_at',
'last_purge_merged',
]) {
expect(body).toContain(`"${key}"`)
}
const patch = await expand(await mountIntegration(), 'PATCH /v1/media/purge-settings')
expect(patch.text()).toContain('1 to 365')
})
it.each(['POST /v1/content-type/', 'POST /v1/content-type/{key}/entry'])(
'shows %s as the JSON body with no status line',
async (route) => {
const { body } = await response(route)
expect(body.startsWith('{')).toBe(true)
expect(body).not.toMatch(/^HTTP\/|^201/)
},
)
})
describe('Paged lists', () => {
it('lists page and size on GET /v1/media/ with the 400 on a bad query', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
expect(list.text()).toContain('Query parameters')
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
expect(list.text()).toContain('The media query is invalid.')
})
it('lists page and size on GET /v1/author/ and answers an object', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/author/')
await activateTab(list.element as HTMLElement, 'Response')
const cells = list.findAll('td').map((cell) => cell.text())
expect(cells).toContain('page')
expect(cells).toContain('size')
expect(Object.keys(JSON.parse(list.get('pre').text()))).toEqual([
'items',
'total',
'page',
'size',
])
})
it('opens GET /v1/site/ with its sites', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/site/')
expect(list.text()).toContain('Verified user')
expect(list.text()).toContain('Query parameters')
})
})
describe('Authentication copy of the custom content types round', () => {
it('says a key manages content types and entries and what happens to a lesser creator', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain(
'API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
)
const auth = section(wrapper, 'auth').text()
expect(auth).toContain(
"Requests act as the user who created the key, with that user's role (owner or admin).",
)
expect(auth).toContain(
'Owner and admin routes answer 403 to a key whose creator is neither.',
)
})
})
describe('9j Navigation, scroll-spy on Content types', () => {
function scrollerHost() {
const host = document.createElement('div')
host.style.overflowY = 'auto'
document.body.appendChild(host)
return host
}
function stubOffsets(host: HTMLElement) {
const order = ['auth', 'articles', 'seo', 'ctypes']
return vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
const anchor = this.getAttribute('data-anchor') ?? ''
const top =
this === host
? 0
: order.includes(anchor)
? order.indexOf(anchor) * 40 - 80
: 900
return {
top,
bottom: top,
left: 0,
right: 0,
width: 0,
height: 0,
x: 0,
y: top,
toJSON: () => ({}),
}
})
}
it('marks Content types in the index and on the Jump to select', async () => {
const host = scrollerHost()
const wrapper = await mountIntegration(host)
const rect = stubOffsets(host)
host.dispatchEvent(new Event('scroll'))
await flushPromises()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button[aria-current="true"]').map((item) => item.text())).toEqual([
'Content types',
])
expect(wrapper.get('button[aria-label="Jump to section"]').text()).toContain(
'Content types',
)
rect.mockRestore()
host.remove()
})
it('marks Content types in the open Jump to list', async () => {
const host = scrollerHost()
const wrapper = await mountIntegration(host)
const rect = stubOffsets(host)
host.dispatchEvent(new Event('scroll'))
await flushPromises()
await wrapper.get('button[aria-label="Jump to section"]').trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]'))
const marked = options.filter((option) => option.getAttribute('aria-selected') === 'true')
expect(marked.map((option) => option.textContent?.trim())).toEqual(['Content types'])
rect.mockRestore()
host.remove()
})
})
+11 -7
View File
@@ -104,7 +104,7 @@ describe('Search and sharing group', () => {
.map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('1 route')
expect(section(wrapper, 'sites').text()).toContain('2 routes')
})
it('describes the resolved SEO object and shows the real error rows', async () => {
@@ -163,12 +163,14 @@ describe('Search and sharing group', () => {
'Authentication',
'Articles',
'Search and sharing',
'Content types',
'Entries',
'Built-in types',
'Media',
'Sites and authors',
'Images and variants',
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
'Notes',
@@ -185,11 +187,13 @@ describe('Search and sharing group', () => {
const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(3)
expect(forbidden.messages).toHaveLength(4)
expect(forbidden.note).toBe(NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes).toEqual([NOTE, NOTE])
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
expect(notes.filter((item) => item === NOTE)).toHaveLength(2)
expect(ERROR_ROWS.filter((row) => row.note).map((row) => row.status)).toEqual([
403, 404, 409, 422,
])
})
it('makes no /v1 request when opening and expanding the new cards', async () => {