Test API documentation viewer
This commit is contained in:
@@ -5,6 +5,23 @@ import { useAuthStore } from '@/auth/store'
|
|||||||
|
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
get length() {
|
||||||
|
return values.size
|
||||||
|
},
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
key: (index: number) => [...values.keys()][index] ?? null,
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
} satisfies Storage,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
useAuthStore.getState().clear()
|
useAuthStore.getState().clear()
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { fetchApiDocument } from '@/api/modules/api-docs'
|
||||||
|
import { ApiDocumentationPage } from './page'
|
||||||
|
|
||||||
|
vi.mock('@/api/modules/api-docs', () => ({
|
||||||
|
fetchApiDocument: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fetchApiDocumentMock = vi.mocked(fetchApiDocument)
|
||||||
|
const createApiReference = vi.fn()
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(<ApiDocumentationPage />, { wrapper: Wrapper })
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchApiDocumentMock.mockReset()
|
||||||
|
createApiReference.mockReset()
|
||||||
|
window.Scalar = { createApiReference }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
delete window.Scalar
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('API documentation page', () => {
|
||||||
|
it('loads the public contract into Scalar by default', async () => {
|
||||||
|
fetchApiDocumentMock.mockResolvedValue('openapi: 3.0.0\n')
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('公共 API')).toBeTruthy()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(createApiReference).toHaveBeenCalledWith(
|
||||||
|
expect.any(HTMLElement),
|
||||||
|
{ content: 'openapi: 3.0.0\n' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(fetchApiDocumentMock).toHaveBeenCalledWith('public', expect.any(AbortSignal))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between public and admin contracts', async () => {
|
||||||
|
fetchApiDocumentMock.mockImplementation(async (document) => `${document}: true\n`)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(fetchApiDocumentMock).toHaveBeenCalledWith('public', expect.anything()))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '管理端 API' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchApiDocumentMock).toHaveBeenCalledWith('admin', expect.anything()))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(createApiReference).toHaveBeenCalledWith(
|
||||||
|
expect.any(HTMLElement),
|
||||||
|
{ content: 'admin: true\n' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generation guidance for a missing document', async () => {
|
||||||
|
fetchApiDocumentMock.mockRejectedValue(
|
||||||
|
new ApiError(404, 'API_DOCUMENT_NOT_GENERATED', 'API 文档尚未生成'),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('文档尚未生成')).toBeTruthy()
|
||||||
|
expect(screen.getByText('php artisan api-docs:generate')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a generic loading failure', async () => {
|
||||||
|
fetchApiDocumentMock
|
||||||
|
.mockRejectedValueOnce(new ApiError(500, 'HTTP_500', '加载失败'))
|
||||||
|
.mockResolvedValueOnce('openapi: 3.0.0\n')
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchApiDocumentMock).toHaveBeenCalledTimes(2))
|
||||||
|
await waitFor(() => expect(createApiReference).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error when the Scalar reader cannot load', async () => {
|
||||||
|
delete window.Scalar
|
||||||
|
fetchApiDocumentMock.mockResolvedValue('openapi: 3.0.0\n')
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const script = await waitFor(() => {
|
||||||
|
const element = document.querySelector<HTMLScriptElement>(
|
||||||
|
'script[src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"]',
|
||||||
|
)
|
||||||
|
expect(element).toBeTruthy()
|
||||||
|
return element as HTMLScriptElement
|
||||||
|
})
|
||||||
|
fireEvent.error(script)
|
||||||
|
|
||||||
|
expect(await screen.findByText('文档阅读器加载失败,请重试。')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { fetchApiDocument, type ApiDocument } from '@/api/modules/api-docs'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ScalarApi {
|
||||||
|
createApiReference: (
|
||||||
|
element: HTMLElement,
|
||||||
|
configuration: Record<string, unknown>,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCUMENTS: Array<{ value: ApiDocument; label: string }> = [
|
||||||
|
{ value: 'public', label: '公共 API' },
|
||||||
|
{ value: 'admin', label: '管理端 API' },
|
||||||
|
]
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Scalar?: ScalarApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scalarLoader: Promise<ScalarApi> | null = null
|
||||||
|
|
||||||
|
function loadScalar(): Promise<ScalarApi> {
|
||||||
|
if (window.Scalar) return Promise.resolve(window.Scalar)
|
||||||
|
if (scalarLoader) return scalarLoader
|
||||||
|
|
||||||
|
scalarLoader = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'
|
||||||
|
script.async = true
|
||||||
|
script.onload = () =>
|
||||||
|
window.Scalar ? resolve(window.Scalar) : reject(new Error('Scalar failed to initialize'))
|
||||||
|
script.onerror = () => reject(new Error('Scalar failed to load'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
|
||||||
|
return scalarLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScalarReference({ content }: { content: string }) {
|
||||||
|
const container = useRef<HTMLDivElement>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setFailed(false)
|
||||||
|
|
||||||
|
void loadScalar()
|
||||||
|
.then((scalar) => {
|
||||||
|
if (!active || !container.current) return
|
||||||
|
container.current.replaceChildren()
|
||||||
|
scalar.createApiReference(container.current, { content })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setFailed(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
container.current?.replaceChildren()
|
||||||
|
}
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return <div className="p-8 text-center text-destructive">文档阅读器加载失败,请重试。</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={container} className="min-h-svh" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiDocumentationPage() {
|
||||||
|
const [document, setDocument] = useState<ApiDocument>('public')
|
||||||
|
const specification = useQuery({
|
||||||
|
queryKey: ['api-docs', document],
|
||||||
|
queryFn: ({ signal }) => fetchApiDocument(document, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const notGenerated =
|
||||||
|
specification.error instanceof ApiError &&
|
||||||
|
specification.error.code === 'API_DOCUMENT_NOT_GENERATED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-svh bg-background">
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] flex gap-1 rounded-lg border bg-background/90 p-1 shadow-lg backdrop-blur">
|
||||||
|
{DOCUMENTS.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
size="sm"
|
||||||
|
variant={document === item.value ? 'default' : 'ghost'}
|
||||||
|
onClick={() => setDocument(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{specification.isPending && (
|
||||||
|
<div className="grid min-h-svh place-items-center p-8">
|
||||||
|
<p className="text-sm text-muted-foreground">正在加载 API 文档…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notGenerated && (
|
||||||
|
<div className="grid min-h-svh place-items-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium">文档尚未生成</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
请联系运维人员运行{' '}
|
||||||
|
<code className="font-mono">php artisan api-docs:generate</code>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{specification.isError && !notGenerated && (
|
||||||
|
<div className="grid min-h-svh place-items-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium text-destructive">API 文档加载失败</p>
|
||||||
|
<Button className="mt-4" variant="outline" onClick={() => void specification.refetch()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{specification.data && <ScalarReference key={document} content={specification.data} />}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-137
@@ -1,12 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { ApiError } from '@/api/client'
|
|
||||||
import { fetchApiDocument, type ApiDocument } from '@/api/modules/api-docs'
|
|
||||||
import { fetchMe } from '@/api/modules/auth'
|
import { fetchMe } from '@/api/modules/auth'
|
||||||
import { PERM, requirePermission } from '@/auth/permissions'
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { isAuthenticated, useAuthStore } from '@/auth/store'
|
import { isAuthenticated, useAuthStore } from '@/auth/store'
|
||||||
import { Button } from '@/components/ui/button'
|
import { ApiDocumentationPage } from '@/features/api-docs/page'
|
||||||
|
|
||||||
export const Route = createFileRoute('/api-docs')({
|
export const Route = createFileRoute('/api-docs')({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
@@ -22,135 +18,3 @@ export const Route = createFileRoute('/api-docs')({
|
|||||||
},
|
},
|
||||||
component: ApiDocumentationPage,
|
component: ApiDocumentationPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface ScalarApi {
|
|
||||||
createApiReference: (
|
|
||||||
element: HTMLElement,
|
|
||||||
configuration: Record<string, unknown>,
|
|
||||||
) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DOCUMENTS: Array<{ value: ApiDocument; label: string }> = [
|
|
||||||
{ value: 'public', label: '公共 API' },
|
|
||||||
{ value: 'admin', label: '管理端 API' },
|
|
||||||
]
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
Scalar?: ScalarApi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let scalarLoader: Promise<ScalarApi> | null = null
|
|
||||||
|
|
||||||
function loadScalar(): Promise<ScalarApi> {
|
|
||||||
if (window.Scalar) return Promise.resolve(window.Scalar)
|
|
||||||
if (scalarLoader) return scalarLoader
|
|
||||||
|
|
||||||
scalarLoader = new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'
|
|
||||||
script.async = true
|
|
||||||
script.onload = () =>
|
|
||||||
window.Scalar ? resolve(window.Scalar) : reject(new Error('Scalar failed to initialize'))
|
|
||||||
script.onerror = () => reject(new Error('Scalar failed to load'))
|
|
||||||
document.head.appendChild(script)
|
|
||||||
})
|
|
||||||
|
|
||||||
return scalarLoader
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScalarReference({ content }: { content: string }) {
|
|
||||||
const container = useRef<HTMLDivElement>(null)
|
|
||||||
const [failed, setFailed] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
setFailed(false)
|
|
||||||
|
|
||||||
void loadScalar()
|
|
||||||
.then((scalar) => {
|
|
||||||
if (!active || !container.current) return
|
|
||||||
container.current.replaceChildren()
|
|
||||||
scalar.createApiReference(container.current, { content })
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (active) setFailed(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
container.current?.replaceChildren()
|
|
||||||
}
|
|
||||||
}, [content])
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return <div className="p-8 text-center text-destructive">文档阅读器加载失败,请重试。</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div ref={container} className="min-h-svh" />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiDocumentationPage() {
|
|
||||||
const [document, setDocument] = useState<ApiDocument>('public')
|
|
||||||
const specification = useQuery({
|
|
||||||
queryKey: ['api-docs', document],
|
|
||||||
queryFn: ({ signal }) => fetchApiDocument(document, signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const notGenerated =
|
|
||||||
specification.error instanceof ApiError &&
|
|
||||||
specification.error.code === 'API_DOCUMENT_NOT_GENERATED'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-svh bg-background">
|
|
||||||
<div className="fixed bottom-4 right-4 z-[100] flex gap-1 rounded-lg border bg-background/90 p-1 shadow-lg backdrop-blur">
|
|
||||||
{DOCUMENTS.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.value}
|
|
||||||
size="sm"
|
|
||||||
variant={document === item.value ? 'default' : 'ghost'}
|
|
||||||
onClick={() => setDocument(item.value)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{specification.isPending && (
|
|
||||||
<div className="grid min-h-svh place-items-center p-8">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
正在加载 API 文档…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notGenerated && (
|
|
||||||
<div className="grid min-h-svh place-items-center p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="font-medium">文档尚未生成</p>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
请联系运维人员运行{' '}
|
|
||||||
<code className="font-mono">php artisan api-docs:generate</code>。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{specification.isError && !notGenerated && (
|
|
||||||
<div className="grid min-h-svh place-items-center p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="font-medium text-destructive">API 文档加载失败</p>
|
|
||||||
<Button className="mt-4" variant="outline" onClick={() => void specification.refetch()}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{specification.data && (
|
|
||||||
<ScalarReference key={document} content={specification.data} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user