diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efc7482..4c387ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,34 +10,67 @@ export default function App() { const [selectedKey, setSelectedKey] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [uploadPath] = useState(''); + + function confirmDiscardIfDirty(): boolean { + if (!isDirty) { + return true; + } + return window.confirm('有未保存的修改,确定要离开吗?'); + } + + function handleSelectFile(key: string) { + if (!confirmDiscardIfDirty()) { + return; + } + setSelectedKey(key); + } function handleJumpToResult(key: string) { + if (!confirmDiscardIfDirty()) { + return; + } setSelectedKey(key); setSearchQuery(''); setSearchResults([]); + setIsSearching(false); + } + + function handleQueryChange(query: string) { + setSearchQuery(query); + setIsSearching(query.trim() !== ''); + } + + function handleResults(results: SearchResult[]) { + setSearchResults(results); + setIsSearching(false); } function handleUploaded(result: UploadDocumentResponse) { setSelectedKey(result.key); + setRefreshKey((prev) => prev + 1); } return (

DocHub

- - + +
- +
{selectedKey ? ( - + ) : (
未选择文档
)}
- {searchQuery !== '' && ( + {searchQuery !== '' && !isSearching && ( )}
diff --git a/frontend/src/components/DocumentEditor.tsx b/frontend/src/components/DocumentEditor.tsx index 141c168..604f352 100644 --- a/frontend/src/components/DocumentEditor.tsx +++ b/frontend/src/components/DocumentEditor.tsx @@ -4,19 +4,22 @@ import { getDocument, saveDocument } from '../api/client'; interface DocumentEditorProps { documentKey: string; + onDirtyChange?: (isDirty: boolean) => void; } -export default function DocumentEditor({ documentKey }: DocumentEditorProps) { +export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) { const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [contentType, setContentType] = useState('text/markdown'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); useEffect(() => { setLoading(true); - setError(null); + setLoadError(null); + setSaveError(null); getDocument(documentKey) .then((doc) => { setContent(doc.content); @@ -25,26 +28,31 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) { setLoading(false); }) .catch(() => { - setError('无法加载文档'); + setLoadError('无法加载文档'); setLoading(false); }); }, [documentKey]); const isDirty = content !== originalContent; + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + function handleChange(value: string | undefined) { setContent(value ?? ''); } function handleSave() { setSaving(true); + setSaveError(null); saveDocument(documentKey, content, contentType) .then(() => { setOriginalContent(content); setSaving(false); }) .catch(() => { - setError('保存失败'); + setSaveError('保存失败,请重试'); setSaving(false); }); } @@ -53,8 +61,8 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) { return
加载中…
; } - if (error) { - return
{error}
; + if (loadError) { + return
{loadError}
; } return ( @@ -64,6 +72,14 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) { + {saveError && ( + + {saveError} + + + )}
diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index fbdf32f..3bc4b14 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client'; interface FileExplorerProps { onSelectFile: (key: string) => void; selectedKey?: string | null; + refreshKey?: number; } -export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) { +export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: FileExplorerProps) { const [folders, setFolders] = useState([]); const [files, setFiles] = useState([]); const [error, setError] = useState(null); @@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer setFiles(data.files); }) .catch(() => setError('无法加载文档目录')); - }, []); + }, [refreshKey]); if (error) { return
{error}
; diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index c5cac0d..8fa6d77 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,14 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { search, type SearchResult } from '../api/client'; interface SearchBarProps { + value: string; onQueryChange: (query: string) => void; onResults: (results: SearchResult[]) => void; debounceMs?: number; } -export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) { - const [value, setValue] = useState(''); +export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) { const timerRef = useRef | null>(null); useEffect(() => { @@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 } function handleChange(event: React.ChangeEvent) { const nextValue = event.target.value; - setValue(nextValue); onQueryChange(nextValue); if (timerRef.current) { diff --git a/frontend/src/components/UploadButton.tsx b/frontend/src/components/UploadButton.tsx index 98d58ae..f65893e 100644 --- a/frontend/src/components/UploadButton.tsx +++ b/frontend/src/components/UploadButton.tsx @@ -9,6 +9,7 @@ interface UploadButtonProps { export default function UploadButton({ path, onUploaded }: UploadButtonProps) { const [isOpen, setIsOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); function openModal() { @@ -18,13 +19,19 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { function closeModal() { setIsOpen(false); setIsDragOver(false); + setUploadError(null); } function uploadFiles(files: FileList | File[]) { Array.from(files).forEach((file) => { - uploadDocument(file, path).then((result) => { - onUploaded(result); - }); + setUploadError(null); + uploadDocument(file, path) + .then((result) => { + onUploaded(result); + }) + .catch((err) => { + setUploadError(err instanceof Error ? err.message : '上传失败'); + }); }); } @@ -68,6 +75,7 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { > 拖拽文件到此处,或点击选择 + {uploadError &&
{uploadError}
} { vi.mocked(listDocuments).mockReset(); vi.mocked(getDocument).mockReset(); vi.mocked(search).mockReset(); + vi.mocked(uploadDocument).mockReset(); vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); vi.mocked(getDocument).mockResolvedValue({ key: '', @@ -30,6 +31,10 @@ beforeEach(() => { vi.mocked(search).mockResolvedValue({ results: [] }); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + test('renders the DocHub heading', () => { render(); expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument(); @@ -100,3 +105,127 @@ test('searching shows the results overlay, and clicking a result opens the docum const textarea = await screen.findByTestId('monaco-editor-mock'); expect(textarea).toHaveValue('# 架构设计'); }); + +test('jumping to a search result clears the search input so it does not show stale text', async () => { + vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); + vi.mocked(search).mockResolvedValue({ + results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...检索...' }], + }); + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement; + await user.type(searchInput, '检索'); + + await waitFor( + () => { + expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument(); + }, + { timeout: 1000 } + ); + + await user.click(screen.getByTestId('search-result-产品文档/架构设计.md')); + + await waitFor(() => { + expect(searchInput).toHaveValue(''); + }); +}); + +test('typing a query does not show a false "no results" message before the debounced search resolves', async () => { + vi.useFakeTimers(); + vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); + vi.mocked(search).mockResolvedValue({ results: [] }); + + render(); + + const searchInput = screen.getByRole('textbox', { name: '搜索文档' }); + fireEvent.change(searchInput, { target: { value: '检索' } }); + + expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument(); + + await vi.advanceTimersByTimeAsync(300); + + expect(screen.getByTestId('search-no-results')).toBeInTheDocument(); + + vi.useRealTimers(); +}); + +test('switching files while the current document is dirty prompts for confirmation', async () => { + vi.mocked(listDocuments).mockResolvedValue({ + folders: [], + files: [ + { key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' }, + { key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' }, + ], + }); + vi.mocked(getDocument).mockImplementation((key: string) => + Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' }) + ); + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + await user.click(await screen.findByText('文档A.md')); + const textarea = await screen.findByTestId('monaco-editor-mock'); + await user.type(textarea, '修改内容'); + + await user.click(screen.getByText('文档B.md')); + + expect(confirmSpy).toHaveBeenCalled(); + expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容'); +}); + +test('confirming the discard prompt proceeds to switch files', async () => { + vi.mocked(listDocuments).mockResolvedValue({ + folders: [], + files: [ + { key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' }, + { key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' }, + ], + }); + vi.mocked(getDocument).mockImplementation((key: string) => + Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' }) + ); + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + await user.click(await screen.findByText('文档A.md')); + const textarea = await screen.findByTestId('monaco-editor-mock'); + await user.type(textarea, '修改内容'); + + await user.click(screen.getByText('文档B.md')); + + await waitFor(async () => { + expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md'); + }); +}); + +test('a successful upload refreshes the file tree', async () => { + vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); + vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 }); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(listDocuments).toHaveBeenCalledTimes(1); + }); + + await user.click(screen.getByRole('button', { name: '上传文档' })); + const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' }); + const input = screen.getByTestId('upload-file-input'); + await user.upload(input, file); + + await waitFor(() => { + expect(listDocuments).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/tests/api/client.test.ts b/frontend/tests/api/client.test.ts index 02a0635..feb62a3 100644 --- a/frontend/tests/api/client.test.ts +++ b/frontend/tests/api/client.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { test, expect, vi, beforeEach } from 'vitest'; import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client'; beforeEach(() => { @@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur folders: ['产品文档'], files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }], }; - global.fetch = vi.fn().mockResolvedValue({ + globalThis.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(globalThis.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; + 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' }; - global.fetch = vi.fn().mockResolvedValue({ + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockResponse), }) as unknown as typeof fetch; const result = await getDocument('产品文档/架构设计.md'); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}` ); expect(result).toEqual(mockResponse); @@ -44,14 +44,14 @@ test('getDocument calls GET /api/documents/{key} preserving slashes in the key', 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({ + globalThis.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( + expect(globalThis.fetch).toHaveBeenCalledWith( `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, { method: 'PUT', @@ -63,11 +63,11 @@ test('saveDocument calls PUT /api/documents/{key} with content and contentType', }); 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; + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch; await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined(); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, { method: 'DELETE' } ); @@ -75,7 +75,7 @@ test('deleteDocument calls DELETE /api/documents/{key} and resolves with no valu 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({ + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockResponse), }) as unknown as typeof fetch; @@ -83,8 +83,8 @@ test('uploadDocument posts a multipart form with file and path fields', async () const result = await uploadDocument(file, '产品文档'); - expect(global.fetch).toHaveBeenCalledTimes(1); - const [url, init] = (global.fetch as ReturnType).mock.calls[0]; + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (globalThis.fetch as ReturnType).mock.calls[0]; expect(url).toBe('/api/upload'); expect(init.method).toBe('POST'); const body = init.body as FormData; @@ -99,13 +99,13 @@ test('search calls GET /api/search with the q query param and returns parsed JSO { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...检索...' }, ], }; - global.fetch = vi.fn().mockResolvedValue({ + globalThis.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(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`); expect(result).toEqual(mockResponse); }); diff --git a/frontend/tests/components/DocumentEditor.test.tsx b/frontend/tests/components/DocumentEditor.test.tsx index e3cfcf5..93df960 100644 --- a/frontend/tests/components/DocumentEditor.test.tsx +++ b/frontend/tests/components/DocumentEditor.test.tsx @@ -70,3 +70,64 @@ test('typing marks the document dirty and saving clears the dirty state', async expect(screen.getByTestId('save-status')).toHaveTextContent('已归档'); }); }); + +test('save failure keeps the editor and unsaved content visible, shows an error, and allows retry', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error')); + vi.mocked(saveDocument).mockResolvedValueOnce({ + key: '产品文档/架构设计.md', + updatedAt: '2026-08-01T10:05:00Z', + }); + const user = userEvent.setup(); + + render(); + + const textarea = await screen.findByTestId('monaco-editor-mock'); + await user.clear(textarea); + await user.type(textarea, '# 未保存的修改'); + + const saveButton = screen.getByRole('button', { name: '保存' }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId('save-error')).toBeInTheDocument(); + }); + expect(screen.getByTestId('document-editor')).toBeInTheDocument(); + expect(screen.getByTestId('monaco-editor-mock')).toHaveValue('# 未保存的修改'); + expect(screen.getByTestId('save-status')).toHaveTextContent('未保存'); + + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId('save-status')).toHaveTextContent('已归档'); + }); + expect(screen.queryByTestId('save-error')).not.toBeInTheDocument(); +}); + +test('calls onDirtyChange when dirty state changes', async () => { + vi.mocked(getDocument).mockResolvedValue({ + key: '产品文档/架构设计.md', + content: '# 架构设计', + contentType: 'text/markdown', + }); + const onDirtyChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const textarea = await screen.findByTestId('monaco-editor-mock'); + + await waitFor(() => { + expect(onDirtyChange).toHaveBeenLastCalledWith(false); + }); + + await user.type(textarea, '追加内容'); + + await waitFor(() => { + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); +}); diff --git a/frontend/tests/components/FileExplorer.test.tsx b/frontend/tests/components/FileExplorer.test.tsx index f1bc309..37e393d 100644 --- a/frontend/tests/components/FileExplorer.test.tsx +++ b/frontend/tests/components/FileExplorer.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { vi, test, expect, beforeEach } from 'vitest'; import FileExplorer from '../../src/components/FileExplorer'; import { listDocuments } from '../../src/api/client'; @@ -67,3 +67,19 @@ test('shows an error message when listDocuments rejects', async () => { expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument(); }); + +test('changing refreshKey re-fetches the root listing', async () => { + vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); + + const { rerender } = render(); + + await waitFor(() => { + expect(listDocuments).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(listDocuments).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/tests/components/SearchBar.test.tsx b/frontend/tests/components/SearchBar.test.tsx index 9a9dc63..1d9aa53 100644 --- a/frontend/tests/components/SearchBar.test.tsx +++ b/frontend/tests/components/SearchBar.test.tsx @@ -1,7 +1,19 @@ +import { useState } from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { vi, test, expect, beforeEach, afterEach } from 'vitest'; import SearchBar from '../../src/components/SearchBar'; -import { search } from '../../src/api/client'; +import { search, type SearchResult } from '../../src/api/client'; + +function ControlledSearchBar({ + onResults, + debounceMs, +}: { + onResults: (results: SearchResult[]) => void; + debounceMs?: number; +}) { + const [value, setValue] = useState(''); + return ; +} vi.mock('../../src/api/client'); @@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => { const onQueryChange = vi.fn(); const onResults = vi.fn(); - render(); + render(); const input = screen.getByRole('textbox') as HTMLInputElement; fireEvent.change(input, { target: { value: '检索' } }); @@ -34,10 +46,9 @@ test('debounces input for 300ms before calling search', async () => { test('clearing the input immediately reports empty results without calling search', async () => { vi.mocked(search).mockResolvedValue({ results: [] }); - const onQueryChange = vi.fn(); const onResults = vi.fn(); - render(); + render(); const input = screen.getByRole('textbox') as HTMLInputElement; fireEvent.change(input, { target: { value: 'x' } }); @@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc expect(onResults).toHaveBeenCalledWith([]); }); + +test('reflects the value prop, allowing a parent to control/reset the input', () => { + const onQueryChange = vi.fn(); + const onResults = vi.fn(); + + const { rerender } = render(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input).toHaveValue('检索'); + + rerender(); + + expect(input).toHaveValue(''); +}); diff --git a/frontend/tests/components/UploadButton.test.tsx b/frontend/tests/components/UploadButton.test.tsx index 3fe7145..5dcd3c0 100644 --- a/frontend/tests/components/UploadButton.test.tsx +++ b/frontend/tests/components/UploadButton.test.tsx @@ -39,6 +39,25 @@ test('selecting a file through the hidden input uploads it', async () => { }); }); +test('a failed upload shows an error message and keeps the modal open for retry', async () => { + vi.mocked(uploadDocument).mockRejectedValue(new Error('Failed to upload document: 400')); + const onUploaded = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: '上传文档' })); + + const file = new File(['data'], '恶意程序.exe', { type: 'application/octet-stream' }); + const input = screen.getByTestId('upload-file-input'); + await user.upload(input, file); + + await waitFor(() => { + expect(screen.getByTestId('upload-error')).toHaveTextContent('Failed to upload document: 400'); + }); + expect(screen.getByTestId('upload-modal')).toBeInTheDocument(); + expect(onUploaded).not.toHaveBeenCalled(); +}); + test('dropping a file onto the dropzone uploads it', async () => { vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); const onUploaded = vi.fn();