DocHub/docs/superpowers/plans/2026-08-01-dochub-implementation.md

3603 lines
115 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `routes/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 `routes/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 Routes (`routes/documents.py`)
**Files:**
- Create: `backend/app/routes/__init__.py`
- Create: `backend/app/routes/documents.py`
- Test: `backend/tests/test_routes_documents.py`
**Interfaces:**
- Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1)
- Produces: `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`), `get_document_service()` dependency provider — 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_routes_documents.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.routes.documents 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_routes_documents.py::test_list_documents_returns_folders_and_files -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes'`
- [ ] **Step 3: Create `backend/app/routes/__init__.py` and write `documents.py` with `GET /api/documents`**
```python
# backend/app/routes/__init__.py
```
```python
# backend/app/routes/documents.py
from functools import lru_cache
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from minio import Minio
import meilisearch
from pydantic import BaseModel
from app.config import settings
from app.document_service import DocumentNotFoundError, DocumentService
from app.minio_client import MinioClient
from app.search_client import SearchClient
router = APIRouter()
@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)
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_routes_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_routes_documents.py::test_get_document_returns_content tests/test_routes_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/routes/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_routes_documents.py::test_get_document_returns_content tests/test_routes_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_routes_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/routes/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_routes_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_routes_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/routes/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_routes_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_routes_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/routes/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_routes_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/routes/__init__.py backend/app/routes/documents.py backend/tests/test_routes_documents.py
git commit -m "feat: add documents routes for list/get/save/delete/upload"
```
---
### Task 7: Search Route (`routes/search.py`)
**Files:**
- Create: `backend/app/routes/search.py`
- Test: `backend/tests/test_routes_search.py`
**Interfaces:**
- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1)
- Produces: `router` (APIRouter with `GET /api/search`), `get_search_client()` dependency provider — 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/search` with matches**
```python
# backend/tests/test_routes_search.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.routes.search 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_routes_search.py::test_search_returns_matching_results -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes.search'`
- [ ] **Step 3: Write `search.py` with `GET /api/search`**
```python
# backend/app/routes/search.py
from functools import lru_cache
import meilisearch
from fastapi import APIRouter, Depends
from app.config import settings
from app.search_client import SearchClient
router = APIRouter()
@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)
@router.get("/api/search")
def search(q: str = "", client: SearchClient = Depends(get_search_client)):
return {"results": client.search(q)}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd backend && pytest tests/test_routes_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_routes_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_routes_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/routes/search.py backend/tests/test_routes_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`, `documents.get_document_service` (Task 6); `search.router`, `search.get_search_client` (Task 7); `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.routes.documents import get_document_service
from app.routes.search import 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.routes 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_routes_documents.py`, `test_routes_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"
```