676 lines
25 KiB
TypeScript
676 lines
25 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
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',
|
|
'Search and sharing',
|
|
'Media',
|
|
'Users and invites',
|
|
'Settings',
|
|
'Sites',
|
|
'Authors',
|
|
'Images and variants',
|
|
'Session only',
|
|
'Errors',
|
|
'Notes',
|
|
]
|
|
|
|
let currentWrapper: VueWrapper | null = null
|
|
let fetchSpy: ReturnType<typeof vi.fn>
|
|
|
|
function stubOrigin(origin: string) {
|
|
vi.stubGlobal('location', { ...window.location, origin })
|
|
}
|
|
|
|
function stubClipboard() {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText },
|
|
})
|
|
return writeText
|
|
}
|
|
|
|
async function mountIntegration() {
|
|
stubOrigin(STUB_ORIGIN)
|
|
fetchSpy = vi.fn()
|
|
vi.stubGlobal('fetch', fetchSpy)
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
session.session = { verified: true, username: 'marta' }
|
|
session.status = 'ready'
|
|
await router.push('/integration')
|
|
const wrapper = mount(IntegrationView, {
|
|
attachTo: document.body,
|
|
global: {
|
|
plugins: [router, uiPlugin],
|
|
},
|
|
})
|
|
currentWrapper = wrapper
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
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?.click()
|
|
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
|
|
vi.unstubAllGlobals()
|
|
vi.useRealTimers()
|
|
await router.push('/login')
|
|
})
|
|
|
|
describe('Default, every card collapsed', () => {
|
|
it('renders the header, intro, base URL and collapsed cards', async () => {
|
|
const wrapper = await mountIntegration()
|
|
|
|
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 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 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(
|
|
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
|
|
).toHaveLength(0)
|
|
})
|
|
|
|
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 cards as 8, 2, 10, 6 and 1', async () => {
|
|
const wrapper = await mountIntegration()
|
|
|
|
expect(
|
|
['articles', 'seo', 'media', 'users', 'settings'].map(
|
|
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
|
),
|
|
).toEqual([8, 2, 10, 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')
|
|
})
|
|
|
|
it('makes no /v1 request for a session without the owner or admin role', 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('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(created.findAll('[role="tablist"]')).toHaveLength(1)
|
|
expect(created.get('pre').text()).toBe(
|
|
endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch,
|
|
)
|
|
expect(text).not.toContain('Shell')
|
|
expect(created.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
|
|
|
|
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 seven 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', 'filter', 'sort', 'variants', 'page', 'size']) {
|
|
expect(list.text()).toContain(name)
|
|
}
|
|
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
|
|
expect(list.text()).not.toContain('next_cursor')
|
|
})
|
|
|
|
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 () => {
|
|
const writeText = stubClipboard()
|
|
vi.useFakeTimers()
|
|
const wrapper = await mountIntegration()
|
|
|
|
await findButton(wrapper, 'Copy')!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
|
|
expect(findButton(wrapper, 'Copied')).toBeTruthy()
|
|
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copied')
|
|
|
|
await vi.advanceTimersByTimeAsync(1999)
|
|
expect(findButton(wrapper, 'Copied')).toBeTruthy()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
|
|
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copy')
|
|
expect(findButton(wrapper, 'Copied')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
describe("Copied, a CodeSnippet's Copy button is independent of the Base URL's", () => {
|
|
it("copies only that card's active tab 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 button = created
|
|
.findAll('button')
|
|
.find((item) => item.attributes('aria-label') === 'Copy code')!
|
|
await button.trigger('click')
|
|
await flushPromises()
|
|
|
|
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 code')
|
|
expect(copied[0]!.element).toBe(button.element)
|
|
expect(findButton(wrapper, 'Copy')!.attributes('aria-label')).toBe('Copy code')
|
|
|
|
await vi.advanceTimersByTimeAsync(1999)
|
|
expect(findButton(wrapper, 'Copied')).toBeTruthy()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(findButton(wrapper, 'Copied')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
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')
|
|
|
|
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 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')
|
|
|
|
await anchor.trigger('click')
|
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
|
|
})
|
|
})
|
|
|
|
describe('Session only', () => {
|
|
it('renders the banner, 25 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.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(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',
|
|
'POST /v1/site/',
|
|
'PATCH /v1/site/{id}',
|
|
'PATCH /v1/site/{id}/default',
|
|
'DELETE /v1/site/{id}',
|
|
'POST /v1/author/',
|
|
'PATCH /v1/author/{id}',
|
|
'DELETE /v1/author/{id}',
|
|
'POST /v1/author/me',
|
|
'PATCH /v1/settings',
|
|
'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),
|
|
)
|
|
})
|
|
|
|
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.",
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('CodeSnippet migration', () => {
|
|
it('draws the Base URL as a CodeSnippet with the single tab "Base URL"', async () => {
|
|
const wrapper = await mountIntegration()
|
|
|
|
const label = wrapper.findAll('span').find((item) => item.text() === 'Base URL')!
|
|
const snippet = label.element.parentElement!
|
|
expect(snippet.querySelectorAll('[role="tab"]')).toHaveLength(0)
|
|
expect(snippet.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('Base URL')
|
|
expect(snippet.querySelector('pre')?.textContent).toBe(STUB_ORIGIN)
|
|
})
|
|
|
|
it('draws the Authentication example with curl and fetch tabs and no Shell or JavaScript label', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const auth = section(wrapper, 'auth')
|
|
|
|
expect(auth.findAll('[role="tablist"]')).toHaveLength(1)
|
|
expect(auth.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['curl', 'fetch'])
|
|
expect(auth.get('[role="tab"][aria-selected="true"]').text()).toBe('curl')
|
|
expect(auth.get('pre').text()).toBe(authExamples(STUB_ORIGIN).curl)
|
|
expect(auth.text()).not.toContain('Shell')
|
|
expect(auth.text()).not.toContain('JavaScript')
|
|
})
|
|
|
|
it('draws the Errors sample as a CodeSnippet named "Error response · text/plain"', async () => {
|
|
const wrapper = await mountIntegration()
|
|
const errors = section(wrapper, 'errors')
|
|
|
|
expect(errors.get('[role="tablist"]').text()).toBe('Error response · text/plain')
|
|
expect(errors.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
|
|
expect(errors.get('pre').text().split('\n')).toEqual([
|
|
'HTTP/1.1 403 Forbidden',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'This action needs a real session.',
|
|
])
|
|
})
|
|
|
|
it('keeps independent Copy buttons across the page', async () => {
|
|
const writeText = stubClipboard()
|
|
const wrapper = await mountIntegration()
|
|
|
|
await findButton(wrapper, 'Copy')!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
|
|
expect(wrapper.findAll('button').filter((item) => item.text() === 'Copied')).toHaveLength(1)
|
|
})
|
|
|
|
it('removes CodeBlock and every import of it, and keeps CodeText', () => {
|
|
const sources = import.meta.glob<string>('/src/**/*.{vue,ts}', {
|
|
query: '?raw',
|
|
import: 'default',
|
|
eager: true,
|
|
})
|
|
const paths = Object.keys(sources)
|
|
|
|
expect(paths).not.toContain('/src/components/CodeBlock.vue')
|
|
expect(paths).toContain('/src/components/CodeText.vue')
|
|
const importers = paths.filter(
|
|
(path) => !path.endsWith('.spec.ts') && sources[path]!.includes('CodeBlock'),
|
|
)
|
|
expect(importers).toEqual([])
|
|
})
|
|
})
|