DocHub/frontend/tests/api/client.test.ts
Tianyang 4a6fdaf994 feat: support uploading entire folders
Adds a folder picker (webkitdirectory) and drag-and-drop directory
support (File System Entries API), preserving relative paths via
a new optional relative_path field on the upload endpoint.
2026-08-01 16:30:29 +08:00

128 lines
5.1 KiB
TypeScript

import { 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' }],
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await listDocuments('产品文档');
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
expect(result).toEqual(mockResponse);
});
test('listDocuments throws when the response is not ok', async () => {
globalThis.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' };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await getDocument('产品文档/架构设计.md');
expect(globalThis.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' };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
expect(globalThis.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 () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
expect(globalThis.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 };
globalThis.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(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).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(body.get('relative_path')).toBeNull();
expect(result).toEqual(mockResponse);
});
test('uploadDocument includes relative_path when uploading a file from a folder', async () => {
const mockResponse = { key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 42 };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
await uploadDocument(file, '产品文档', '子目录/新文档.md');
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = init.body as FormData;
expect(body.get('relative_path')).toBe('子目录/新文档.md');
});
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: '...<em>检索</em>...' },
],
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await search('检索');
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
expect(result).toEqual(mockResponse);
});