3610 lines
115 KiB
Markdown
3610 lines
115 KiB
Markdown
# DocHub Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build DocHub, a small-team document hub that stores Markdown/HTML documents in MinIO, indexes their content in Meilisearch for full-text search, and lets users browse, edit, and upload documents through a React frontend.
|
||
|
||
**Architecture:** A FastAPI backend exposes REST endpoints that coordinate two thin SDK wrappers (`MinioClient`, `SearchClient`) through a `DocumentService` that writes to MinIO first and then synchronously updates the Meilisearch index (logging and swallowing index failures so a MinIO write is never rolled back for an index error). A React + Vite frontend consumes this API through a typed `client.ts`, rendering a lazy-loaded file tree, a Monaco-based editor with dirty-state tracking, a debounced global search bar with a results overlay, and an upload control supporting both click-to-select and drag-and-drop. No database and no authentication layer are used, per the approved design spec — MinIO holds content and metadata, Meilisearch holds the search index.
|
||
|
||
**Tech Stack:** Backend: Python, FastAPI, `minio` SDK, `meilisearch` SDK, pytest, httpx, python-multipart. Frontend: React 18, TypeScript, Vite, `@monaco-editor/react`, Vitest, Testing Library. Both services are containerized with Docker; MinIO is deployed separately per the design spec.
|
||
|
||
## Global Constraints
|
||
|
||
- No database and no authentication/authorization layer (per spec — trusted internal tool for a team of 10 or fewer).
|
||
- Index updates happen synchronously in `document_service.py`: write to MinIO first, then update Meilisearch; if the Meilisearch update fails, log it and do not fail the request.
|
||
- `text_extract.py` uses only the Python standard library (no third-party Markdown/HTML parsing dependency) since it only needs approximate text for indexing, not rendering.
|
||
- Backend and frontend communicate through the fixed API contract below — both plan sections were written against this exact contract, so it must not be changed without updating both sides.
|
||
- API contract:
|
||
- `GET /api/documents?path=<folder path>` → `{"folders": [...], "files": [{"key", "name", "size", "modifiedAt"}]}`
|
||
- `GET /api/documents/{key}` → `{"key", "content", "contentType"}` (404 if missing)
|
||
- `PUT /api/documents/{key}` body `{"content", "contentType"}` → `{"key", "updatedAt"}`
|
||
- `DELETE /api/documents/{key}` → 204 empty body
|
||
- `POST /api/upload` multipart fields `file`, `path` → 201 `{"key", "name", "size"}`
|
||
- `GET /api/search?q=<query>` → `{"results": [{"key", "title", "path", "snippet"}]}` (snippet contains `<em>` highlight tags)
|
||
- TDD throughout: every task follows write-failing-test → verify failure → minimal implementation → verify pass → commit.
|
||
|
||
---
|
||
|
||
### Task 1: Project Scaffolding, Config, and Health Endpoint
|
||
|
||
**Files:**
|
||
- Create: `backend/requirements.txt`
|
||
- Create: `backend/pytest.ini`
|
||
- Create: `backend/Dockerfile`
|
||
- Create: `backend/app/__init__.py`
|
||
- Create: `backend/app/config.py`
|
||
- Create: `backend/app/main.py`
|
||
- Test: `backend/tests/test_main.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: (none — first task)
|
||
- Produces: `app.config.settings` (instance of `Settings` with attributes `minio_endpoint`, `minio_access_key`, `minio_secret_key`, `minio_bucket`, `meilisearch_host`, `meilisearch_api_key`, `meilisearch_index`, `cors_origins`); `app.main.app` (FastAPI instance) consumed by Task 6, 7, 8 and by `uvicorn`/Docker at runtime.
|
||
|
||
- [ ] **Step 1: Create `backend/requirements.txt` and install dependencies**
|
||
|
||
```text
|
||
fastapi==0.115.0
|
||
uvicorn[standard]==0.30.6
|
||
minio==7.2.9
|
||
meilisearch==0.31.5
|
||
pytest==8.3.3
|
||
httpx==0.27.2
|
||
python-multipart==0.0.9
|
||
```
|
||
|
||
Run: `cd backend && pip install -r requirements.txt`
|
||
|
||
- [ ] **Step 2: Create `backend/pytest.ini` so `tests/` can import the `app` package**
|
||
|
||
```ini
|
||
[pytest]
|
||
pythonpath = .
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing test for the health endpoint**
|
||
|
||
```python
|
||
# backend/tests/test_main.py
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
client = TestClient(app)
|
||
|
||
|
||
def test_health_returns_ok():
|
||
response = client.get("/health")
|
||
assert response.status_code == 200
|
||
assert response.json() == {"status": "ok"}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_main.py::test_health_returns_ok -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.main'` (neither `app/__init__.py` nor `app/main.py` exist yet)
|
||
|
||
- [ ] **Step 5: Create `backend/app/__init__.py`**
|
||
|
||
```python
|
||
# backend/app/__init__.py
|
||
```
|
||
|
||
- [ ] **Step 6: Write `backend/app/config.py`**
|
||
|
||
```python
|
||
# backend/app/config.py
|
||
import os
|
||
|
||
|
||
class Settings:
|
||
def __init__(self):
|
||
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000")
|
||
self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
|
||
self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
|
||
self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub")
|
||
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(",")
|
||
|
||
|
||
settings = Settings()
|
||
```
|
||
|
||
- [ ] **Step 7: Write minimal `backend/app/main.py`**
|
||
|
||
```python
|
||
# backend/app/main.py
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
from app.config import settings
|
||
|
||
app = FastAPI(title="DocHub API")
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_main.py::test_health_returns_ok -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write `backend/Dockerfile`**
|
||
|
||
```dockerfile
|
||
FROM python:3.11-slim
|
||
|
||
WORKDIR /app
|
||
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
|
||
COPY app ./app
|
||
|
||
EXPOSE 8000
|
||
|
||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add backend/requirements.txt backend/pytest.ini backend/Dockerfile backend/app/__init__.py backend/app/config.py backend/app/main.py backend/tests/test_main.py
|
||
git commit -m "feat: scaffold FastAPI backend with config and health endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Text Extraction (`text_extract.py`)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/text_extract.py`
|
||
- Test: `backend/tests/test_text_extract.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: (none — stdlib only, no dependency on earlier tasks)
|
||
- Produces: `markdown_to_text(text: str) -> str`, `html_to_text(text: str) -> str`, both consumed by `DocumentService` in Task 5.
|
||
|
||
- [ ] **Step 1: Write the failing test for basic HTML tag stripping**
|
||
|
||
```python
|
||
# backend/tests/test_text_extract.py
|
||
from app.text_extract import html_to_text, markdown_to_text
|
||
|
||
|
||
def test_html_to_text_strips_tags():
|
||
html = """
|
||
<html>
|
||
<body>
|
||
<h1>架构设计</h1>
|
||
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统。</p>
|
||
<ul>
|
||
<li>浏览文档</li>
|
||
<li>全文检索</li>
|
||
</ul>
|
||
</body>
|
||
</html>
|
||
"""
|
||
result = html_to_text(html)
|
||
assert "架构设计" in result
|
||
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result
|
||
assert "浏览文档" in result
|
||
assert "<h1>" not in result
|
||
assert "<strong>" not in result
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.text_extract'`
|
||
|
||
- [ ] **Step 3: Write minimal `html_to_text` implementation**
|
||
|
||
```python
|
||
# backend/app/text_extract.py
|
||
from html.parser import HTMLParser
|
||
|
||
|
||
class _HTMLTextExtractor(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._chunks = []
|
||
|
||
def handle_data(self, data):
|
||
self._chunks.append(data)
|
||
|
||
def get_text(self):
|
||
return "".join(self._chunks)
|
||
|
||
|
||
def html_to_text(text: str) -> str:
|
||
parser = _HTMLTextExtractor()
|
||
parser.feed(text)
|
||
parser.close()
|
||
return parser.get_text()
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing test for whitespace collapsing**
|
||
|
||
```python
|
||
def test_html_to_text_collapses_whitespace():
|
||
html = "<p>第一段</p>\n\n\n<p>第二段</p>"
|
||
result = html_to_text(html)
|
||
assert result == "第一段 第二段"
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v`
|
||
Expected: FAIL with `AssertionError: '第一段\n\n\n第二段' != '第一段 第二段'`
|
||
|
||
- [ ] **Step 7: Extend `html_to_text` to collapse whitespace**
|
||
|
||
```python
|
||
# backend/app/text_extract.py
|
||
import re
|
||
from html.parser import HTMLParser
|
||
|
||
|
||
class _HTMLTextExtractor(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._chunks = []
|
||
|
||
def handle_data(self, data):
|
||
self._chunks.append(data)
|
||
|
||
def get_text(self):
|
||
return "".join(self._chunks)
|
||
|
||
|
||
def html_to_text(text: str) -> str:
|
||
parser = _HTMLTextExtractor()
|
||
parser.feed(text)
|
||
parser.close()
|
||
raw = parser.get_text()
|
||
return re.sub(r"\s+", " ", raw).strip()
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing test for Markdown headers and emphasis**
|
||
|
||
```python
|
||
def test_markdown_to_text_strips_headers_and_emphasis():
|
||
markdown = """# 架构设计
|
||
|
||
## 背景
|
||
|
||
DocHub 是一个 **基于 MinIO** 的 _文档管理系统_,支持全文检索。
|
||
"""
|
||
result = markdown_to_text(markdown)
|
||
assert "#" not in result
|
||
assert "**" not in result
|
||
assert "_" not in result
|
||
assert "架构设计" in result
|
||
assert "背景" in result
|
||
assert "基于 MinIO" in result
|
||
assert "文档管理系统" in result
|
||
```
|
||
|
||
- [ ] **Step 10: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v`
|
||
Expected: FAIL with `AttributeError: module 'app.text_extract' has no attribute 'markdown_to_text'`
|
||
|
||
- [ ] **Step 11: Write minimal `markdown_to_text` for headers and emphasis**
|
||
|
||
```python
|
||
# backend/app/text_extract.py (add below html_to_text)
|
||
def markdown_to_text(text: str) -> str:
|
||
result = text
|
||
result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"\s+", " ", result).strip()
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 12: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 13: Write the failing test for links and list markers**
|
||
|
||
```python
|
||
def test_markdown_to_text_strips_links_and_list_markers():
|
||
markdown = """## 功能列表
|
||
|
||
- 浏览 [MinIO](https://min.io) 中存储的文档
|
||
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
|
||
1. 在线编辑并保存
|
||
"""
|
||
result = markdown_to_text(markdown)
|
||
assert "[" not in result
|
||
assert "](" not in result
|
||
assert "浏览 MinIO 中存储的文档" in result
|
||
assert "支持 Meilisearch 全文检索" in result
|
||
assert "在线编辑并保存" in result
|
||
```
|
||
|
||
- [ ] **Step 14: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v`
|
||
Expected: FAIL with `AssertionError: assert '[' not in '功能列表 浏览 [MinIO](https://min.io) 中存储的文档 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 在线编辑并保存'`
|
||
|
||
- [ ] **Step 15: Extend `markdown_to_text` with link and list-marker stripping**
|
||
|
||
```python
|
||
# backend/app/text_extract.py
|
||
def markdown_to_text(text: str) -> str:
|
||
result = text
|
||
result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result)
|
||
result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result)
|
||
result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"\s+", " ", result).strip()
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 16: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 17: Write the failing test for code fences and inline code**
|
||
|
||
```python
|
||
def test_markdown_to_text_strips_code_fences_and_inline_code():
|
||
markdown = """使用 `pip install -r requirements.txt` 安装依赖。
|
||
|
||
```python
|
||
def hello():
|
||
return "world"
|
||
```
|
||
|
||
安装完成后启动服务。
|
||
"""
|
||
result = markdown_to_text(markdown)
|
||
assert "```" not in result
|
||
assert "def hello" not in result
|
||
assert "使用 pip install -r requirements.txt 安装依赖。" in result
|
||
assert "安装完成后启动服务。" in result
|
||
```
|
||
|
||
- [ ] **Step 18: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_code_fences_and_inline_code -v`
|
||
Expected: FAIL with `AssertionError: assert 'def hello' not in '...def hello():...'`
|
||
|
||
- [ ] **Step 19: Extend `markdown_to_text` with code-fence and inline-code stripping (applied first)**
|
||
|
||
```python
|
||
# backend/app/text_extract.py
|
||
def markdown_to_text(text: str) -> str:
|
||
result = text
|
||
result = re.sub(r"```.*?```", " ", result, flags=re.DOTALL)
|
||
result = re.sub(r"`([^`]*)`", r"\1", result)
|
||
result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result)
|
||
result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result)
|
||
result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
|
||
result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"^\s*>\s*", "", result, flags=re.MULTILINE)
|
||
result = re.sub(r"\s+", " ", result).strip()
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 20: Run full test file to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/test_text_extract.py -v`
|
||
Expected: PASS (all 6 tests)
|
||
|
||
- [ ] **Step 21: Commit**
|
||
|
||
```bash
|
||
git add backend/app/text_extract.py backend/tests/test_text_extract.py
|
||
git commit -m "feat: add stdlib-only markdown and html text extraction for indexing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: MinIO Client Wrapper (`minio_client.py`)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/minio_client.py`
|
||
- Test: `backend/tests/test_minio_client.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `minio.Minio` SDK instance (constructor-injected; tests inject a hand-written fake), `minio.error.S3Error`
|
||
- Produces: `MinioClient(client, bucket: str)` with `list_objects(prefix: str) -> list[dict]` (each dict has `key`, `size`, `is_dir`, `modifiedAt`), `get_object(key: str) -> bytes` (raises `FileNotFoundError` if missing), `put_object(key: str, data: bytes, content_type: str) -> None`, `delete_object(key: str) -> None` — consumed by `DocumentService` in Task 5.
|
||
|
||
- [ ] **Step 1: Write the failing test for `list_objects`**
|
||
|
||
```python
|
||
# backend/tests/test_minio_client.py
|
||
from datetime import datetime, timezone
|
||
|
||
import pytest
|
||
from minio.error import S3Error
|
||
|
||
from app.minio_client import MinioClient
|
||
|
||
|
||
class _FakeObject:
|
||
def __init__(self, object_name, size, last_modified):
|
||
self.object_name = object_name
|
||
self.size = size
|
||
self.last_modified = last_modified
|
||
|
||
|
||
class _FakeResponse:
|
||
def __init__(self, data: bytes):
|
||
self._data = data
|
||
self.closed = False
|
||
self.released = False
|
||
|
||
def read(self):
|
||
return self._data
|
||
|
||
def close(self):
|
||
self.closed = True
|
||
|
||
def release_conn(self):
|
||
self.released = True
|
||
|
||
|
||
class FakeMinioSDK:
|
||
def __init__(self):
|
||
self.objects = {}
|
||
self.put_calls = []
|
||
self.removed_keys = []
|
||
|
||
def list_objects(self, bucket, prefix="", recursive=False):
|
||
for key, (data, content_type, last_modified) in self.objects.items():
|
||
if key.startswith(prefix):
|
||
yield _FakeObject(key, len(data), last_modified)
|
||
|
||
def get_object(self, bucket, key):
|
||
if key not in self.objects:
|
||
raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None)
|
||
data, _, _ = self.objects[key]
|
||
return _FakeResponse(data)
|
||
|
||
def put_object(self, bucket, key, stream, length, content_type):
|
||
data = stream.read()
|
||
self.objects[key] = (data, content_type, datetime.now(timezone.utc))
|
||
self.put_calls.append((bucket, key, content_type))
|
||
|
||
def remove_object(self, bucket, key):
|
||
del self.objects[key]
|
||
self.removed_keys.append(key)
|
||
|
||
|
||
def test_list_objects_returns_keys_matching_prefix():
|
||
sdk = FakeMinioSDK()
|
||
sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
|
||
sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
|
||
client = MinioClient(sdk, bucket="dochub")
|
||
|
||
result = client.list_objects("产品文档/")
|
||
|
||
assert len(result) == 1
|
||
assert result[0]["key"] == "产品文档/架构设计.md"
|
||
assert result[0]["size"] == 4
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.minio_client'`
|
||
|
||
- [ ] **Step 3: Write minimal `MinioClient.list_objects`**
|
||
|
||
```python
|
||
# backend/app/minio_client.py
|
||
import io
|
||
|
||
|
||
class MinioClient:
|
||
def __init__(self, client, bucket: str):
|
||
self._client = client
|
||
self._bucket = bucket
|
||
|
||
def list_objects(self, prefix: str) -> list[dict]:
|
||
objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False)
|
||
result = []
|
||
for obj in objects:
|
||
result.append({
|
||
"key": obj.object_name,
|
||
"size": obj.size,
|
||
"is_dir": obj.object_name.endswith("/"),
|
||
"modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None,
|
||
})
|
||
return result
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing test for `get_object` success**
|
||
|
||
```python
|
||
def test_get_object_returns_bytes():
|
||
sdk = FakeMinioSDK()
|
||
sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
|
||
client = MinioClient(sdk, bucket="dochub")
|
||
|
||
data = client.get_object("产品文档/架构设计.md")
|
||
|
||
assert data == b"# architecture"
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v`
|
||
Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'get_object'`
|
||
|
||
- [ ] **Step 7: Write `get_object`**
|
||
|
||
```python
|
||
# backend/app/minio_client.py (add inside MinioClient)
|
||
from minio.error import S3Error
|
||
|
||
def get_object(self, key: str) -> bytes:
|
||
try:
|
||
response = self._client.get_object(self._bucket, key)
|
||
except S3Error as exc:
|
||
if exc.code == "NoSuchKey":
|
||
raise FileNotFoundError(key) from exc
|
||
raise
|
||
try:
|
||
return response.read()
|
||
finally:
|
||
response.close()
|
||
response.release_conn()
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing test for `get_object` on a missing key**
|
||
|
||
```python
|
||
def test_get_object_raises_file_not_found_for_missing_key():
|
||
sdk = FakeMinioSDK()
|
||
client = MinioClient(sdk, bucket="dochub")
|
||
|
||
with pytest.raises(FileNotFoundError):
|
||
client.get_object("不存在/文档.md")
|
||
```
|
||
|
||
- [ ] **Step 10: Run test to verify it passes (already satisfied by Step 7's implementation)**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_raises_file_not_found_for_missing_key -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 11: Write the failing test for `put_object`**
|
||
|
||
```python
|
||
def test_put_object_stores_data_with_content_type():
|
||
sdk = FakeMinioSDK()
|
||
client = MinioClient(sdk, bucket="dochub")
|
||
|
||
client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")
|
||
|
||
assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
|
||
assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
|
||
```
|
||
|
||
- [ ] **Step 12: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v`
|
||
Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'put_object'`
|
||
|
||
- [ ] **Step 13: Write `put_object`**
|
||
|
||
```python
|
||
# backend/app/minio_client.py (add inside MinioClient)
|
||
def put_object(self, key: str, data: bytes, content_type: str) -> None:
|
||
self._client.put_object(
|
||
self._bucket,
|
||
key,
|
||
io.BytesIO(data),
|
||
length=len(data),
|
||
content_type=content_type,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 14: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 15: Write the failing test for `delete_object`**
|
||
|
||
```python
|
||
def test_delete_object_removes_key():
|
||
sdk = FakeMinioSDK()
|
||
sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
|
||
client = MinioClient(sdk, bucket="dochub")
|
||
|
||
client.delete_object("产品文档/旧文档.md")
|
||
|
||
assert "产品文档/旧文档.md" not in sdk.objects
|
||
assert sdk.removed_keys == ["产品文档/旧文档.md"]
|
||
```
|
||
|
||
- [ ] **Step 16: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py::test_delete_object_removes_key -v`
|
||
Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'delete_object'`
|
||
|
||
- [ ] **Step 17: Write `delete_object`**
|
||
|
||
```python
|
||
# backend/app/minio_client.py (add inside MinioClient)
|
||
def delete_object(self, key: str) -> None:
|
||
self._client.remove_object(self._bucket, key)
|
||
```
|
||
|
||
- [ ] **Step 18: Run full test file to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/test_minio_client.py -v`
|
||
Expected: PASS (all 5 tests)
|
||
|
||
- [ ] **Step 19: Commit**
|
||
|
||
```bash
|
||
git add backend/app/minio_client.py backend/tests/test_minio_client.py
|
||
git commit -m "feat: add MinioClient wrapper for list/get/put/delete object operations"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Search Client Wrapper (`search_client.py`)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/search_client.py`
|
||
- Test: `backend/tests/test_search_client.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `meilisearch.Client` SDK instance (constructor-injected; tests inject a hand-written fake)
|
||
- Produces: `SearchClient(client, index_name: str)` with `index_document(key: str, path: str, title: str, content: str) -> None`, `delete_document(key: str) -> None`, `search(query: str) -> list[dict]` (each dict has `key`, `title`, `path`, `snippet`) — consumed by `DocumentService` in Task 5 and `routers/search.py` in Task 7.
|
||
|
||
- [ ] **Step 1: Write the failing test for `index_document`**
|
||
|
||
```python
|
||
# backend/tests/test_search_client.py
|
||
from app.search_client import SearchClient
|
||
|
||
|
||
class FakeIndex:
|
||
def __init__(self):
|
||
self.documents = {}
|
||
self.deleted_ids = []
|
||
|
||
def add_documents(self, docs):
|
||
for doc in docs:
|
||
self.documents[doc["id"]] = doc
|
||
|
||
def delete_document(self, doc_id):
|
||
self.documents.pop(doc_id, None)
|
||
self.deleted_ids.append(doc_id)
|
||
|
||
def search(self, query, params=None):
|
||
hits = []
|
||
for doc in self.documents.values():
|
||
if query in doc["content"] or query in doc["title"]:
|
||
formatted = dict(doc)
|
||
if query in doc["content"]:
|
||
formatted["content"] = doc["content"].replace(query, f"<em>{query}</em>")
|
||
hits.append({**doc, "_formatted": formatted})
|
||
return {"hits": hits}
|
||
|
||
|
||
class FakeMeilisearchSDK:
|
||
def __init__(self):
|
||
self.indexes = {}
|
||
|
||
def index(self, name):
|
||
if name not in self.indexes:
|
||
self.indexes[name] = FakeIndex()
|
||
return self.indexes[name]
|
||
|
||
|
||
def test_index_document_adds_document_with_encoded_id():
|
||
sdk = FakeMeilisearchSDK()
|
||
client = SearchClient(sdk, index_name="documents")
|
||
|
||
client.index_document(
|
||
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
|
||
)
|
||
|
||
index = sdk.indexes["documents"]
|
||
assert len(index.documents) == 1
|
||
stored = list(index.documents.values())[0]
|
||
assert stored["key"] == "产品文档/架构设计.md"
|
||
assert stored["path"] == "产品文档"
|
||
assert stored["title"] == "架构设计.md"
|
||
assert stored["content"] == "DocHub 使用 MinIO 存储文档"
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.search_client'`
|
||
|
||
- [ ] **Step 3: Write minimal `SearchClient.index_document`**
|
||
|
||
```python
|
||
# backend/app/search_client.py
|
||
import base64
|
||
|
||
|
||
def _encode_key(key: str) -> str:
|
||
return base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii").rstrip("=")
|
||
|
||
|
||
class SearchClient:
|
||
def __init__(self, client, index_name: str):
|
||
self._client = client
|
||
self._index_name = index_name
|
||
|
||
def index_document(self, key: str, path: str, title: str, content: str) -> None:
|
||
index = self._client.index(self._index_name)
|
||
index.add_documents([{
|
||
"id": _encode_key(key),
|
||
"key": key,
|
||
"path": path,
|
||
"title": title,
|
||
"content": content,
|
||
}])
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing test for `delete_document`**
|
||
|
||
```python
|
||
def test_delete_document_removes_document():
|
||
sdk = FakeMeilisearchSDK()
|
||
client = SearchClient(sdk, index_name="documents")
|
||
client.index_document("产品文档/旧文档.md", "产品文档", "旧文档.md", "旧内容")
|
||
|
||
client.delete_document("产品文档/旧文档.md")
|
||
|
||
index = sdk.indexes["documents"]
|
||
assert index.documents == {}
|
||
assert len(index.deleted_ids) == 1
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v`
|
||
Expected: FAIL with `AttributeError: 'SearchClient' object has no attribute 'delete_document'`
|
||
|
||
- [ ] **Step 7: Write `delete_document`**
|
||
|
||
```python
|
||
# backend/app/search_client.py (add inside SearchClient)
|
||
def delete_document(self, key: str) -> None:
|
||
index = self._client.index(self._index_name)
|
||
index.delete_document(_encode_key(key))
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing test for `search` with highlighted snippet**
|
||
|
||
```python
|
||
def test_search_returns_matching_documents_with_highlighted_snippet():
|
||
sdk = FakeMeilisearchSDK()
|
||
client = SearchClient(sdk, index_name="documents")
|
||
client.index_document(
|
||
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 支持全文检索功能"
|
||
)
|
||
|
||
results = client.search("检索")
|
||
|
||
assert len(results) == 1
|
||
assert results[0]["key"] == "产品文档/架构设计.md"
|
||
assert results[0]["title"] == "架构设计.md"
|
||
assert results[0]["path"] == "产品文档"
|
||
assert "<em>检索</em>" in results[0]["snippet"]
|
||
```
|
||
|
||
- [ ] **Step 10: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py::test_search_returns_matching_documents_with_highlighted_snippet -v`
|
||
Expected: FAIL with `AttributeError: 'SearchClient' object has no attribute 'search'`
|
||
|
||
- [ ] **Step 11: Write `search`**
|
||
|
||
```python
|
||
# backend/app/search_client.py (add inside SearchClient)
|
||
def search(self, query: str) -> list[dict]:
|
||
index = self._client.index(self._index_name)
|
||
response = index.search(query, {
|
||
"attributesToHighlight": ["content"],
|
||
"highlightPreTag": "<em>",
|
||
"highlightPostTag": "</em>",
|
||
})
|
||
results = []
|
||
for hit in response.get("hits", []):
|
||
formatted = hit.get("_formatted", {})
|
||
results.append({
|
||
"key": hit["key"],
|
||
"title": hit["title"],
|
||
"path": hit["path"],
|
||
"snippet": formatted.get("content", hit.get("content", "")),
|
||
})
|
||
return results
|
||
```
|
||
|
||
- [ ] **Step 12: Run full test file to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/test_search_client.py -v`
|
||
Expected: PASS (all 3 tests)
|
||
|
||
- [ ] **Step 13: Commit**
|
||
|
||
```bash
|
||
git add backend/app/search_client.py backend/tests/test_search_client.py
|
||
git commit -m "feat: add SearchClient wrapper for index/delete/search operations"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Document Service (`document_service.py`)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/document_service.py`
|
||
- Test: `backend/tests/test_document_service.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MinioClient.list_objects/get_object/put_object/delete_object` (Task 3), `SearchClient.index_document/delete_document` (Task 4), `markdown_to_text`/`html_to_text` (Task 2)
|
||
- Produces: `DocumentService(minio_client, search_client)` with `list_documents(path: str) -> dict`, `get_document(key: str) -> dict`, `save_document(key: str, content: bytes, content_type: str) -> dict`, `delete_document(key: str) -> None`, `upload_document(key: str, data: bytes, content_type: str) -> dict`; `DocumentNotFoundError` exception — all consumed by `routers/documents.py` in Task 6.
|
||
|
||
- [ ] **Step 1: Write the failing test for `list_documents`**
|
||
|
||
```python
|
||
# backend/tests/test_document_service.py
|
||
import pytest
|
||
|
||
from app.document_service import DocumentService, DocumentNotFoundError
|
||
|
||
|
||
class FakeMinioClient:
|
||
def __init__(self):
|
||
self.entries = []
|
||
self.stored = {}
|
||
self.deleted_keys = []
|
||
self.put_calls = []
|
||
|
||
def list_objects(self, prefix):
|
||
return [e for e in self.entries if e["key"].startswith(prefix)]
|
||
|
||
def get_object(self, key):
|
||
if key not in self.stored:
|
||
raise FileNotFoundError(key)
|
||
return self.stored[key]
|
||
|
||
def put_object(self, key, data, content_type):
|
||
self.stored[key] = data
|
||
self.put_calls.append((key, content_type))
|
||
|
||
def delete_object(self, key):
|
||
if key not in self.stored:
|
||
raise FileNotFoundError(key)
|
||
del self.stored[key]
|
||
self.deleted_keys.append(key)
|
||
|
||
|
||
class FakeSearchClient:
|
||
def __init__(self, fail_on_index=False, fail_on_delete=False):
|
||
self.indexed = []
|
||
self.deleted = []
|
||
self.fail_on_index = fail_on_index
|
||
self.fail_on_delete = fail_on_delete
|
||
|
||
def index_document(self, key, path, title, content):
|
||
if self.fail_on_index:
|
||
raise RuntimeError("meilisearch unavailable")
|
||
self.indexed.append({"key": key, "path": path, "title": title, "content": content})
|
||
|
||
def delete_document(self, key):
|
||
if self.fail_on_delete:
|
||
raise RuntimeError("meilisearch unavailable")
|
||
self.deleted.append(key)
|
||
|
||
|
||
def test_list_documents_at_root_returns_folders_and_files():
|
||
minio_client = FakeMinioClient()
|
||
minio_client.entries = [
|
||
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
|
||
]
|
||
service = DocumentService(minio_client, FakeSearchClient())
|
||
|
||
result = service.list_documents("")
|
||
|
||
assert result["folders"] == ["产品文档", "团队规范"]
|
||
assert result["files"] == [
|
||
{"key": "README.md", "name": "README.md", "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.document_service'`
|
||
|
||
- [ ] **Step 3: Write minimal `DocumentService.list_documents`**
|
||
|
||
```python
|
||
# backend/app/document_service.py
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
|
||
from app.text_extract import html_to_text, markdown_to_text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class DocumentNotFoundError(Exception):
|
||
pass
|
||
|
||
|
||
def _guess_content_type(key: str) -> str:
|
||
if key.endswith(".md"):
|
||
return "text/markdown"
|
||
if key.endswith(".html") or key.endswith(".htm"):
|
||
return "text/html"
|
||
return "application/octet-stream"
|
||
|
||
|
||
class DocumentService:
|
||
def __init__(self, minio_client, search_client):
|
||
self._minio_client = minio_client
|
||
self._search_client = search_client
|
||
|
||
def list_documents(self, path: str) -> dict:
|
||
prefix = path if not path or path.endswith("/") else f"{path}/"
|
||
entries = self._minio_client.list_objects(prefix)
|
||
folders = []
|
||
files = []
|
||
for entry in entries:
|
||
key = entry["key"]
|
||
if entry.get("is_dir"):
|
||
name = key[len(prefix):].rstrip("/")
|
||
if name:
|
||
folders.append(name)
|
||
else:
|
||
name = key[len(prefix):]
|
||
files.append({
|
||
"key": key,
|
||
"name": name,
|
||
"size": entry["size"],
|
||
"modifiedAt": entry["modifiedAt"],
|
||
})
|
||
return {"folders": folders, "files": files}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing tests for `get_document` (found and not found)**
|
||
|
||
```python
|
||
def test_get_document_returns_content_and_content_type():
|
||
minio_client = FakeMinioClient()
|
||
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8")
|
||
service = DocumentService(minio_client, FakeSearchClient())
|
||
|
||
result = service.get_document("产品文档/架构设计.md")
|
||
|
||
assert result["key"] == "产品文档/架构设计.md"
|
||
assert result["contentType"] == "text/markdown"
|
||
assert "架构设计" in result["content"]
|
||
|
||
|
||
def test_get_document_raises_not_found_for_missing_key():
|
||
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||
|
||
with pytest.raises(DocumentNotFoundError):
|
||
service.get_document("不存在/文档.md")
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v`
|
||
Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'get_document'`
|
||
|
||
- [ ] **Step 7: Write `get_document`**
|
||
|
||
```python
|
||
# backend/app/document_service.py (add inside DocumentService)
|
||
def get_document(self, key: str) -> dict:
|
||
try:
|
||
data = self._minio_client.get_object(key)
|
||
except FileNotFoundError as exc:
|
||
raise DocumentNotFoundError(key) from exc
|
||
return {
|
||
"key": key,
|
||
"content": data.decode("utf-8"),
|
||
"contentType": _guess_content_type(key),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing tests for `save_document` (success and index-failure tolerance)**
|
||
|
||
```python
|
||
def test_save_document_writes_to_minio_then_indexes_content():
|
||
minio_client = FakeMinioClient()
|
||
search_client = FakeSearchClient()
|
||
service = DocumentService(minio_client, search_client)
|
||
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
|
||
|
||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||
|
||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
||
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
|
||
assert search_client.indexed[0]["path"] == "产品文档"
|
||
assert search_client.indexed[0]["title"] == "架构设计.md"
|
||
assert "MinIO" in search_client.indexed[0]["content"]
|
||
assert "#" not in search_client.indexed[0]["content"]
|
||
assert "key" in result and "updatedAt" in result
|
||
|
||
|
||
def test_save_document_does_not_fail_when_index_update_fails():
|
||
minio_client = FakeMinioClient()
|
||
search_client = FakeSearchClient(fail_on_index=True)
|
||
service = DocumentService(minio_client, search_client)
|
||
content = b"# doc"
|
||
|
||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||
|
||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
||
assert result["key"] == "产品文档/架构设计.md"
|
||
```
|
||
|
||
- [ ] **Step 10: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v`
|
||
Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'save_document'`
|
||
|
||
- [ ] **Step 11: Write `save_document`**
|
||
|
||
```python
|
||
# backend/app/document_service.py (add inside DocumentService)
|
||
def _extract_text(self, content: bytes, content_type: str) -> str:
|
||
text = content.decode("utf-8", errors="ignore")
|
||
if content_type == "text/html":
|
||
return html_to_text(text)
|
||
if content_type == "text/markdown":
|
||
return markdown_to_text(text)
|
||
return text
|
||
|
||
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
||
self._minio_client.put_object(key, content, content_type)
|
||
try:
|
||
text = self._extract_text(content, content_type)
|
||
title = key.rsplit("/", 1)[-1]
|
||
path = key.rsplit("/", 1)[0] if "/" in key else ""
|
||
self._search_client.index_document(key, path, title, text)
|
||
except Exception:
|
||
logger.exception("Failed to update search index for %s", key)
|
||
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
||
```
|
||
|
||
- [ ] **Step 12: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 13: Write the failing tests for `delete_document` (success and index-failure tolerance)**
|
||
|
||
```python
|
||
def test_delete_document_removes_from_minio_and_search_index():
|
||
minio_client = FakeMinioClient()
|
||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
||
search_client = FakeSearchClient()
|
||
service = DocumentService(minio_client, search_client)
|
||
|
||
service.delete_document("产品文档/旧文档.md")
|
||
|
||
assert "产品文档/旧文档.md" not in minio_client.stored
|
||
assert search_client.deleted == ["产品文档/旧文档.md"]
|
||
|
||
|
||
def test_delete_document_does_not_fail_when_index_delete_fails():
|
||
minio_client = FakeMinioClient()
|
||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
||
search_client = FakeSearchClient(fail_on_delete=True)
|
||
service = DocumentService(minio_client, search_client)
|
||
|
||
service.delete_document("产品文档/旧文档.md")
|
||
|
||
assert "产品文档/旧文档.md" not in minio_client.stored
|
||
```
|
||
|
||
- [ ] **Step 14: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v`
|
||
Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'delete_document'`
|
||
|
||
- [ ] **Step 15: Write `delete_document`**
|
||
|
||
```python
|
||
# backend/app/document_service.py (add inside DocumentService)
|
||
def delete_document(self, key: str) -> None:
|
||
self._minio_client.delete_object(key)
|
||
try:
|
||
self._search_client.delete_document(key)
|
||
except Exception:
|
||
logger.exception("Failed to delete search index entry for %s", key)
|
||
```
|
||
|
||
- [ ] **Step 16: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 17: Write the failing test for `upload_document`**
|
||
|
||
```python
|
||
def test_upload_document_writes_file_and_returns_metadata():
|
||
minio_client = FakeMinioClient()
|
||
service = DocumentService(minio_client, FakeSearchClient())
|
||
data = b"# uploaded doc"
|
||
|
||
result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
|
||
|
||
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
|
||
assert minio_client.stored["产品文档/新上传.md"] == data
|
||
```
|
||
|
||
- [ ] **Step 18: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py::test_upload_document_writes_file_and_returns_metadata -v`
|
||
Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'upload_document'`
|
||
|
||
- [ ] **Step 19: Write `upload_document`**
|
||
|
||
```python
|
||
# backend/app/document_service.py (add inside DocumentService)
|
||
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
||
self.save_document(key, data, content_type)
|
||
name = key.rsplit("/", 1)[-1]
|
||
return {"key": key, "name": name, "size": len(data)}
|
||
```
|
||
|
||
- [ ] **Step 20: Run full test file to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/test_document_service.py -v`
|
||
Expected: PASS (all 8 tests)
|
||
|
||
- [ ] **Step 21: Commit**
|
||
|
||
```bash
|
||
git add backend/app/document_service.py backend/tests/test_document_service.py
|
||
git commit -m "feat: add DocumentService coordinating MinIO writes and search indexing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Documents Router (`routers/documents.py`)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/dependencies.py`
|
||
- Create: `backend/app/routers/__init__.py`
|
||
- Create: `backend/app/routers/documents.py`
|
||
- Test: `backend/tests/test_routers_documents.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1)
|
||
- Produces: `app.dependencies.get_document_service()` dependency provider (FastAPI's official "Bigger Applications" layout keeps shared dependency providers in a dedicated `dependencies.py`, separate from routers); `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`) — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`.
|
||
|
||
- [ ] **Step 1: Write the failing test for `GET /api/documents`**
|
||
|
||
```python
|
||
# backend/tests/test_routers_documents.py
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
from app.dependencies import get_document_service
|
||
from app.document_service import DocumentNotFoundError
|
||
|
||
|
||
class FakeDocumentService:
|
||
def __init__(self):
|
||
self.saved = []
|
||
self.deleted = []
|
||
self.uploaded = []
|
||
|
||
def list_documents(self, path):
|
||
return {
|
||
"folders": ["产品文档", "团队规范"],
|
||
"files": [
|
||
{"key": "架构设计.md", "name": "架构设计.md", "size": 512, "modifiedAt": "2026-08-01T10:00:00Z"},
|
||
],
|
||
}
|
||
|
||
def get_document(self, key):
|
||
if key == "不存在/文档.md":
|
||
raise DocumentNotFoundError(key)
|
||
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
||
|
||
def save_document(self, key, content, content_type):
|
||
self.saved.append((key, content, content_type))
|
||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||
|
||
def delete_document(self, key):
|
||
self.deleted.append(key)
|
||
|
||
def upload_document(self, key, data, content_type):
|
||
self.uploaded.append((key, data, content_type))
|
||
return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)}
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
fake_service = FakeDocumentService()
|
||
app.dependency_overrides[get_document_service] = lambda: fake_service
|
||
with TestClient(app) as test_client:
|
||
test_client.fake_service = fake_service
|
||
yield test_client
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_list_documents_returns_folders_and_files(client):
|
||
response = client.get("/api/documents", params={"path": "产品文档"})
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert body["folders"] == ["产品文档", "团队规范"]
|
||
assert body["files"][0]["key"] == "架构设计.md"
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers'`
|
||
|
||
- [ ] **Step 3: Create `backend/app/dependencies.py`, `backend/app/routers/__init__.py`, and write `documents.py` with `GET /api/documents`**
|
||
|
||
```python
|
||
# backend/app/dependencies.py
|
||
from functools import lru_cache
|
||
|
||
import meilisearch
|
||
from minio import Minio
|
||
|
||
from app.config import settings
|
||
from app.document_service import DocumentService
|
||
from app.minio_client import MinioClient
|
||
from app.search_client import SearchClient
|
||
|
||
|
||
@lru_cache
|
||
def get_document_service() -> DocumentService:
|
||
minio_sdk = 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)
|
||
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)
|
||
```
|
||
|
||
```python
|
||
# backend/app/routers/__init__.py
|
||
```
|
||
|
||
```python
|
||
# backend/app/routers/documents.py
|
||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||
from pydantic import BaseModel
|
||
|
||
from app.dependencies import get_document_service
|
||
from app.document_service import DocumentNotFoundError, DocumentService
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class SaveDocumentRequest(BaseModel):
|
||
content: str
|
||
contentType: str
|
||
|
||
|
||
@router.get("/api/documents")
|
||
def list_documents(path: str = "", service: DocumentService = Depends(get_document_service)):
|
||
return service.list_documents(path)
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing tests for `GET /api/documents/{key}` (found and 404)**
|
||
|
||
```python
|
||
def test_get_document_returns_content(client):
|
||
response = client.get("/api/documents/产品文档/架构设计.md")
|
||
assert response.status_code == 200
|
||
assert response.json()["contentType"] == "text/markdown"
|
||
|
||
|
||
def test_get_document_returns_404_for_missing_key(client):
|
||
response = client.get("/api/documents/不存在/文档.md")
|
||
assert response.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v`
|
||
Expected: FAIL with `404 Not Found` (route not registered)
|
||
|
||
- [ ] **Step 7: Add `GET /api/documents/{key:path}`**
|
||
|
||
```python
|
||
# backend/app/routers/documents.py (add below list_documents)
|
||
@router.get("/api/documents/{key:path}")
|
||
def get_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||
try:
|
||
return service.get_document(key)
|
||
except DocumentNotFoundError:
|
||
raise HTTPException(status_code=404, detail="Document not found")
|
||
```
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing test for `PUT /api/documents/{key}`**
|
||
|
||
```python
|
||
def test_put_document_saves_content(client):
|
||
response = client.put(
|
||
"/api/documents/产品文档/架构设计.md",
|
||
json={"content": "# 更新后的内容", "contentType": "text/markdown"},
|
||
)
|
||
assert response.status_code == 200
|
||
assert response.json()["key"] == "产品文档/架构设计.md"
|
||
assert client.fake_service.saved[0][0] == "产品文档/架构设计.md"
|
||
assert client.fake_service.saved[0][1] == "# 更新后的内容".encode("utf-8")
|
||
```
|
||
|
||
- [ ] **Step 10: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v`
|
||
Expected: FAIL with `405 Method Not Allowed`
|
||
|
||
- [ ] **Step 11: Add `PUT /api/documents/{key:path}`**
|
||
|
||
```python
|
||
# backend/app/routers/documents.py (add below get_document)
|
||
@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)
|
||
```
|
||
|
||
- [ ] **Step 12: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 13: Write the failing test for `DELETE /api/documents/{key}`**
|
||
|
||
```python
|
||
def test_delete_document_returns_204(client):
|
||
response = client.delete("/api/documents/产品文档/架构设计.md")
|
||
assert response.status_code == 204
|
||
assert response.content == b""
|
||
assert client.fake_service.deleted == ["产品文档/架构设计.md"]
|
||
```
|
||
|
||
- [ ] **Step 14: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v`
|
||
Expected: FAIL with `405 Method Not Allowed`
|
||
|
||
- [ ] **Step 15: Add `DELETE /api/documents/{key:path}`**
|
||
|
||
```python
|
||
# backend/app/routers/documents.py (add below save_document)
|
||
@router.delete("/api/documents/{key:path}", status_code=204)
|
||
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||
service.delete_document(key)
|
||
return None
|
||
```
|
||
|
||
- [ ] **Step 16: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 17: Write the failing test for `POST /api/upload`**
|
||
|
||
```python
|
||
def test_upload_document_returns_201_with_metadata(client):
|
||
response = client.post(
|
||
"/api/upload",
|
||
data={"path": "产品文档"},
|
||
files={"file": ("新文档.md", b"# new content", "text/markdown")},
|
||
)
|
||
assert response.status_code == 201
|
||
body = response.json()
|
||
assert body["key"] == "产品文档/新文档.md"
|
||
assert body["name"] == "新文档.md"
|
||
assert body["size"] == len(b"# new content")
|
||
```
|
||
|
||
- [ ] **Step 18: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py::test_upload_document_returns_201_with_metadata -v`
|
||
Expected: FAIL with `404 Not Found`
|
||
|
||
- [ ] **Step 19: Add `POST /api/upload`**
|
||
|
||
```python
|
||
# backend/app/routers/documents.py (add below delete_document)
|
||
@router.post("/api/upload", status_code=201)
|
||
async def upload_document(
|
||
file: UploadFile = File(...),
|
||
path: str = Form(...),
|
||
service: DocumentService = Depends(get_document_service),
|
||
):
|
||
data = await file.read()
|
||
key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename
|
||
content_type = file.content_type or "application/octet-stream"
|
||
return service.upload_document(key, data, content_type)
|
||
```
|
||
|
||
- [ ] **Step 20: Run full test file to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_documents.py -v`
|
||
Expected: PASS (all 6 tests) — note: at this point `app.main` does not yet include `documents.router`, so these tests will actually fail with 404s until Task 8 wires the router into `app`. Proceed to Task 8 before considering this task fully green end-to-end; the route functions themselves are complete and unit-correct.
|
||
|
||
- [ ] **Step 21: Commit**
|
||
|
||
```bash
|
||
git add backend/app/dependencies.py backend/app/routers/__init__.py backend/app/routers/documents.py backend/tests/test_routers_documents.py
|
||
git commit -m "feat: add documents router for list/get/save/delete/upload"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Search Router (`routers/search.py`)
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/dependencies.py`
|
||
- Create: `backend/app/routers/search.py`
|
||
- Test: `backend/tests/test_routers_search.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1), `app.dependencies.get_search_client` (Task 6, already defined)
|
||
- Produces: `router` (APIRouter with `GET /api/search`) — consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`.
|
||
|
||
- [ ] **Step 1: Write the failing test for `GET /api/search` with matches**
|
||
|
||
```python
|
||
# backend/tests/test_routers_search.py
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
from app.dependencies import get_search_client
|
||
|
||
|
||
class FakeSearchClient:
|
||
def __init__(self):
|
||
self.queries = []
|
||
|
||
def search(self, query):
|
||
self.queries.append(query)
|
||
if query == "检索":
|
||
return [
|
||
{
|
||
"key": "产品文档/架构设计.md",
|
||
"title": "架构设计.md",
|
||
"path": "产品文档",
|
||
"snippet": "支持全文<em>检索</em>功能",
|
||
}
|
||
]
|
||
return []
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
fake_client = FakeSearchClient()
|
||
app.dependency_overrides[get_search_client] = lambda: fake_client
|
||
with TestClient(app) as test_client:
|
||
test_client.fake_client = fake_client
|
||
yield test_client
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_search_returns_matching_results(client):
|
||
response = client.get("/api/search", params={"q": "检索"})
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert body["results"][0]["key"] == "产品文档/架构设计.md"
|
||
assert "<em>检索</em>" in body["results"][0]["snippet"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers.search'`
|
||
|
||
- [ ] **Step 3: Write `search.py` with `GET /api/search`, reusing `get_search_client` from `dependencies.py`**
|
||
|
||
```python
|
||
# backend/app/routers/search.py
|
||
from fastapi import APIRouter, Depends
|
||
|
||
from app.dependencies import get_search_client
|
||
from app.search_client import SearchClient
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/api/search")
|
||
def search(q: str = "", client: SearchClient = Depends(get_search_client)):
|
||
return {"results": client.search(q)}
|
||
```
|
||
|
||
Note: `get_search_client` was already added to `backend/app/dependencies.py` in Task 6's Step 3. This task only adds the router that consumes it.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v`
|
||
Expected: PASS — note: this test will actually return 404 until Task 8 wires `search.router` into `app.main`; the route logic itself is complete at this point.
|
||
|
||
- [ ] **Step 5: Write the failing test for `GET /api/search` with no matches**
|
||
|
||
```python
|
||
def test_search_returns_empty_results_for_no_match(client):
|
||
response = client.get("/api/search", params={"q": "不存在的关键词"})
|
||
assert response.status_code == 200
|
||
assert response.json() == {"results": []}
|
||
```
|
||
|
||
- [ ] **Step 6: Run test (already satisfied by Step 3's implementation once wired in Task 8)**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_empty_results_for_no_match -v`
|
||
Expected: PASS after Task 8 wires the router
|
||
|
||
- [ ] **Step 7: Write the failing test asserting the query string is forwarded**
|
||
|
||
```python
|
||
def test_search_passes_query_string_to_search_client(client):
|
||
client.get("/api/search", params={"q": "MinIO"})
|
||
assert client.fake_client.queries == ["MinIO"]
|
||
```
|
||
|
||
- [ ] **Step 8: Run test (already satisfied by Step 3's implementation once wired in Task 8)**
|
||
|
||
Run: `cd backend && pytest tests/test_routers_search.py::test_search_passes_query_string_to_search_client -v`
|
||
Expected: PASS after Task 8 wires the router
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add backend/app/routers/search.py backend/tests/test_routers_search.py
|
||
git commit -m "feat: add search route forwarding queries to SearchClient"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Wire Routers into `main.py` and End-to-End Smoke Test
|
||
|
||
**Files:**
|
||
- Edit: `backend/app/main.py`
|
||
- Edit: `backend/tests/test_main.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `documents.router` (Task 6); `search.router` (Task 7); `app.dependencies.get_document_service`, `app.dependencies.get_search_client` (Task 6); `DocumentNotFoundError` (Task 5)
|
||
- Produces: fully wired `app.main.app` — the production FastAPI entrypoint served by `uvicorn app.main:app` (per `Dockerfile` from Task 1) and the object the frontend's API client (built separately) will call against.
|
||
|
||
- [ ] **Step 1: Write the failing end-to-end smoke test**
|
||
|
||
```python
|
||
# backend/tests/test_main.py (append to existing file from Task 1)
|
||
from app.document_service import DocumentNotFoundError
|
||
from app.dependencies import get_document_service, get_search_client
|
||
|
||
|
||
class FakeDocumentService:
|
||
def __init__(self):
|
||
self.storage = {}
|
||
|
||
def list_documents(self, path):
|
||
prefix = path if not path or path.endswith("/") else f"{path}/"
|
||
files = [
|
||
{"key": key, "name": key[len(prefix):], "size": len(data), "modifiedAt": "2026-08-01T10:00:00Z"}
|
||
for key, data in self.storage.items()
|
||
if key.startswith(prefix)
|
||
]
|
||
return {"folders": [], "files": files}
|
||
|
||
def get_document(self, key):
|
||
if key not in self.storage:
|
||
raise DocumentNotFoundError(key)
|
||
return {"key": key, "content": self.storage[key].decode("utf-8"), "contentType": "text/markdown"}
|
||
|
||
def save_document(self, key, content, content_type):
|
||
self.storage[key] = content
|
||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||
|
||
def delete_document(self, key):
|
||
self.storage.pop(key, None)
|
||
|
||
def upload_document(self, key, data, content_type):
|
||
self.storage[key] = data
|
||
return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)}
|
||
|
||
|
||
class FakeSearchClient:
|
||
def search(self, query):
|
||
return [
|
||
{
|
||
"key": "产品文档/架构设计.md",
|
||
"title": "架构设计.md",
|
||
"path": "产品文档",
|
||
"snippet": "全文<em>检索</em>示例",
|
||
}
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def e2e_client():
|
||
fake_service = FakeDocumentService()
|
||
fake_search = FakeSearchClient()
|
||
app.dependency_overrides[get_document_service] = lambda: fake_service
|
||
app.dependency_overrides[get_search_client] = lambda: fake_search
|
||
with TestClient(app) as test_client:
|
||
yield test_client
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_end_to_end_upload_get_list_search_delete_flow(e2e_client):
|
||
upload_response = e2e_client.post(
|
||
"/api/upload",
|
||
data={"path": "产品文档"},
|
||
files={"file": ("架构设计.md", "# 架构设计\n\nDocHub 支持全文检索。".encode("utf-8"), "text/markdown")},
|
||
)
|
||
assert upload_response.status_code == 201
|
||
key = upload_response.json()["key"]
|
||
assert key == "产品文档/架构设计.md"
|
||
|
||
get_response = e2e_client.get(f"/api/documents/{key}")
|
||
assert get_response.status_code == 200
|
||
assert "架构设计" in get_response.json()["content"]
|
||
|
||
list_response = e2e_client.get("/api/documents", params={"path": "产品文档"})
|
||
assert list_response.status_code == 200
|
||
assert any(f["key"] == key for f in list_response.json()["files"])
|
||
|
||
search_response = e2e_client.get("/api/search", params={"q": "检索"})
|
||
assert search_response.status_code == 200
|
||
assert search_response.json()["results"][0]["key"] == "产品文档/架构设计.md"
|
||
|
||
delete_response = e2e_client.delete(f"/api/documents/{key}")
|
||
assert delete_response.status_code == 204
|
||
|
||
missing_response = e2e_client.get(f"/api/documents/{key}")
|
||
assert missing_response.status_code == 404
|
||
```
|
||
|
||
Also add `import pytest` at the top of `backend/tests/test_main.py` if not already present from Task 1.
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && pytest tests/test_main.py::test_end_to_end_upload_get_list_search_delete_flow -v`
|
||
Expected: FAIL with `404 Not Found` on the first `/api/upload` call, since `documents.router` and `search.router` are not yet included in `app`
|
||
|
||
- [ ] **Step 3: Update `main.py` to include both routers**
|
||
|
||
```python
|
||
# backend/app/main.py
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
from app.config import settings
|
||
from app.routers import documents, search
|
||
|
||
app = FastAPI(title="DocHub API")
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
app.include_router(documents.router)
|
||
app.include_router(search.router)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the full backend test suite to verify everything passes**
|
||
|
||
Run: `cd backend && pytest tests/ -v`
|
||
Expected: PASS (all tests across `test_main.py`, `test_text_extract.py`, `test_minio_client.py`, `test_search_client.py`, `test_document_service.py`, `test_routers_documents.py`, `test_routers_search.py`)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/main.py backend/tests/test_main.py
|
||
git commit -m "feat: wire documents and search routers into FastAPI app with e2e smoke test"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Project scaffolding + minimal App shell
|
||
|
||
**Files:**
|
||
- Create: `frontend/package.json`
|
||
- Create: `frontend/vite.config.ts`
|
||
- Create: `frontend/tsconfig.json`
|
||
- Create: `frontend/index.html`
|
||
- Create: `frontend/tests/setup.ts`
|
||
- Create: `frontend/src/main.tsx`
|
||
- Create: `frontend/src/App.tsx`
|
||
- Create: `frontend/Dockerfile`
|
||
- Create: `frontend/nginx.conf`
|
||
- Test: `frontend/tests/App.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: none
|
||
- Produces: `export default function App(): JSX.Element` — imported by `frontend/src/main.tsx` and extended by Task 15's integration wiring.
|
||
|
||
- [ ] **Step 1: Create the project configuration files**
|
||
|
||
`frontend/package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "dochub-frontend",
|
||
"private": true,
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc -b && vite build",
|
||
"preview": "vite preview",
|
||
"test": "vitest run"
|
||
},
|
||
"dependencies": {
|
||
"@monaco-editor/react": "4.6.0",
|
||
"react": "18.3.1",
|
||
"react-dom": "18.3.1"
|
||
},
|
||
"devDependencies": {
|
||
"@testing-library/jest-dom": "6.4.8",
|
||
"@testing-library/react": "16.0.0",
|
||
"@testing-library/user-event": "14.5.2",
|
||
"@types/react": "18.3.3",
|
||
"@types/react-dom": "18.3.0",
|
||
"@vitejs/plugin-react": "4.3.1",
|
||
"jsdom": "24.1.1",
|
||
"typescript": "5.5.4",
|
||
"vite": "5.4.1",
|
||
"vitest": "2.0.5"
|
||
}
|
||
}
|
||
```
|
||
|
||
`frontend/tsconfig.json`:
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"noEmit": true,
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||
},
|
||
"include": ["src", "tests", "vite.config.ts"]
|
||
}
|
||
```
|
||
|
||
`frontend/vite.config.ts`:
|
||
|
||
```ts
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
globals: true,
|
||
environment: 'jsdom',
|
||
setupFiles: ['./tests/setup.ts'],
|
||
},
|
||
});
|
||
```
|
||
|
||
`frontend/index.html`:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>DocHub</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`frontend/tests/setup.ts`:
|
||
|
||
```ts
|
||
import '@testing-library/jest-dom/vitest';
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`frontend/tests/App.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react';
|
||
import { test, expect } from 'vitest';
|
||
import App from '../src/App';
|
||
|
||
test('renders the DocHub heading', () => {
|
||
render(<App />);
|
||
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `npm install && npm run test -- App.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../src/App' or its corresponding type declarations."
|
||
|
||
- [ ] **Step 4: Write minimal implementation**
|
||
|
||
`frontend/src/main.tsx`:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import ReactDOM from 'react-dom/client';
|
||
import App from './App';
|
||
|
||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
`frontend/src/App.tsx`:
|
||
|
||
```tsx
|
||
export default function App() {
|
||
return (
|
||
<div className="app">
|
||
<h1>DocHub</h1>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- App.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Add Docker build/serve files**
|
||
|
||
`frontend/Dockerfile`:
|
||
|
||
```dockerfile
|
||
# ---- build stage ----
|
||
FROM node:20.16-alpine AS build
|
||
WORKDIR /app
|
||
COPY package.json package-lock.json* ./
|
||
RUN npm install
|
||
COPY . .
|
||
ARG VITE_API_BASE_URL
|
||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||
RUN npm run build
|
||
|
||
# ---- serve stage ----
|
||
FROM nginx:1.27-alpine AS serve
|
||
COPY --from=build /app/dist /usr/share/nginx/html
|
||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||
EXPOSE 80
|
||
CMD ["nginx", "-g", "daemon off;"]
|
||
```
|
||
|
||
`frontend/nginx.conf`:
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name _;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
location /api/ {
|
||
proxy_pass http://backend:8000/api/;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/package.json frontend/vite.config.ts frontend/tsconfig.json frontend/index.html frontend/tests/setup.ts frontend/src/main.tsx frontend/src/App.tsx frontend/tests/App.test.tsx frontend/Dockerfile frontend/nginx.conf
|
||
git commit -m "feat: scaffold frontend project with Vite, Vitest and Docker build"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: api/client.ts
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/client.ts`
|
||
- Test: `frontend/tests/api/client.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: global `fetch`, `import.meta.env.VITE_API_BASE_URL`
|
||
- Produces: `DocumentFile`, `DocumentListResponse`, `DocumentContent`, `SearchResult`, `SearchResponse`, `SaveDocumentResponse`, `UploadDocumentResponse` interfaces; `listDocuments(path: string): Promise<DocumentListResponse>`, `getDocument(key: string): Promise<DocumentContent>`, `saveDocument(key: string, content: string, contentType: string): Promise<SaveDocumentResponse>`, `deleteDocument(key: string): Promise<void>`, `uploadDocument(file: File, path: string): Promise<UploadDocumentResponse>`, `search(query: string): Promise<SearchResponse>` — consumed by `FileExplorer`, `DocumentEditor`, `SearchBar`, `SearchResults`, `UploadButton`, `App`.
|
||
|
||
- [ ] **Step 1: Write the failing tests for read operations**
|
||
|
||
`frontend/tests/api/client.test.ts`:
|
||
|
||
```ts
|
||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||
import { listDocuments, getDocument } 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);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: FAIL with "Cannot find module '../../src/api/client'"
|
||
|
||
- [ ] **Step 3: Write minimal implementation for read operations**
|
||
|
||
`frontend/src/api/client.ts`:
|
||
|
||
```ts
|
||
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<DocumentListResponse> {
|
||
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<DocumentContent> {
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing tests for write operations**
|
||
|
||
Append to `frontend/tests/api/client.test.ts`:
|
||
|
||
```ts
|
||
import { saveDocument, deleteDocument } from '../../src/api/client';
|
||
|
||
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' }
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: FAIL with "saveDocument is not a function" / "deleteDocument is not a function"
|
||
|
||
- [ ] **Step 7: Write minimal implementation for write operations**
|
||
|
||
Append to `frontend/src/api/client.ts`:
|
||
|
||
```ts
|
||
export interface SaveDocumentResponse {
|
||
key: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export async function saveDocument(
|
||
key: string,
|
||
content: string,
|
||
contentType: string
|
||
): Promise<SaveDocumentResponse> {
|
||
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<void> {
|
||
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}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Write the failing test for upload**
|
||
|
||
Append to `frontend/tests/api/client.test.ts`:
|
||
|
||
```ts
|
||
import { uploadDocument } from '../../src/api/client';
|
||
|
||
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<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(result).toEqual(mockResponse);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: FAIL with "uploadDocument is not a function"
|
||
|
||
- [ ] **Step 11: Write minimal implementation for upload**
|
||
|
||
Append to `frontend/src/api/client.ts`:
|
||
|
||
```ts
|
||
export interface UploadDocumentResponse {
|
||
key: string;
|
||
name: string;
|
||
size: number;
|
||
}
|
||
|
||
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 13: Write the failing test for search**
|
||
|
||
Append to `frontend/tests/api/client.test.ts`:
|
||
|
||
```ts
|
||
import { search } from '../../src/api/client';
|
||
|
||
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>...' },
|
||
],
|
||
};
|
||
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);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 14: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: FAIL with "search is not a function"
|
||
|
||
- [ ] **Step 15: Write minimal implementation for search**
|
||
|
||
Append to `frontend/src/api/client.ts`:
|
||
|
||
```ts
|
||
export interface SearchResult {
|
||
key: string;
|
||
title: string;
|
||
path: string;
|
||
snippet: string;
|
||
}
|
||
|
||
export interface SearchResponse {
|
||
results: SearchResult[];
|
||
}
|
||
|
||
export async function search(query: string): Promise<SearchResponse> {
|
||
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();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 16: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- client.test.ts`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 17: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/client.ts frontend/tests/api/client.test.ts
|
||
git commit -m "feat: add typed API client for documents, upload and search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: FileExplorer.tsx
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/FileExplorer.tsx`
|
||
- Test: `frontend/tests/components/FileExplorer.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `listDocuments(path: string): Promise<DocumentListResponse>`, `DocumentFile` from `../api/client`
|
||
- Produces: `export default function FileExplorer({ onSelectFile, selectedKey }: { onSelectFile: (key: string) => void; selectedKey?: string | null }): JSX.Element` — consumed by `App.tsx` in Task 15.
|
||
|
||
- [ ] **Step 1: Write the failing tests for rendering and file selection**
|
||
|
||
`frontend/tests/components/FileExplorer.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import { vi, test, expect, beforeEach } from 'vitest';
|
||
import FileExplorer from '../../src/components/FileExplorer';
|
||
import { listDocuments } from '../../src/api/client';
|
||
|
||
vi.mock('../../src/api/client');
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(listDocuments).mockReset();
|
||
});
|
||
|
||
test('renders folders and files returned by listDocuments for the root path', async () => {
|
||
vi.mocked(listDocuments).mockResolvedValue({
|
||
folders: ['产品文档'],
|
||
files: [{ key: '需求说明.md', name: '需求说明.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||
});
|
||
|
||
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
|
||
|
||
expect(await screen.findByText('产品文档')).toBeInTheDocument();
|
||
expect(await screen.findByText('需求说明.md')).toBeInTheDocument();
|
||
expect(listDocuments).toHaveBeenCalledWith('');
|
||
});
|
||
|
||
test('clicking a file calls onSelectFile with its key', async () => {
|
||
vi.mocked(listDocuments).mockResolvedValue({
|
||
folders: [],
|
||
files: [{ key: '架构设计.md', name: '架构设计.md', size: 200, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||
});
|
||
const onSelectFile = vi.fn();
|
||
|
||
render(<FileExplorer onSelectFile={onSelectFile} selectedKey={null} />);
|
||
|
||
const fileRow = await screen.findByText('架构设计.md');
|
||
fireEvent.click(fileRow);
|
||
|
||
expect(onSelectFile).toHaveBeenCalledWith('架构设计.md');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- FileExplorer.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../../src/components/FileExplorer'"
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
`frontend/src/components/FileExplorer.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { listDocuments, type DocumentFile } from '../api/client';
|
||
|
||
interface FileExplorerProps {
|
||
onSelectFile: (key: string) => void;
|
||
selectedKey?: string | null;
|
||
}
|
||
|
||
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
|
||
const [folders, setFolders] = useState<string[]>([]);
|
||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
listDocuments('')
|
||
.then((data) => {
|
||
setFolders(data.folders);
|
||
setFiles(data.files);
|
||
})
|
||
.catch(() => setError('无法加载文档目录'));
|
||
}, []);
|
||
|
||
if (error) {
|
||
return <div data-testid="file-explorer-error">{error}</div>;
|
||
}
|
||
|
||
return (
|
||
<div data-testid="file-explorer">
|
||
{folders.map((folder) => (
|
||
<div key={folder} data-testid={`folder-${folder}`} className="folder">
|
||
{folder}
|
||
</div>
|
||
))}
|
||
{files.map((file) => (
|
||
<div
|
||
key={file.key}
|
||
data-testid={`file-${file.key}`}
|
||
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
|
||
onClick={() => onSelectFile(file.key)}
|
||
>
|
||
{file.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- FileExplorer.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing tests for folder expansion and error state**
|
||
|
||
Append to `frontend/tests/components/FileExplorer.test.tsx`:
|
||
|
||
```tsx
|
||
test('clicking a folder loads and shows its nested contents', async () => {
|
||
vi.mocked(listDocuments).mockImplementation((path: string) => {
|
||
if (path === '') {
|
||
return Promise.resolve({ folders: ['产品文档'], files: [] });
|
||
}
|
||
if (path === '产品文档') {
|
||
return Promise.resolve({
|
||
folders: [],
|
||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 300, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||
});
|
||
}
|
||
return Promise.resolve({ folders: [], files: [] });
|
||
});
|
||
|
||
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
|
||
|
||
const folderRow = await screen.findByText('产品文档');
|
||
fireEvent.click(folderRow);
|
||
|
||
expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
|
||
expect(listDocuments).toHaveBeenCalledWith('产品文档');
|
||
});
|
||
|
||
test('shows an error message when listDocuments rejects', async () => {
|
||
vi.mocked(listDocuments).mockRejectedValue(new Error('network error'));
|
||
|
||
render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);
|
||
|
||
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- FileExplorer.test.tsx`
|
||
Expected: FAIL with "Unable to find an element with the text: 架构设计.md" (folder click is not wired to load nested contents yet)
|
||
|
||
- [ ] **Step 7: Write the full implementation with lazy-loaded nested folders**
|
||
|
||
Replace the contents of `frontend/src/components/FileExplorer.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { listDocuments, type DocumentFile } from '../api/client';
|
||
|
||
interface FileExplorerProps {
|
||
onSelectFile: (key: string) => void;
|
||
selectedKey?: string | null;
|
||
}
|
||
|
||
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
|
||
const [folders, setFolders] = useState<string[]>([]);
|
||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
listDocuments('')
|
||
.then((data) => {
|
||
setFolders(data.folders);
|
||
setFiles(data.files);
|
||
})
|
||
.catch(() => setError('无法加载文档目录'));
|
||
}, []);
|
||
|
||
if (error) {
|
||
return <div data-testid="file-explorer-error">{error}</div>;
|
||
}
|
||
|
||
return (
|
||
<div data-testid="file-explorer">
|
||
{folders.map((folder) => (
|
||
<FolderNode key={folder} name={folder} path={folder} onSelectFile={onSelectFile} selectedKey={selectedKey} />
|
||
))}
|
||
{files.map((file) => (
|
||
<FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FileRow({
|
||
file,
|
||
onSelectFile,
|
||
selectedKey,
|
||
}: {
|
||
file: DocumentFile;
|
||
onSelectFile: (key: string) => void;
|
||
selectedKey?: string | null;
|
||
}) {
|
||
return (
|
||
<div
|
||
data-testid={`file-${file.key}`}
|
||
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
|
||
onClick={() => onSelectFile(file.key)}
|
||
>
|
||
{file.name}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FolderNode({
|
||
name,
|
||
path,
|
||
onSelectFile,
|
||
selectedKey,
|
||
}: {
|
||
name: string;
|
||
path: string;
|
||
onSelectFile: (key: string) => void;
|
||
selectedKey?: string | null;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [folders, setFolders] = useState<string[]>([]);
|
||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||
|
||
function toggle() {
|
||
if (!expanded && !loaded) {
|
||
listDocuments(path).then((data) => {
|
||
setFolders(data.folders);
|
||
setFiles(data.files);
|
||
setLoaded(true);
|
||
});
|
||
}
|
||
setExpanded((prev) => !prev);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
|
||
{name}
|
||
</div>
|
||
{expanded && (
|
||
<div className="tree-children">
|
||
{folders.map((folder) => (
|
||
<FolderNode
|
||
key={`${path}/${folder}`}
|
||
name={folder}
|
||
path={`${path}/${folder}`}
|
||
onSelectFile={onSelectFile}
|
||
selectedKey={selectedKey}
|
||
/>
|
||
))}
|
||
{files.map((file) => (
|
||
<FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- FileExplorer.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/FileExplorer.tsx frontend/tests/components/FileExplorer.test.tsx
|
||
git commit -m "feat: add FileExplorer with lazy-loaded folder tree"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: DocumentEditor.tsx
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/DocumentEditor.tsx`
|
||
- Test: `frontend/tests/components/DocumentEditor.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getDocument(key: string): Promise<DocumentContent>`, `saveDocument(key: string, content: string, contentType: string): Promise<SaveDocumentResponse>` from `../api/client`; `Editor` default export from `@monaco-editor/react`
|
||
- Produces: `export default function DocumentEditor({ documentKey }: { documentKey: string }): JSX.Element` — consumed by `App.tsx` in Task 15.
|
||
|
||
- [ ] **Step 1: Write the failing test for loading and displaying content**
|
||
|
||
`frontend/tests/components/DocumentEditor.test.tsx`:
|
||
|
||
```tsx
|
||
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';
|
||
|
||
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();
|
||
});
|
||
|
||
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" />);
|
||
|
||
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('已归档');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- DocumentEditor.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../../src/components/DocumentEditor'"
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
`frontend/src/components/DocumentEditor.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import Editor from '@monaco-editor/react';
|
||
import { getDocument } from '../api/client';
|
||
|
||
interface DocumentEditorProps {
|
||
documentKey: string;
|
||
}
|
||
|
||
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||
const [content, setContent] = useState('');
|
||
const [contentType, setContentType] = useState('text/markdown');
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
setError(null);
|
||
getDocument(documentKey)
|
||
.then((doc) => {
|
||
setContent(doc.content);
|
||
setContentType(doc.contentType);
|
||
setLoading(false);
|
||
})
|
||
.catch(() => {
|
||
setError('无法加载文档');
|
||
setLoading(false);
|
||
});
|
||
}, [documentKey]);
|
||
|
||
if (loading) {
|
||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return <div data-testid="document-editor-error">{error}</div>;
|
||
}
|
||
|
||
return (
|
||
<div data-testid="document-editor">
|
||
<span data-testid="save-status">已归档</span>
|
||
<Editor value={content} onChange={() => {}} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- DocumentEditor.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing test for dirty tracking and save**
|
||
|
||
Append to `frontend/tests/components/DocumentEditor.test.tsx`:
|
||
|
||
```tsx
|
||
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" />);
|
||
|
||
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('已归档');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- DocumentEditor.test.tsx`
|
||
Expected: FAIL with "Unable to find an accessible element with the role \"button\" and name \"保存\""
|
||
|
||
- [ ] **Step 7: Write the full implementation with dirty state and save**
|
||
|
||
Replace the contents of `frontend/src/components/DocumentEditor.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import Editor from '@monaco-editor/react';
|
||
import { getDocument, saveDocument } from '../api/client';
|
||
|
||
interface DocumentEditorProps {
|
||
documentKey: string;
|
||
}
|
||
|
||
export default function DocumentEditor({ documentKey }: 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<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
setError(null);
|
||
getDocument(documentKey)
|
||
.then((doc) => {
|
||
setContent(doc.content);
|
||
setOriginalContent(doc.content);
|
||
setContentType(doc.contentType);
|
||
setLoading(false);
|
||
})
|
||
.catch(() => {
|
||
setError('无法加载文档');
|
||
setLoading(false);
|
||
});
|
||
}, [documentKey]);
|
||
|
||
const isDirty = content !== originalContent;
|
||
|
||
function handleChange(value: string | undefined) {
|
||
setContent(value ?? '');
|
||
}
|
||
|
||
function handleSave() {
|
||
setSaving(true);
|
||
saveDocument(documentKey, content, contentType)
|
||
.then(() => {
|
||
setOriginalContent(content);
|
||
setSaving(false);
|
||
})
|
||
.catch(() => {
|
||
setError('保存失败');
|
||
setSaving(false);
|
||
});
|
||
}
|
||
|
||
if (loading) {
|
||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return <div data-testid="document-editor-error">{error}</div>;
|
||
}
|
||
|
||
return (
|
||
<div data-testid="document-editor">
|
||
<div className="doc-header">
|
||
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
|
||
<button onClick={handleSave} disabled={!isDirty || saving}>
|
||
{saving ? '保存中…' : '保存'}
|
||
</button>
|
||
</div>
|
||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- DocumentEditor.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/DocumentEditor.tsx frontend/tests/components/DocumentEditor.test.tsx
|
||
git commit -m "feat: add DocumentEditor with dirty tracking and save"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: SearchBar.tsx + SearchResults.tsx
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/SearchBar.tsx`
|
||
- Create: `frontend/src/components/SearchResults.tsx`
|
||
- Test: `frontend/tests/components/SearchBar.test.tsx`
|
||
- Test: `frontend/tests/components/SearchResults.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `search(query: string): Promise<SearchResponse>`, `SearchResult` from `../api/client`
|
||
- Produces: `export default function SearchBar({ onQueryChange, onResults, debounceMs }: { onQueryChange: (query: string) => void; onResults: (results: SearchResult[]) => void; debounceMs?: number }): JSX.Element` and `export default function SearchResults({ results, query, onJumpToResult }: { results: SearchResult[]; query: string; onJumpToResult: (key: string) => void }): JSX.Element` — both consumed by `App.tsx` in Task 15.
|
||
|
||
- [ ] **Step 1: Write the failing tests for SearchBar debounce behavior**
|
||
|
||
`frontend/tests/components/SearchBar.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
||
import SearchBar from '../../src/components/SearchBar';
|
||
import { search } from '../../src/api/client';
|
||
|
||
vi.mock('../../src/api/client');
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(search).mockReset();
|
||
vi.useFakeTimers();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
test('debounces input for 300ms before calling search', async () => {
|
||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||
const onQueryChange = vi.fn();
|
||
const onResults = vi.fn();
|
||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||
|
||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||
|
||
const input = screen.getByRole('textbox');
|
||
await user.type(input, '检索');
|
||
|
||
expect(onQueryChange).toHaveBeenLastCalledWith('检索');
|
||
expect(search).not.toHaveBeenCalled();
|
||
|
||
vi.advanceTimersByTime(300);
|
||
|
||
expect(search).toHaveBeenCalledWith('检索');
|
||
});
|
||
|
||
test('clearing the input immediately reports empty results without calling search', async () => {
|
||
const onQueryChange = vi.fn();
|
||
const onResults = vi.fn();
|
||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||
|
||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||
|
||
const input = screen.getByRole('textbox');
|
||
await user.type(input, 'x');
|
||
vi.advanceTimersByTime(300);
|
||
await user.clear(input);
|
||
|
||
expect(onResults).toHaveBeenCalledWith([]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- SearchBar.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../../src/components/SearchBar'"
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
`frontend/src/components/SearchBar.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { search, type SearchResult } from '../api/client';
|
||
|
||
interface SearchBarProps {
|
||
onQueryChange: (query: string) => void;
|
||
onResults: (results: SearchResult[]) => void;
|
||
debounceMs?: number;
|
||
}
|
||
|
||
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||
const [value, setValue] = useState('');
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timerRef.current) {
|
||
clearTimeout(timerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||
const nextValue = event.target.value;
|
||
setValue(nextValue);
|
||
onQueryChange(nextValue);
|
||
|
||
if (timerRef.current) {
|
||
clearTimeout(timerRef.current);
|
||
}
|
||
|
||
if (nextValue.trim() === '') {
|
||
onResults([]);
|
||
return;
|
||
}
|
||
|
||
timerRef.current = setTimeout(() => {
|
||
search(nextValue).then((response) => {
|
||
onResults(response.results);
|
||
});
|
||
}, debounceMs);
|
||
}
|
||
|
||
return (
|
||
<input
|
||
type="text"
|
||
placeholder="检索全部文档内容…"
|
||
value={value}
|
||
onChange={handleChange}
|
||
aria-label="搜索文档"
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- SearchBar.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing tests for SearchResults**
|
||
|
||
`frontend/tests/components/SearchResults.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import { test, expect, vi } from 'vitest';
|
||
import SearchResults from '../../src/components/SearchResults';
|
||
|
||
test('renders result count, titles, and highlighted snippets', () => {
|
||
render(
|
||
<SearchResults
|
||
query="检索"
|
||
results={[
|
||
{
|
||
key: '产品文档/架构设计.md',
|
||
title: '架构设计.md',
|
||
path: '产品文档',
|
||
snippet: '检索:Meilisearch,支持 <em>中文全文检索</em>。',
|
||
},
|
||
]}
|
||
onJumpToResult={vi.fn()}
|
||
/>
|
||
);
|
||
|
||
expect(screen.getByTestId('search-results-count')).toHaveTextContent('1 条与「检索」相关的结果');
|
||
expect(screen.getByText('架构设计.md')).toBeInTheDocument();
|
||
expect(document.querySelector('em')).toHaveTextContent('中文全文检索');
|
||
});
|
||
|
||
test('clicking a result calls onJumpToResult with its key', () => {
|
||
const onJumpToResult = vi.fn();
|
||
render(
|
||
<SearchResults
|
||
query="检索"
|
||
results={[{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...' }]}
|
||
onJumpToResult={onJumpToResult}
|
||
/>
|
||
);
|
||
|
||
fireEvent.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
||
|
||
expect(onJumpToResult).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||
});
|
||
|
||
test('shows an empty state when there are no results', () => {
|
||
render(<SearchResults query="xyz" results={[]} onJumpToResult={vi.fn()} />);
|
||
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- SearchResults.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../../src/components/SearchResults'"
|
||
|
||
- [ ] **Step 7: Write minimal implementation**
|
||
|
||
`frontend/src/components/SearchResults.tsx`:
|
||
|
||
```tsx
|
||
import type { SearchResult } from '../api/client';
|
||
|
||
interface SearchResultsProps {
|
||
results: SearchResult[];
|
||
query: string;
|
||
onJumpToResult: (key: string) => void;
|
||
}
|
||
|
||
export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
|
||
if (results.length === 0) {
|
||
return <div data-testid="search-no-results">没有找到匹配的文档</div>;
|
||
}
|
||
|
||
return (
|
||
<div data-testid="search-results">
|
||
<div data-testid="search-results-count">
|
||
{results.length} 条与「{query}」相关的结果
|
||
</div>
|
||
{results.map((result) => (
|
||
<div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
|
||
<div className="result-title">{result.title}</div>
|
||
<div className="result-path">{result.path}</div>
|
||
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- SearchResults.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/SearchBar.tsx frontend/src/components/SearchResults.tsx frontend/tests/components/SearchBar.test.tsx frontend/tests/components/SearchResults.test.tsx
|
||
git commit -m "feat: add debounced SearchBar and highlighted SearchResults"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: UploadButton.tsx
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/UploadButton.tsx`
|
||
- Test: `frontend/tests/components/UploadButton.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `uploadDocument(file: File, path: string): Promise<UploadDocumentResponse>` from `../api/client`
|
||
- Produces: `export default function UploadButton({ path, onUploaded }: { path: string; onUploaded: (result: UploadDocumentResponse) => void }): JSX.Element` — consumed by `App.tsx` in Task 15.
|
||
|
||
- [ ] **Step 1: Write the failing tests for the modal and click-to-select upload**
|
||
|
||
`frontend/tests/components/UploadButton.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { vi, test, expect, beforeEach } from 'vitest';
|
||
import UploadButton from '../../src/components/UploadButton';
|
||
import { uploadDocument } from '../../src/api/client';
|
||
|
||
vi.mock('../../src/api/client');
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(uploadDocument).mockReset();
|
||
});
|
||
|
||
test('opens the upload modal when the button is clicked', async () => {
|
||
const user = userEvent.setup();
|
||
render(<UploadButton path="产品文档" onUploaded={vi.fn()} />);
|
||
|
||
expect(screen.queryByTestId('upload-modal')).not.toBeInTheDocument();
|
||
|
||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||
|
||
expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
|
||
});
|
||
|
||
test('selecting a file through the hidden input uploads it', async () => {
|
||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 });
|
||
const onUploaded = vi.fn();
|
||
const user = userEvent.setup();
|
||
|
||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||
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);
|
||
|
||
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档');
|
||
await waitFor(() => {
|
||
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- UploadButton.test.tsx`
|
||
Expected: FAIL with "Cannot find module '../../src/components/UploadButton'"
|
||
|
||
- [ ] **Step 3: Write minimal implementation (modal + click-to-select only)**
|
||
|
||
`frontend/src/components/UploadButton.tsx`:
|
||
|
||
```tsx
|
||
import { useRef, useState } from 'react';
|
||
import { uploadDocument, type UploadDocumentResponse } from '../api/client';
|
||
|
||
interface UploadButtonProps {
|
||
path: string;
|
||
onUploaded: (result: UploadDocumentResponse) => void;
|
||
}
|
||
|
||
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
function openModal() {
|
||
setIsOpen(true);
|
||
}
|
||
|
||
function closeModal() {
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function uploadFiles(files: FileList | File[]) {
|
||
Array.from(files).forEach((file) => {
|
||
uploadDocument(file, path).then((result) => {
|
||
onUploaded(result);
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||
if (event.target.files) {
|
||
uploadFiles(event.target.files);
|
||
}
|
||
event.target.value = '';
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button onClick={openModal}>上传文档</button>
|
||
{isOpen && (
|
||
<div className="modal-backdrop show" data-testid="upload-modal">
|
||
<div className="modal">
|
||
<div className="dropzone" data-testid="dropzone" onClick={() => fileInputRef.current?.click()}>
|
||
拖拽文件到此处,或点击选择
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
data-testid="upload-file-input"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileInputChange}
|
||
/>
|
||
<button onClick={closeModal}>取消</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- UploadButton.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Write the failing test for drag-and-drop upload**
|
||
|
||
Append to `frontend/tests/components/UploadButton.test.tsx`:
|
||
|
||
```tsx
|
||
import { fireEvent } from '@testing-library/react';
|
||
|
||
test('dropping a file onto the dropzone uploads it', async () => {
|
||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||
const onUploaded = vi.fn();
|
||
const user = userEvent.setup();
|
||
|
||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||
|
||
const file = new File(['内容'], '拖拽文档.md', { type: 'text/markdown' });
|
||
const dropzone = screen.getByTestId('dropzone');
|
||
|
||
fireEvent.drop(dropzone, {
|
||
dataTransfer: { files: [file] },
|
||
});
|
||
|
||
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档');
|
||
await waitFor(() => {
|
||
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- UploadButton.test.tsx`
|
||
Expected: FAIL with "expected \"spy\" to be called with arguments: [ File, '产品文档' ]" (uploadDocument was never called because the dropzone has no `onDrop` handler)
|
||
|
||
- [ ] **Step 7: Write the full implementation with drag-and-drop support**
|
||
|
||
Replace the contents of `frontend/src/components/UploadButton.tsx`:
|
||
|
||
```tsx
|
||
import { useRef, useState } from 'react';
|
||
import { uploadDocument, type UploadDocumentResponse } from '../api/client';
|
||
|
||
interface UploadButtonProps {
|
||
path: string;
|
||
onUploaded: (result: UploadDocumentResponse) => void;
|
||
}
|
||
|
||
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
function openModal() {
|
||
setIsOpen(true);
|
||
}
|
||
|
||
function closeModal() {
|
||
setIsOpen(false);
|
||
setIsDragOver(false);
|
||
}
|
||
|
||
function uploadFiles(files: FileList | File[]) {
|
||
Array.from(files).forEach((file) => {
|
||
uploadDocument(file, path).then((result) => {
|
||
onUploaded(result);
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||
if (event.target.files) {
|
||
uploadFiles(event.target.files);
|
||
}
|
||
event.target.value = '';
|
||
}
|
||
|
||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||
event.preventDefault();
|
||
setIsDragOver(false);
|
||
if (event.dataTransfer.files) {
|
||
uploadFiles(event.dataTransfer.files);
|
||
}
|
||
}
|
||
|
||
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||
event.preventDefault();
|
||
setIsDragOver(true);
|
||
}
|
||
|
||
function handleDragLeave() {
|
||
setIsDragOver(false);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button onClick={openModal}>上传文档</button>
|
||
{isOpen && (
|
||
<div className="modal-backdrop show" data-testid="upload-modal">
|
||
<div className="modal">
|
||
<div
|
||
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
||
data-testid="dropzone"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
>
|
||
拖拽文件到此处,或点击选择
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
data-testid="upload-file-input"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileInputChange}
|
||
/>
|
||
<button onClick={closeModal}>取消</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- UploadButton.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/UploadButton.tsx frontend/tests/components/UploadButton.test.tsx
|
||
git commit -m "feat: add UploadButton with click-to-select and drag-and-drop"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: App.tsx integration wiring
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/App.tsx`
|
||
- Modify: `frontend/tests/App.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `FileExplorer` (Task 11), `DocumentEditor` (Task 12), `SearchBar`, `SearchResults` (Task 13), `UploadButton` (Task 14), `UploadDocumentResponse`/`SearchResult` types from `../api/client` (Task 10)
|
||
- Produces: fully wired `export default function App(): JSX.Element` — the complete frontend entrypoint rendered by `frontend/src/main.tsx`.
|
||
|
||
- [ ] **Step 1: Write the failing integration tests**
|
||
|
||
Append to `frontend/tests/App.test.tsx`:
|
||
|
||
```tsx
|
||
import userEvent from '@testing-library/user-event';
|
||
import { waitFor } from '@testing-library/react';
|
||
import { listDocuments, getDocument, search } 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(listDocuments).mockReset();
|
||
vi.mocked(getDocument).mockReset();
|
||
vi.mocked(search).mockReset();
|
||
});
|
||
|
||
test('shows an empty state and a file tree when no document is selected', async () => {
|
||
vi.mocked(listDocuments).mockResolvedValue({
|
||
folders: [],
|
||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||
});
|
||
|
||
render(<App />);
|
||
|
||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||
expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
|
||
});
|
||
|
||
test('selecting a file from the tree loads it into the editor', async () => {
|
||
vi.mocked(listDocuments).mockResolvedValue({
|
||
folders: [],
|
||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||
});
|
||
vi.mocked(getDocument).mockResolvedValue({
|
||
key: '产品文档/架构设计.md',
|
||
content: '# 架构设计',
|
||
contentType: 'text/markdown',
|
||
});
|
||
const user = userEvent.setup();
|
||
|
||
render(<App />);
|
||
|
||
const fileRow = await screen.findByText('架构设计.md');
|
||
await user.click(fileRow);
|
||
|
||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||
expect(textarea).toHaveValue('# 架构设计');
|
||
});
|
||
|
||
test('searching shows the results overlay, and clicking a result opens the document', async () => {
|
||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||
vi.mocked(search).mockResolvedValue({
|
||
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
||
});
|
||
vi.mocked(getDocument).mockResolvedValue({
|
||
key: '产品文档/架构设计.md',
|
||
content: '# 架构设计',
|
||
contentType: 'text/markdown',
|
||
});
|
||
const user = userEvent.setup();
|
||
|
||
render(<App />);
|
||
|
||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
||
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(screen.queryByTestId('search-results')).not.toBeInTheDocument();
|
||
});
|
||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||
expect(textarea).toHaveValue('# 架构设计');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm run test -- App.test.tsx`
|
||
Expected: FAIL with "Unable to find an element by: [data-testid=\"empty-state\"]" (the scaffolded `App.tsx` from Task 9 only renders a heading)
|
||
|
||
- [ ] **Step 3: Write the full implementation wiring all components**
|
||
|
||
Replace the contents of `frontend/src/App.tsx`:
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import FileExplorer from './components/FileExplorer';
|
||
import DocumentEditor from './components/DocumentEditor';
|
||
import SearchBar from './components/SearchBar';
|
||
import SearchResults from './components/SearchResults';
|
||
import UploadButton from './components/UploadButton';
|
||
import type { SearchResult, UploadDocumentResponse } from './api/client';
|
||
|
||
export default function App() {
|
||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||
|
||
function handleJumpToResult(key: string) {
|
||
setSelectedKey(key);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
}
|
||
|
||
function handleUploaded(result: UploadDocumentResponse) {
|
||
setSelectedKey(result.key);
|
||
}
|
||
|
||
return (
|
||
<div className="app">
|
||
<div className="topbar">
|
||
<h1>DocHub</h1>
|
||
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
|
||
<UploadButton path="" onUploaded={handleUploaded} />
|
||
</div>
|
||
<div className="body">
|
||
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
|
||
<div className="main" data-testid="main-area">
|
||
{selectedKey ? (
|
||
<DocumentEditor documentKey={selectedKey} />
|
||
) : (
|
||
<div data-testid="empty-state">未选择文档</div>
|
||
)}
|
||
</div>
|
||
{searchQuery !== '' && (
|
||
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npm run test -- App.test.tsx`
|
||
Expected: PASS (including the original `renders the DocHub heading` test from Task 9)
|
||
|
||
- [ ] **Step 5: Run the full test suite**
|
||
|
||
Run: `npm run test`
|
||
Expected: PASS for all suites (`client.test.ts`, `FileExplorer.test.tsx`, `DocumentEditor.test.tsx`, `SearchBar.test.tsx`, `SearchResults.test.tsx`, `UploadButton.test.tsx`, `App.test.tsx`)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/App.tsx frontend/tests/App.test.tsx
|
||
git commit -m "feat: wire FileExplorer, DocumentEditor, search and upload into App"
|
||
```
|