feat: updated integration page with missing API routes
This commit is contained in:
1 parent
25d9c30b8a
commit
7c0ee21906
25 files changed
+2402
-350
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user