diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..43f9db7 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,111 @@ +export interface DocumentFile { + key: string; + name: string; + size: number; + modifiedAt: string; +} + +export interface DocumentListResponse { + folders: string[]; + files: DocumentFile[]; +} + +export interface DocumentContent { + key: string; + content: string; + contentType: string; +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; + +function encodeKeyPath(key: string): string { + return key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +export async function listDocuments(path: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents?path=${encodeURIComponent(path)}`); + if (!response.ok) { + throw new Error(`Failed to list documents: ${response.status}`); + } + return response.json(); +} + +export async function getDocument(key: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`); + if (!response.ok) { + throw new Error(`Failed to get document: ${response.status}`); + } + return response.json(); +} + +export interface SaveDocumentResponse { + key: string; + updatedAt: string; +} + +export async function saveDocument( + key: string, + content: string, + contentType: string +): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, contentType }), + }); + if (!response.ok) { + throw new Error(`Failed to save document: ${response.status}`); + } + return response.json(); +} + +export async function deleteDocument(key: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error(`Failed to delete document: ${response.status}`); + } +} + +export interface UploadDocumentResponse { + key: string; + name: string; + size: number; +} + +export async function uploadDocument(file: File, path: string): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('path', path); + const response = await fetch(`${API_BASE_URL}/api/upload`, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + throw new Error(`Failed to upload document: ${response.status}`); + } + return response.json(); +} + +export interface SearchResult { + key: string; + title: string; + path: string; + snippet: string; +} + +export interface SearchResponse { + results: SearchResult[]; +} + +export async function search(query: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/search?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error(`Failed to search: ${response.status}`); + } + return response.json(); +} diff --git a/frontend/tests/api/client.test.ts b/frontend/tests/api/client.test.ts new file mode 100644 index 0000000..02a0635 --- /dev/null +++ b/frontend/tests/api/client.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client'; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +test('listDocuments calls GET /api/documents with the path query param and returns parsed JSON', async () => { + const mockResponse = { + folders: ['产品文档'], + files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }], + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await listDocuments('产品文档'); + + expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`); + expect(result).toEqual(mockResponse); +}); + +test('listDocuments throws when the response is not ok', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch; + + await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500'); +}); + +test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => { + const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await getDocument('产品文档/架构设计.md'); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}` + ); + expect(result).toEqual(mockResponse); +}); + +test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => { + const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown'); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '# 新内容', contentType: 'text/markdown' }), + } + ); + expect(result).toEqual(mockResponse); +}); + +test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch; + + await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined(); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, + { method: 'DELETE' } + ); +}); + +test('uploadDocument posts a multipart form with file and path fields', async () => { + const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' }); + + const result = await uploadDocument(file, '产品文档'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (global.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/api/upload'); + expect(init.method).toBe('POST'); + const body = init.body as FormData; + expect(body.get('file')).toBe(file); + expect(body.get('path')).toBe('产品文档'); + expect(result).toEqual(mockResponse); +}); + +test('search calls GET /api/search with the q query param and returns parsed JSON', async () => { + const mockResponse = { + results: [ + { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...检索...' }, + ], + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await search('检索'); + + expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`); + expect(result).toEqual(mockResponse); +});