Fix wave 2: CORS default, path traversal, delete UI, shared clients, bucket check
- 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
This commit is contained in:
parent
911d814a2f
commit
9ef22f9f0a
@ -10,7 +10,7 @@ class Settings:
|
||||
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
||||
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
||||
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
|
||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -10,20 +10,27 @@ from app.search_client import SearchClient
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_document_service() -> DocumentService:
|
||||
minio_sdk = Minio(
|
||||
def _get_minio_sdk() -> Minio:
|
||||
return Minio(
|
||||
settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=False,
|
||||
)
|
||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
minio_client = MinioClient(minio_sdk, settings.minio_bucket)
|
||||
search_client = SearchClient(meili_sdk, settings.meilisearch_index)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_meilisearch_sdk() -> meilisearch.Client:
|
||||
return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_document_service() -> DocumentService:
|
||||
minio_client = MinioClient(_get_minio_sdk(), settings.minio_bucket)
|
||||
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||
return DocumentService(minio_client, search_client)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_search_client() -> SearchClient:
|
||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
return SearchClient(meili_sdk, settings.meilisearch_index)
|
||||
return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||
|
||||
@ -26,6 +26,17 @@ class FileTooLargeError(Exception):
|
||||
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
||||
|
||||
|
||||
class InvalidPathError(Exception):
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
super().__init__(f"invalid path: {path}")
|
||||
|
||||
|
||||
def _validate_path(path: str) -> None:
|
||||
if path.startswith("/") or any(segment == ".." for segment in path.split("/")):
|
||||
raise InvalidPathError(path)
|
||||
|
||||
|
||||
def _extension_of(key: str) -> str:
|
||||
name = key.rsplit("/", 1)[-1]
|
||||
if "." not in name:
|
||||
@ -68,6 +79,7 @@ class DocumentService:
|
||||
return {"folders": folders, "files": files}
|
||||
|
||||
def get_document(self, key: str) -> dict:
|
||||
_validate_path(key)
|
||||
try:
|
||||
data = self._minio_client.get_object(key)
|
||||
except FileNotFoundError as exc:
|
||||
@ -94,6 +106,7 @@ class DocumentService:
|
||||
return text
|
||||
|
||||
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
||||
_validate_path(key)
|
||||
self._minio_client.put_object(key, content, content_type)
|
||||
try:
|
||||
text = self._extract_text(content, content_type)
|
||||
@ -105,6 +118,7 @@ class DocumentService:
|
||||
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
def delete_document(self, key: str) -> None:
|
||||
_validate_path(key)
|
||||
self._minio_client.delete_object(key)
|
||||
try:
|
||||
self._search_client.delete_document(key)
|
||||
@ -112,6 +126,7 @@ class DocumentService:
|
||||
logger.exception("Failed to delete search index entry for %s", key)
|
||||
|
||||
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
||||
_validate_path(key)
|
||||
extension = _extension_of(key)
|
||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise UnsupportedFileTypeError(extension)
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import _get_minio_sdk
|
||||
from app.routers import documents, search
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="DocHub API")
|
||||
|
||||
app.add_middleware(
|
||||
@ -18,6 +23,16 @@ app.include_router(documents.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def ensure_bucket_exists():
|
||||
try:
|
||||
minio_sdk = _get_minio_sdk()
|
||||
if not minio_sdk.bucket_exists(settings.minio_bucket):
|
||||
minio_sdk.make_bucket(settings.minio_bucket)
|
||||
except Exception:
|
||||
logger.warning("Could not verify/create MinIO bucket '%s' on startup", settings.minio_bucket, exc_info=True)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@ -6,6 +6,7 @@ from app.document_service import (
|
||||
DocumentNotFoundError,
|
||||
DocumentService,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
|
||||
@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
|
||||
return service.get_document(key)
|
||||
except DocumentNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.put("/api/documents/{key:path}")
|
||||
def save_document(
|
||||
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
||||
):
|
||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||
try:
|
||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.delete("/api/documents/{key:path}", status_code=204)
|
||||
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||||
service.delete_document(key)
|
||||
try:
|
||||
service.delete_document(key)
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return None
|
||||
|
||||
|
||||
@ -54,5 +63,5 @@ async def upload_document(
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
try:
|
||||
return service.upload_document(key, data, content_type)
|
||||
except (UnsupportedFileTypeError, FileTooLargeError) as exc:
|
||||
except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
17
backend/tests/test_config.py
Normal file
17
backend/tests/test_config.py
Normal file
@ -0,0 +1,17 @@
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_cors_origins_defaults_to_localhost_dev_port(monkeypatch):
|
||||
monkeypatch.delenv("CORS_ORIGINS", raising=False)
|
||||
|
||||
settings = Settings()
|
||||
|
||||
assert settings.cors_origins == ["http://localhost:5173"]
|
||||
|
||||
|
||||
def test_cors_origins_can_be_overridden_to_wildcard(monkeypatch):
|
||||
monkeypatch.setenv("CORS_ORIGINS", "*")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
assert settings.cors_origins == ["*"]
|
||||
@ -4,6 +4,7 @@ from app.document_service import (
|
||||
DocumentService,
|
||||
DocumentNotFoundError,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
MAX_UPLOAD_SIZE_BYTES,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
@ -196,6 +197,40 @@ def test_upload_document_rejects_oversized_file():
|
||||
assert "产品文档/大文件.md" not in minio_client.stored
|
||||
|
||||
|
||||
def test_get_document_rejects_path_traversal_key():
|
||||
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||||
|
||||
with pytest.raises(InvalidPathError):
|
||||
service.get_document("../../etc/passwd")
|
||||
|
||||
|
||||
def test_save_document_rejects_path_traversal_key():
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
with pytest.raises(InvalidPathError):
|
||||
service.save_document("../../etc/passwd", b"data", "text/plain")
|
||||
|
||||
assert "../../etc/passwd" not in minio_client.stored
|
||||
|
||||
|
||||
def test_delete_document_rejects_path_traversal_key():
|
||||
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||||
|
||||
with pytest.raises(InvalidPathError):
|
||||
service.delete_document("../../etc/passwd")
|
||||
|
||||
|
||||
def test_upload_document_rejects_path_traversal_key():
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
with pytest.raises(InvalidPathError):
|
||||
service.upload_document("../../etc/passwd", b"data", "text/plain")
|
||||
|
||||
assert "../../etc/passwd" not in minio_client.stored
|
||||
|
||||
|
||||
def test_upload_document_accepts_allowed_image_extension():
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main_module
|
||||
from app.main import app
|
||||
from app.document_service import DocumentNotFoundError
|
||||
from app.dependencies import get_document_service, get_search_client
|
||||
@ -14,6 +17,40 @@ def test_health_returns_ok():
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_startup_creates_bucket_when_missing(monkeypatch):
|
||||
fake_minio = MagicMock()
|
||||
fake_minio.bucket_exists.return_value = False
|
||||
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio)
|
||||
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
fake_minio.bucket_exists.assert_called()
|
||||
fake_minio.make_bucket.assert_called()
|
||||
|
||||
|
||||
def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch):
|
||||
fake_minio = MagicMock()
|
||||
fake_minio.bucket_exists.return_value = True
|
||||
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio)
|
||||
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
fake_minio.make_bucket.assert_not_called()
|
||||
|
||||
|
||||
def test_startup_does_not_crash_app_when_minio_is_unreachable(monkeypatch):
|
||||
def raise_connection_error():
|
||||
raise ConnectionError("minio unreachable")
|
||||
|
||||
monkeypatch.setattr(main_module, "_get_minio_sdk", raise_connection_error)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
response = test_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class FakeDocumentService:
|
||||
def __init__(self):
|
||||
self.storage = {}
|
||||
|
||||
@ -3,7 +3,12 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.dependencies import get_document_service
|
||||
from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError
|
||||
from app.document_service import (
|
||||
DocumentNotFoundError,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
|
||||
|
||||
class FakeDocumentService:
|
||||
@ -23,16 +28,24 @@ class FakeDocumentService:
|
||||
def get_document(self, key):
|
||||
if key == "不存在/文档.md":
|
||||
raise DocumentNotFoundError(key)
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
||||
|
||||
def save_document(self, key, content, content_type):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
self.saved.append((key, content, content_type))
|
||||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||||
|
||||
def delete_document(self, key):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
self.deleted.append(key)
|
||||
|
||||
def upload_document(self, key, data, content_type):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
if key.endswith(".exe"):
|
||||
raise UnsupportedFileTypeError(".exe")
|
||||
if len(data) > 1024:
|
||||
@ -119,3 +132,30 @@ def test_upload_document_returns_400_for_oversized_file(client):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "file too large" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_put_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.put(
|
||||
"/api/documents/..%2F..%2Fetc%2Fpasswd",
|
||||
json={"content": "x", "contentType": "text/plain"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_upload_document_returns_400_for_path_traversal_in_path(client):
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={"path": "../../etc"},
|
||||
files={"file": ("passwd.md", b"data", "text/markdown")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@ -54,6 +54,11 @@ export default function App() {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setSelectedKey(null);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
@ -67,7 +72,7 @@ export default function App() {
|
||||
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
||||
<div className="main" data-testid="main-area">
|
||||
{selectedKey ? (
|
||||
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} />
|
||||
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} onDeleted={handleDeleted} />
|
||||
) : (
|
||||
<div className="empty-state" data-testid="empty-state">
|
||||
未选择文档
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { getDocument, saveDocument } from '../api/client';
|
||||
import { deleteDocument, getDocument, saveDocument } from '../api/client';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
documentKey: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) {
|
||||
export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: 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 [deleting, setDeleting] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@ -57,6 +60,23 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
deleteDocument(documentKey)
|
||||
.then(() => {
|
||||
setDeleting(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch(() => {
|
||||
setDeleteError('删除失败,请重试');
|
||||
setDeleting(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||
}
|
||||
@ -84,6 +104,17 @@ export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentE
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{deleteError && (
|
||||
<span className="save-error" data-testid="delete-error">
|
||||
{deleteError}
|
||||
<button onClick={() => setDeleteError(null)} aria-label="关闭错误提示">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? '删除中…' : '删除'}
|
||||
</button>
|
||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||
import { getDocument, saveDocument } from '../../src/api/client';
|
||||
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
||||
|
||||
vi.mock('../../src/api/client');
|
||||
|
||||
@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
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 () => {
|
||||
@ -27,7 +28,7 @@ test('shows a loading state, then loads and displays document content', async ()
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
||||
|
||||
@ -52,7 +53,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.clear(textarea);
|
||||
@ -84,7 +85,7 @@ test('save failure keeps the editor and unsaved content visible, shows an error,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.clear(textarea);
|
||||
@ -117,7 +118,7 @@ test('calls onDirtyChange when dirty state changes', async () => {
|
||||
const onDirtyChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
|
||||
@ -131,3 +132,72 @@ test('calls onDirtyChange when dirty state changes', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user