diff --git a/src/api/client.test.ts b/src/api/client.test.ts index bbb3a85..358f6f0 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -5,6 +5,23 @@ import { useAuthStore } from '@/auth/store' const fetchMock = vi.fn() +vi.hoisted(() => { + const values = new Map() + 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(() => { vi.stubGlobal('fetch', fetchMock) useAuthStore.getState().clear() diff --git a/src/features/api-docs/page.test.tsx b/src/features/api-docs/page.test.tsx new file mode 100644 index 0000000..5555f9f --- /dev/null +++ b/src/features/api-docs/page.test.tsx @@ -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 {children} + } + + return render(, { 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( + '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() + }) +}) diff --git a/src/features/api-docs/page.tsx b/src/features/api-docs/page.tsx new file mode 100644 index 0000000..523db8a --- /dev/null +++ b/src/features/api-docs/page.tsx @@ -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, + ) => 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 | null = null + +function loadScalar(): Promise { + 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(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
文档阅读器加载失败,请重试。
+ } + + return
+} + +export function ApiDocumentationPage() { + const [document, setDocument] = useState('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 ( +
+
+ {DOCUMENTS.map((item) => ( + + ))} +
+ + {specification.isPending && ( +
+

正在加载 API 文档…

+
+ )} + + {notGenerated && ( +
+
+

文档尚未生成

+

+ 请联系运维人员运行{' '} + php artisan api-docs:generate。 +

+
+
+ )} + + {specification.isError && !notGenerated && ( +
+
+

API 文档加载失败

+ +
+
+ )} + + {specification.data && } +
+ ) +} diff --git a/src/routes/api-docs.tsx b/src/routes/api-docs.tsx index 3bd1789..8539059 100644 --- a/src/routes/api-docs.tsx +++ b/src/routes/api-docs.tsx @@ -1,12 +1,8 @@ -import { useQuery } from '@tanstack/react-query' 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 { PERM, requirePermission } from '@/auth/permissions' 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')({ beforeLoad: async () => { @@ -22,135 +18,3 @@ export const Route = createFileRoute('/api-docs')({ }, component: ApiDocumentationPage, }) - -interface ScalarApi { - createApiReference: ( - element: HTMLElement, - configuration: Record, - ) => 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 | null = null - -function loadScalar(): Promise { - 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(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
文档阅读器加载失败,请重试。
- } - - return
-} - -function ApiDocumentationPage() { - const [document, setDocument] = useState('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 ( -
-
- {DOCUMENTS.map((item) => ( - - ))} -
- - {specification.isPending && ( -
-

- 正在加载 API 文档… -

-
- )} - - {notGenerated && ( -
-
-

文档尚未生成

-

- 请联系运维人员运行{' '} - php artisan api-docs:generate。 -

-
-
- )} - - {specification.isError && !notGenerated && ( -
-
-

API 文档加载失败

- -
-
- )} - - {specification.data && ( - - )} -
- ) -}