- Default CORS_ORIGINS to http://localhost:5173 instead of "*" (still overridable) - Reject ".." and leading "/" in document keys/paths across get/save/delete/upload, mapped to HTTP 400 via new InvalidPathError - Add a delete button to DocumentEditor wired to deleteDocument + onDeleted, giving the existing DELETE API an actual UI entry point - Share a single lru_cache'd Meilisearch (and Minio) SDK client instance instead of constructing duplicates in dependencies.py - Add a startup check that creates the MinIO bucket if missing, tolerating MinIO being unreachable at boot without crashing the app
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
import { render, screen } from '@testing-library/react';
|
|
import { vi, test, expect, beforeEach } from 'vitest';
|
|
import DocumentEditor from '../../src/components/DocumentEditor';
|
|
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
|
|
|
vi.mock('../../src/api/client');
|
|
|
|
vi.mock('@monaco-editor/react', () => ({
|
|
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
|
|
<textarea
|
|
data-testid="monaco-editor-mock"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getDocument).mockReset();
|
|
vi.mocked(saveDocument).mockReset();
|
|
vi.mocked(deleteDocument).mockReset();
|
|
});
|
|
|
|
test('shows a loading state, then loads and displays document content', async () => {
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
});
|
|
|
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
|
|
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
|
expect(textarea).toHaveValue('# 架构设计');
|
|
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
|
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
|
});
|
|
|
|
import userEvent from '@testing-library/user-event';
|
|
import { waitFor } from '@testing-library/react';
|
|
|
|
test('typing marks the document dirty and saving clears the dirty state', async () => {
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
});
|
|
vi.mocked(saveDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
updatedAt: '2026-08-01T10:05:00Z',
|
|
});
|
|
const user = userEvent.setup();
|
|
|
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
|
|
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
|
await user.clear(textarea);
|
|
await user.type(textarea, '# 新内容');
|
|
|
|
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
|
|
|
|
const saveButton = screen.getByRole('button', { name: '保存' });
|
|
await user.click(saveButton);
|
|
|
|
await waitFor(() => {
|
|
expect(saveDocument).toHaveBeenCalledWith('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
|
});
|
|
await waitFor(() => {
|
|
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
|
|
|
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(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
|
|
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
|
|
|
await waitFor(() => {
|
|
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
|
});
|
|
|
|
await user.type(textarea, '追加内容');
|
|
|
|
await waitFor(() => {
|
|
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
|
});
|
|
});
|
|
|
|
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
});
|
|
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
|
const onDeleted = vi.fn();
|
|
const user = userEvent.setup();
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
|
|
|
await screen.findByTestId('monaco-editor-mock');
|
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
|
await user.click(deleteButton);
|
|
|
|
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
|
|
await waitFor(() => {
|
|
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
|
});
|
|
await waitFor(() => {
|
|
expect(onDeleted).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
test('clicking delete does nothing if the confirmation is declined', async () => {
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
});
|
|
const onDeleted = vi.fn();
|
|
const user = userEvent.setup();
|
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
|
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
|
|
|
await screen.findByTestId('monaco-editor-mock');
|
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
|
await user.click(deleteButton);
|
|
|
|
expect(deleteDocument).not.toHaveBeenCalled();
|
|
expect(onDeleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('shows an inline error and does not call onDeleted when delete fails', async () => {
|
|
vi.mocked(getDocument).mockResolvedValue({
|
|
key: '产品文档/架构设计.md',
|
|
content: '# 架构设计',
|
|
contentType: 'text/markdown',
|
|
});
|
|
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
|
const onDeleted = vi.fn();
|
|
const user = userEvent.setup();
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
|
|
|
await screen.findByTestId('monaco-editor-mock');
|
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
|
await user.click(deleteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
|
|
});
|
|
expect(onDeleted).not.toHaveBeenCalled();
|
|
});
|