feat: integration page (PART 3/3)
This commit is contained in:
1 parent
514a082c96
commit
acd392c29d
20 files changed
+3115
-409
No files matched your search
+383
-117
@@ -1,164 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import CodeBlock from '@/components/CodeBlock.vue'
|
||||
import CodeText from '@/components/CodeText.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import SectionIndex from '@/components/SectionIndex.vue'
|
||||
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import {
|
||||
API_GROUPS,
|
||||
ERROR_ROWS,
|
||||
ERROR_SAMPLE,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
SESSION_GROUPS,
|
||||
authExamples,
|
||||
} from '@/data/apiCatalogue'
|
||||
|
||||
const SPY_OFFSET_PX = 120
|
||||
|
||||
const origin = computed(() => window.location.origin)
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
|
||||
const copyButtonIcon = computed(() =>
|
||||
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
|
||||
)
|
||||
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
|
||||
function copyBaseUrl() {
|
||||
copy(origin.value)
|
||||
}
|
||||
|
||||
const sampleArticle = {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>Every bathroom is different, but the way we price them isn’t.</p>',
|
||||
images: [
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const activeSection = ref('auth')
|
||||
const authTab = ref('curl')
|
||||
|
||||
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
|
||||
|
||||
const sections = computed(() => [
|
||||
{ id: 'auth', label: t('Authentication') },
|
||||
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
|
||||
{ id: 'session', label: t('Session only') },
|
||||
{ id: 'errors', label: t('Errors') },
|
||||
{ id: 'notes', label: t('Notes') },
|
||||
])
|
||||
|
||||
const authItems = computed(() => {
|
||||
const examples = authExamples(origin.value)
|
||||
return [
|
||||
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
|
||||
{
|
||||
id: 1,
|
||||
path: '/static/uploadedImages/2f6a1c0e-2b8b-4c3d-9c1e-1a2b3c4d5e6f.png',
|
||||
article_id: 12,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-03-12T09:14:00Z',
|
||||
label: 'fetch',
|
||||
value: 'fetch',
|
||||
code: examples.fetch,
|
||||
lang: 'javascript',
|
||||
title: 'JavaScript',
|
||||
},
|
||||
],
|
||||
body_format: 'html',
|
||||
is_online: true,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-10T07:00:00Z',
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const sampleArticleList = [sampleArticle]
|
||||
|
||||
const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/',
|
||||
get description() {
|
||||
return t('Lists every online article.')
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/?format=html`,
|
||||
response: JSON.stringify(sampleArticleList, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/{id}',
|
||||
get description() {
|
||||
return t(
|
||||
'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
)
|
||||
},
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/12?format=html`,
|
||||
response: JSON.stringify(sampleArticle, null, 2),
|
||||
},
|
||||
},
|
||||
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.',
|
||||
),
|
||||
t(
|
||||
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
|
||||
),
|
||||
])
|
||||
|
||||
const notes = computed(() => [
|
||||
t(
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
),
|
||||
t(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
|
||||
),
|
||||
t(
|
||||
"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.",
|
||||
),
|
||||
t(
|
||||
'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.',
|
||||
),
|
||||
])
|
||||
|
||||
const errorColumns = computed(() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
get header() {
|
||||
return t('Status')
|
||||
},
|
||||
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
|
||||
},
|
||||
{
|
||||
accessorKey: 'meaning',
|
||||
get header() {
|
||||
return t('Meaning')
|
||||
},
|
||||
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
|
||||
},
|
||||
{
|
||||
accessorKey: 'messages',
|
||||
get header() {
|
||||
return t('Message')
|
||||
},
|
||||
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
|
||||
},
|
||||
])
|
||||
|
||||
function quoteMessages(messages: string[]) {
|
||||
return messages.map((message) => `"${message}"`).join(' / ')
|
||||
}
|
||||
|
||||
function routeCount(count: number) {
|
||||
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
|
||||
}
|
||||
|
||||
function scrollParent(element: HTMLElement) {
|
||||
let current = element.parentElement
|
||||
while (current) {
|
||||
const overflow = getComputedStyle(current).overflowY
|
||||
if (overflow === 'auto' || overflow === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sectionElement(id: string) {
|
||||
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
|
||||
}
|
||||
|
||||
function goTo(id: string) {
|
||||
activeSection.value = id
|
||||
sectionElement(id)?.scrollIntoView?.({ block: 'start' })
|
||||
}
|
||||
|
||||
function updateActive() {
|
||||
const element = root.value
|
||||
const parent = element ? scrollParent(element) : null
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
|
||||
let current = sections.value[0]!.id
|
||||
for (const section of sections.value) {
|
||||
const target = sectionElement(section.id)
|
||||
if (target && target.getBoundingClientRect().top <= limit) {
|
||||
current = section.id
|
||||
}
|
||||
}
|
||||
activeSection.value = current
|
||||
}
|
||||
|
||||
let scroller: HTMLElement | null = null
|
||||
|
||||
onMounted(() => {
|
||||
scroller = root.value ? scrollParent(root.value) : null
|
||||
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
|
||||
|
||||
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
|
||||
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
|
||||
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
|
||||
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
|
||||
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell :mobile-title="t('Integration')">
|
||||
<div class="flex max-w-199 flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Integration')
|
||||
},
|
||||
},
|
||||
]"
|
||||
color="neutral"
|
||||
:ui="{
|
||||
link: 'text-sm font-normal text-muted',
|
||||
linkLabel: 'text-muted',
|
||||
list: 'gap-1',
|
||||
}"
|
||||
/>
|
||||
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
|
||||
{{ t('API reference') }}
|
||||
</h1>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('Everything a developer needs to build on RenewCMS.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="max-w-180 text-base text-pretty text-toned">
|
||||
{{
|
||||
t(
|
||||
'A guide for the developer who builds your public site.',
|
||||
'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.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-pretty text-toned">
|
||||
{{
|
||||
t(
|
||||
"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.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">{{
|
||||
t('Base URL')
|
||||
}}</span>
|
||||
<div class="flex border border-accented bg-default">
|
||||
<span
|
||||
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
|
||||
>{{ origin }}</span
|
||||
>
|
||||
<UButton
|
||||
:icon="copyButtonIcon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
aria-live="polite"
|
||||
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
|
||||
:class="copied ? 'text-success' : 'text-highlighted'"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="copyBaseUrl"
|
||||
>
|
||||
{{ copyButtonLabel }}
|
||||
</UButton>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
|
||||
<div class="flex border border-accented bg-default">
|
||||
<span
|
||||
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
|
||||
>{{ origin }}</span
|
||||
>
|
||||
<UButton
|
||||
:icon="copyButtonIcon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
aria-live="polite"
|
||||
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
|
||||
:class="copied ? 'text-success' : 'text-highlighted'"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="copyBaseUrl"
|
||||
>
|
||||
{{ copyButtonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndpointCard
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.path"
|
||||
:method="endpoint.method"
|
||||
:path="endpoint.path"
|
||||
:description="endpoint.description"
|
||||
:examples="endpoint.examples"
|
||||
/>
|
||||
<section
|
||||
data-anchor="auth"
|
||||
aria-labelledby="h-auth"
|
||||
:class="[SECTION_CLASS, 'gap-3.5']"
|
||||
>
|
||||
<h2 id="h-auth" :class="SECTION_TITLE">{{ t('Authentication') }}</h2>
|
||||
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
|
||||
<div :class="AUTH_CARD">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="CARD_TITLE">{{ t('Public') }}</span>
|
||||
<UBadge
|
||||
:label="t('No key')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-pretty text-toned">{{
|
||||
t(
|
||||
'No key needed. Two read-only routes, and only online articles are returned.',
|
||||
)
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
|
||||
>
|
||||
<span>GET /v1/article/</span>
|
||||
<span>GET /v1/article/{id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-info"
|
||||
class="mt-0.75 size-4 shrink-0 text-info"
|
||||
/>
|
||||
<span class="text-base text-pretty text-toned">{{
|
||||
note
|
||||
<div :class="[AUTH_CARD, 'min-w-0']">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="CARD_TITLE">{{ t('API key') }}</span>
|
||||
<UBadge
|
||||
:label="t('Full access')"
|
||||
color="success"
|
||||
variant="soft"
|
||||
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-pretty text-toned"
|
||||
><CodeText
|
||||
:text="
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
"
|
||||
/></span>
|
||||
<UTabs
|
||||
v-model="authTab"
|
||||
:items="authItems"
|
||||
variant="link"
|
||||
color="primary"
|
||||
:ui="{
|
||||
root: 'gap-0 border border-default',
|
||||
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
|
||||
trigger:
|
||||
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
|
||||
content: 'mt-0',
|
||||
}"
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<CodeBlock
|
||||
:label="(item as (typeof authItems)[number]).title"
|
||||
:lang="(item as (typeof authItems)[number]).lang"
|
||||
:code="(item as (typeof authItems)[number]).code"
|
||||
/>
|
||||
</template>
|
||||
</UTabs>
|
||||
<span class="text-pretty text-muted"
|
||||
><CodeText
|
||||
:text="
|
||||
t(
|
||||
'Keys start with `rcms_` and are shown in full once, when you create them.',
|
||||
)
|
||||
"
|
||||
/></span>
|
||||
<UButton
|
||||
to="/integration/api-keys"
|
||||
variant="link"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
class="min-h-11 self-start px-0 lg:min-h-7"
|
||||
>
|
||||
{{ t('Manage API keys') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 text-base text-toned">
|
||||
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
|
||||
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
||||
<span class="text-pretty"><CodeText :text="note" /></span>
|
||||
</div>
|
||||
</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">{{ group.description }}</span>
|
||||
<div class="border border-t-0 border-default bg-default">
|
||||
<EndpointCard
|
||||
v-for="endpoint in group.endpoints"
|
||||
:key="endpoint.id"
|
||||
:endpoint="endpoint"
|
||||
:origin="origin"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
|
||||
<span class="text-muted">{{
|
||||
t('{0} routes, made for a browser session', [sessionRouteCount])
|
||||
}}</span>
|
||||
</div>
|
||||
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
|
||||
</section>
|
||||
|
||||
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
|
||||
<h2 id="h-errors" :class="SECTION_TITLE">{{ t('Errors') }}</h2>
|
||||
<span class="text-pretty text-toned">{{
|
||||
t(
|
||||
'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.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<UTable
|
||||
:data="ERROR_ROWS"
|
||||
:columns="errorColumns"
|
||||
:ui="{
|
||||
root: 'border border-default bg-default',
|
||||
base: 'table-fixed',
|
||||
thead: 'bg-elevated [&>tr]:border-t-0',
|
||||
separator: 'hidden',
|
||||
th: 'px-3 py-2 text-sm font-medium text-muted',
|
||||
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
|
||||
tbody: 'divide-y-0',
|
||||
tr: 'border-t border-default',
|
||||
}"
|
||||
>
|
||||
<template #status-cell="{ row }">
|
||||
<span class="font-mono text-sm font-medium text-highlighted">{{
|
||||
row.original.status
|
||||
}}</span>
|
||||
</template>
|
||||
<template #meaning-cell="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #messages-cell="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-pretty">{{
|
||||
quoteMessages(row.original.messages)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
<div class="min-w-0 border border-default bg-elevated">
|
||||
<div
|
||||
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
|
||||
>
|
||||
{{ t('Error response · text/plain') }}
|
||||
</div>
|
||||
<pre
|
||||
tabindex="0"
|
||||
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
|
||||
>{{ ERROR_SAMPLE }}</pre
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
|
||||
<h2 id="h-notes" :class="SECTION_TITLE">{{ t('Notes') }}</h2>
|
||||
<div class="flex flex-col gap-2.5 text-base text-toned">
|
||||
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
||||
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
||||
<span class="text-pretty"><CodeText :text="note" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
to="/integration/api-keys"
|
||||
variant="link"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
class="self-start px-0"
|
||||
>
|
||||
{{ t('Manage API keys') }}
|
||||
</UButton>
|
||||
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -1,16 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import UButton from '@nuxt/ui/components/Button.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import {
|
||||
API_KEY_PLACEHOLDER,
|
||||
ERROR_SAMPLE,
|
||||
authExamples,
|
||||
allEndpoints,
|
||||
endpointExamples,
|
||||
} from '@/data/apiCatalogue'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
|
||||
const STUB_ORIGIN = 'https://cms.renew.example'
|
||||
const SECTION_LABELS = [
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Session only',
|
||||
'Errors',
|
||||
'Notes',
|
||||
]
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
let currentWrapper: VueWrapper | null = null
|
||||
let fetchSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
function stubOrigin(origin: string) {
|
||||
vi.stubGlobal('location', { ...window.location, origin })
|
||||
@@ -26,6 +44,9 @@ function stubClipboard() {
|
||||
}
|
||||
|
||||
async function mountIntegration() {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
@@ -42,10 +63,56 @@ async function mountIntegration() {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, name: string) {
|
||||
function findButton(wrapper: VueWrapper, name: string) {
|
||||
return wrapper.findAll('button').find((button) => button.text().trim() === name)
|
||||
}
|
||||
|
||||
function row(wrapper: VueWrapper, route: string) {
|
||||
const found = wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))
|
||||
if (!found) {
|
||||
throw new Error(`no row for ${route}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function card(wrapper: VueWrapper, route: string) {
|
||||
const [method, path] = route.split(' ')
|
||||
return wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.find(
|
||||
(item) =>
|
||||
item.props('endpoint').method === method && item.props('endpoint').path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
async function expand(wrapper: VueWrapper, route: string) {
|
||||
await row(wrapper, route).trigger('click')
|
||||
await flushPromises()
|
||||
return card(wrapper, route)
|
||||
}
|
||||
|
||||
async function activateTab(container: HTMLElement, label: string) {
|
||||
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
button?.focus()
|
||||
button?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function rowRoute(item: ReturnType<VueWrapper['get']>) {
|
||||
const [method, path] = item.findAll('span.font-mono').map((span) => span.text())
|
||||
return `${method} ${path}`
|
||||
}
|
||||
|
||||
function section(wrapper: VueWrapper, id: string) {
|
||||
return wrapper.get(`[data-anchor="${id}"]`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
@@ -54,89 +121,154 @@ afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("Default — the page loads with the app's own origin", () => {
|
||||
it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
describe('Default, every card collapsed', () => {
|
||||
it('renders the header, intro, base URL and collapsed cards', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(wrapper.text()).toContain('Connect your website')
|
||||
expect(wrapper.text()).toContain('A guide for the developer who builds your public site.')
|
||||
expect(wrapper.text()).toContain('Integration')
|
||||
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 and publishes your articles but doesn't show them to visitors.",
|
||||
'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.',
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain(STUB_ORIGIN)
|
||||
expect(findButton(wrapper, 'Copy')).toBeTruthy()
|
||||
|
||||
const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text())
|
||||
expect(cardTexts).toHaveLength(2)
|
||||
expect(cardTexts[0]).toContain('/v1/article/')
|
||||
expect(cardTexts[0]).toContain('Lists every online article.')
|
||||
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
|
||||
expect(cardTexts[1]).toContain('/v1/article/{id}')
|
||||
expect(cardTexts[1]).toContain(
|
||||
'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
)
|
||||
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
|
||||
|
||||
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
|
||||
expect(fetchTabs.length).toBeGreaterThan(0)
|
||||
fetchTabs.forEach((tab) => {
|
||||
expect(tab.attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
"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.",
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the breadcrumb Integration', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const wrapper = await mountIntegration()
|
||||
expect(wrapper.text()).toContain('Integration')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Manage API keys link', () => {
|
||||
it('sits below the three notes as a link-variant button with a trailing arrow and navigates to the keys screen', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
|
||||
expect(anchor.attributes('href')).toBe('/integration/api-keys')
|
||||
const button = wrapper
|
||||
.findAllComponents(UButton)
|
||||
.find((component) => component.element === anchor.element)!
|
||||
expect(button.props('variant')).toBe('link')
|
||||
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
|
||||
const lastNote = wrapper
|
||||
.findAll('span')
|
||||
.find((span) => span.text().startsWith('Browsers can only call the API'))!
|
||||
const rows = wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.map((item) => item.get('button[aria-expanded]'))
|
||||
expect(rows).toHaveLength(allEndpoints().length)
|
||||
rows.forEach((button) => expect(button.attributes('aria-expanded')).toBe('false'))
|
||||
expect(
|
||||
lastNote.element.compareDocumentPosition(anchor.element) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
await anchor.trigger('click')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
|
||||
it('lists the sections in the sticky index and the Jump to select', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
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')
|
||||
})
|
||||
|
||||
it('shows Owner only on the three invite rows and Verified user elsewhere', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
for (const route of [
|
||||
'POST /v1/user/invite',
|
||||
'DELETE /v1/user/invite/{id}',
|
||||
'POST /v1/user/invite/{id}/renew',
|
||||
]) {
|
||||
expect(row(wrapper, route).text()).toContain('Owner only')
|
||||
expect(row(wrapper, route).text()).not.toContain('Verified user')
|
||||
}
|
||||
expect(row(wrapper, 'GET /v1/auth/me').text()).toContain('Verified user')
|
||||
expect(row(wrapper, 'GET /v1/article/').text()).toContain('Public')
|
||||
expect(row(wrapper, 'GET /v1/article/').text()).toContain('API key OK')
|
||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||
})
|
||||
|
||||
it('groups the 20 cards as 7, 6, 6 and 1', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(
|
||||
['articles', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([7, 6, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('7 routes')
|
||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
it('makes no /v1 request while rendering or expanding cards', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await expand(wrapper, 'POST /v1/article/')
|
||||
await expand(wrapper, 'GET /v1/media/')
|
||||
|
||||
expect(globalThis.fetch).toBe(fetchSpy)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copied — the Base URL copy button', () => {
|
||||
describe('Group scrolled, one card expanded', () => {
|
||||
it('reports aria-expanded and shows the tables, tabs, code and errors', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const created = await expand(wrapper, 'POST /v1/article/')
|
||||
|
||||
expect(row(wrapper, 'POST /v1/article/').attributes('aria-expanded')).toBe('true')
|
||||
const text = created.text()
|
||||
expect(text.indexOf('Query parameters')).toBeGreaterThan(-1)
|
||||
expect(text.indexOf('Query parameters')).toBeLessThan(text.indexOf('Request body'))
|
||||
expect(text).toContain('format')
|
||||
expect(text).toContain('markdown, html or json. Default markdown.')
|
||||
expect(text).toContain('title')
|
||||
expect(text).toContain("3 to 200 characters. Can't be changed later.")
|
||||
expect(text).toContain('Up to 10 000 characters.')
|
||||
|
||||
const tabs = created.findAll('[role="tab"]')
|
||||
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
|
||||
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(text).toContain('JavaScript')
|
||||
expect(
|
||||
created
|
||||
.findAll('button')
|
||||
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
|
||||
).toBe(true)
|
||||
|
||||
expect(text).toContain('Errors')
|
||||
const statuses = created
|
||||
.findAll('[title]')
|
||||
.map((item) => item.text())
|
||||
.join(' ')
|
||||
expect(statuses).toContain('400')
|
||||
expect(statuses).toContain('401')
|
||||
expect(text).toContain('Article title must be between 3 and 200 characters.')
|
||||
expect(text).toContain('The format must be one of: markdown, html, json.')
|
||||
})
|
||||
|
||||
it('shows "No query parameters." and "No request body." when a card has none', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const published = await expand(wrapper, 'GET /v1/settings')
|
||||
|
||||
expect(published.text()).toContain('No query parameters.')
|
||||
expect(published.text()).toContain('No request body.')
|
||||
})
|
||||
|
||||
it('shows the six media query parameters on the media list', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/media/')
|
||||
|
||||
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
|
||||
expect(list.text()).toContain(name)
|
||||
}
|
||||
expect(list.text()).toContain('1 to 100. Default 24.')
|
||||
})
|
||||
|
||||
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const upload = await expand(wrapper, 'POST /v1/media/')
|
||||
expect(upload.text()).toContain('Request body · multipart/form-data, 1 file field')
|
||||
expect(upload.text()).toContain('file')
|
||||
expect(upload.text()).toContain('the image could not be read')
|
||||
expect(upload.text()).toContain('the file exceeds the maximum upload size')
|
||||
expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image')
|
||||
|
||||
const invite = await expand(wrapper, 'POST /v1/user/invite')
|
||||
expect(invite.text()).toContain('Only the owner can do this.')
|
||||
expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.')
|
||||
expect(invite.text()).toContain("There's already a pending invite for this address.")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copied, the Base URL copy button', () => {
|
||||
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const writeText = stubClipboard()
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const copyButton = findButton(wrapper, 'Copy')!
|
||||
await copyButton.trigger('click')
|
||||
await findButton(wrapper, 'Copy')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
|
||||
@@ -150,66 +282,308 @@ describe('Copied — the Base URL copy button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => {
|
||||
it("copies only that card's code and leaves the other buttons unaffected", async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
|
||||
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
|
||||
const writeText = stubClipboard()
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountIntegration()
|
||||
const created = await expand(wrapper, 'POST /v1/article/')
|
||||
|
||||
const cards = wrapper.findAllComponents(EndpointCard)
|
||||
const firstCardCopyButton = cards[0]
|
||||
const button = created
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === 'Copy')!
|
||||
await firstCardCopyButton.trigger('click')
|
||||
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const expectedCode =
|
||||
`const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
|
||||
'const articles = await res.json()'
|
||||
expect(writeText).toHaveBeenCalledWith(expectedCode)
|
||||
const expected = endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch
|
||||
expect(writeText).toHaveBeenCalledWith(expected)
|
||||
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
|
||||
expect(copied).toHaveLength(1)
|
||||
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
|
||||
expect(findButton(wrapper, 'Copy')).toBeTruthy()
|
||||
|
||||
const copyButtons = wrapper.findAll('button').filter((button) => {
|
||||
const text = button.text().trim()
|
||||
return text === 'Copy' || text === 'Copied'
|
||||
})
|
||||
const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied')
|
||||
expect(copiedButtons).toHaveLength(1)
|
||||
|
||||
const baseUrlCopyButton = findButton(wrapper, 'Copy')
|
||||
expect(baseUrlCopyButton).toBeTruthy()
|
||||
|
||||
const secondCardCopyButton = cards[1]
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === 'Copy')
|
||||
expect(secondCardCopyButton).toBeTruthy()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(findButton(wrapper, 'Copied')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
async function activateTab(container: HTMLElement, label: string) {
|
||||
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
button?.focus()
|
||||
button?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
describe('Authentication', () => {
|
||||
it('renders the Public and API key cards, the tabs for GET /v1/auth/me, the link and both notes', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const auth = section(wrapper, 'auth')
|
||||
|
||||
describe('curl and Response tabs render, unchecked visually', () => {
|
||||
it('shows the curl command and the Response JSON sample built from the real Article fields', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
expect(auth.text()).toContain(
|
||||
'No key needed. Two read-only routes, and only online articles are returned.',
|
||||
)
|
||||
expect(auth.text()).toContain('GET /v1/article/')
|
||||
expect(auth.text()).toContain('GET /v1/article/{id}')
|
||||
expect(auth.text()).toContain(
|
||||
"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.",
|
||||
)
|
||||
expect(auth.text()).toContain(
|
||||
'Keys start with rcms_ and are shown in full once, when you create them.',
|
||||
)
|
||||
|
||||
const tabs = auth.findAll('[role="tab"]').map((tab) => tab.text())
|
||||
expect(tabs).toEqual(['curl', 'fetch'])
|
||||
const examples = authExamples(STUB_ORIGIN)
|
||||
expect(auth.text()).toContain(`${STUB_ORIGIN}/v1/auth/me`)
|
||||
expect(auth.text()).toContain(API_KEY_PLACEHOLDER)
|
||||
|
||||
await activateTab(auth.element as HTMLElement, 'fetch')
|
||||
expect(auth.text()).toContain(`fetch('${STUB_ORIGIN}/v1/auth/me'`)
|
||||
expect(examples.curl).toContain('Authorization: Bearer rcms_')
|
||||
|
||||
const link = auth.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
|
||||
expect(link.attributes('href')).toBe('/integration/api-keys')
|
||||
|
||||
expect(auth.text()).toContain(
|
||||
'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.',
|
||||
)
|
||||
expect(auth.text()).toContain(
|
||||
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a Retry-After header.',
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates the Manage API keys link to the keys screen', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
|
||||
await activateTab(firstCard.element as HTMLElement, 'curl')
|
||||
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
|
||||
const button = wrapper
|
||||
.findAllComponents(UButton)
|
||||
.find((component) => component.element === anchor.element)!
|
||||
expect(button.props('variant')).toBe('link')
|
||||
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
|
||||
|
||||
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
|
||||
await anchor.trigger('click')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
|
||||
})
|
||||
})
|
||||
|
||||
await activateTab(firstCard.element as HTMLElement, 'Response')
|
||||
describe('Session only', () => {
|
||||
it('renders the banner, 13 rows without expand controls and the 403 chips', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const session = section(wrapper, 'session')
|
||||
|
||||
expect(firstCard.text()).toContain('"is_online"')
|
||||
expect(firstCard.text()).toContain('"created_at"')
|
||||
expect(firstCard.text()).toContain('"id"')
|
||||
expect(session.text()).toContain('13 routes')
|
||||
expect(session.text()).toContain(
|
||||
'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."',
|
||||
)
|
||||
const rows = session.findAll('[data-session-row]')
|
||||
expect(rows).toHaveLength(13)
|
||||
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
|
||||
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
|
||||
|
||||
const chipRows = rows.filter((item) => item.text().includes('Session only'))
|
||||
expect(chipRows.map(rowRoute)).toEqual([
|
||||
'GET /v1/system/update',
|
||||
'POST /v1/system/update/check',
|
||||
'POST /v1/system/update/download',
|
||||
'POST /v1/system/update/restart',
|
||||
'POST /v1/auth/verify',
|
||||
'DELETE /v1/auth/register',
|
||||
'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),
|
||||
)
|
||||
})
|
||||
|
||||
it('captions Log in, Log out, Register and Reset password without a 403 claim', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const rows = section(wrapper, 'session').findAll('[data-session-row]')
|
||||
|
||||
for (const path of [
|
||||
'/v1/auth/login',
|
||||
'/v1/auth/logout',
|
||||
'/v1/auth/register',
|
||||
'/v1/auth/password-reset',
|
||||
]) {
|
||||
const item = rows.find(
|
||||
(candidate) =>
|
||||
candidate.text().includes(path) &&
|
||||
candidate.text().includes('Made for the browser.'),
|
||||
)!
|
||||
expect(item.text()).toContain('A key is ignored here, with no 403.')
|
||||
expect(item.text()).not.toContain('Session only')
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', 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 rowLabels = wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.map((button) => button.attributes('aria-label'))
|
||||
expect(rowLabels.some((label) => label?.includes('/v1/system'))).toBe(false)
|
||||
})
|
||||
|
||||
it('lists the 6 Public auth flow rows with the Public chip and no lock', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const rows = section(wrapper, 'session').findAll('[data-public-row]')
|
||||
|
||||
expect(rows).toHaveLength(6)
|
||||
expect(rows.map((item) => item.text())).toEqual([
|
||||
expect.stringContaining('GET/v1/Hello world.'),
|
||||
expect.stringContaining('GET/v1/auth/registrationWhether registration is open.'),
|
||||
expect.stringContaining(
|
||||
'POST/v1/auth/password-reset/confirmSet a new password with the emailed code.',
|
||||
),
|
||||
expect.stringContaining('GET/v1/auth/inviteCheck an invite code.'),
|
||||
expect.stringContaining('POST/v1/auth/invite/acceptAccept an invite.'),
|
||||
expect.stringContaining('GET/healthzPlain text "ok". Outside /v1.'),
|
||||
])
|
||||
rows.forEach((item) => {
|
||||
expect(item.text()).toContain('Public')
|
||||
expect(item.text()).not.toContain('Session only')
|
||||
expect(item.find('[class*="lucide-lock"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Errors', () => {
|
||||
it('renders the intro, the real messages and the 403 sample', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const errors = section(wrapper, 'errors')
|
||||
|
||||
expect(errors.text()).toContain(
|
||||
'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.',
|
||||
)
|
||||
const rows = errors.findAll('tbody tr')
|
||||
expect(rows.map((item) => item.find('td').text())).toEqual([
|
||||
'400',
|
||||
'401',
|
||||
'403',
|
||||
'404',
|
||||
'409',
|
||||
'429',
|
||||
'500',
|
||||
])
|
||||
const text = errors.text()
|
||||
for (const message of [
|
||||
'The request cannot be processed due to a mismatch in the format of the body.',
|
||||
'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 the owner can do this.',
|
||||
'You must verify your account to access this resource.',
|
||||
'Article not found',
|
||||
'Image not found',
|
||||
'Invite not found',
|
||||
'API key not found',
|
||||
"There's already a pending invite for this address.",
|
||||
'This invite has already been accepted.',
|
||||
'Too many requests. Please try again later.',
|
||||
'Internal server error',
|
||||
]) {
|
||||
expect(text).toContain(message)
|
||||
}
|
||||
const sample = errors.get('pre').text()
|
||||
expect(sample).toBe(ERROR_SAMPLE)
|
||||
expect(sample.split('\n')).toEqual([
|
||||
'HTTP/1.1 403 Forbidden',
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
'This action needs a real session.',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('scrolls to the anchor and marks the index item current', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
const media = index.findAll('button').find((button) => button.text() === 'Media')!
|
||||
expect(media.attributes('aria-current')).toBeUndefined()
|
||||
await media.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(media.attributes('aria-current')).toBe('true')
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1)
|
||||
expect(scrollIntoView.mock.contexts[0]).toBe(section(wrapper, 'media').element)
|
||||
const current = index.findAll('button[aria-current="true"]')
|
||||
expect(current.map((button) => button.text())).toEqual(['Media'])
|
||||
})
|
||||
|
||||
it('lists the same sections in the mobile Jump to select', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const jump = wrapper.get('button[aria-label="Jump to section"]')
|
||||
await jump.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const options = Array.from(document.body.querySelectorAll('[role="option"]')).map(
|
||||
(option) => option.textContent?.trim(),
|
||||
)
|
||||
expect(options).toEqual(SECTION_LABELS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tabs, curl and Response render, unchecked visually', () => {
|
||||
it('shows curl with the origin and the Authorization header on a non-public route', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const created = await expand(wrapper, 'POST /v1/article/')
|
||||
await activateTab(created.element as HTMLElement, 'curl')
|
||||
|
||||
expect(created.text()).toContain(`curl -X POST "${STUB_ORIGIN}/v1/article/"`)
|
||||
expect(created.text()).toContain(`Authorization: Bearer ${API_KEY_PLACEHOLDER}`)
|
||||
})
|
||||
|
||||
it('shows curl without an Authorization header on a public route', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/article/')
|
||||
await activateTab(list.element as HTMLElement, 'curl')
|
||||
|
||||
expect(list.text()).toContain(`curl "${STUB_ORIGIN}/v1/article/"`)
|
||||
expect(list.text()).not.toContain('Authorization')
|
||||
})
|
||||
|
||||
it('shows the Response sample built from the real article fields', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const single = await expand(wrapper, 'GET /v1/article/{id}')
|
||||
await activateTab(single.element as HTMLElement, 'Response')
|
||||
|
||||
for (const key of [
|
||||
'"id"',
|
||||
'"title"',
|
||||
'"body"',
|
||||
'"body_format"',
|
||||
'"is_online"',
|
||||
'"created_at"',
|
||||
'"updated_at"',
|
||||
]) {
|
||||
expect(single.text()).toContain(key)
|
||||
}
|
||||
expect(single.text()).not.toContain('"images"')
|
||||
expect(single.text()).not.toContain('"status"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Notes', () => {
|
||||
it('renders the format, image-prefix and CORS notes', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const notes = section(wrapper, 'notes').text()
|
||||
|
||||
expect(notes).toContain(
|
||||
'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.',
|
||||
)
|
||||
expect(notes).toContain(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
)
|
||||
expect(notes).toContain(
|
||||
"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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user