115 KiB
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.pyuses 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 bodyPOST /api/uploadmultipart fieldsfile,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 ofSettingswith attributesminio_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 byuvicorn/Docker at runtime. -
Step 1: Create
backend/requirements.txtand install dependencies
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.inisotests/can import theapppackage
[pytest]
pythonpath = .
- Step 3: Write the failing test for the health endpoint
# 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
# backend/app/__init__.py
- Step 6: Write
backend/app/config.py
# 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
# 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
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
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 byDocumentServicein Task 5. -
Step 1: Write the failing test for basic HTML tag stripping
# 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_textimplementation
# 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
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_textto collapse whitespace
# 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
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_textfor headers and emphasis
# 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
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_textwith link and list-marker stripping
# 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
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
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.MinioSDK instance (constructor-injected; tests inject a hand-written fake),minio.error.S3Error -
Produces:
MinioClient(client, bucket: str)withlist_objects(prefix: str) -> list[dict](each dict haskey,size,is_dir,modifiedAt),get_object(key: str) -> bytes(raisesFileNotFoundErrorif missing),put_object(key: str, data: bytes, content_type: str) -> None,delete_object(key: str) -> None— consumed byDocumentServicein Task 5. -
Step 1: Write the failing test for
list_objects
# 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
# 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_objectsuccess
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
# 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_objecton a missing key
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
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
# 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
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
# 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
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.ClientSDK instance (constructor-injected; tests inject a hand-written fake) -
Produces:
SearchClient(client, index_name: str)withindex_document(key: str, path: str, title: str, content: str) -> None,delete_document(key: str) -> None,search(query: str) -> list[dict](each dict haskey,title,path,snippet) — consumed byDocumentServicein Task 5 androutes/search.pyin Task 7. -
Step 1: Write the failing test for
index_document
# 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
# 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
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
# 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
searchwith highlighted snippet
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
# 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
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)withlist_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;DocumentNotFoundErrorexception — all consumed byroutes/documents.pyin Task 6. -
Step 1: Write the failing test for
list_documents
# 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
# 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)
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
# 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)
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
# 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)
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
# 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
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
# 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
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 withGET /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 bymain.pyin Task 8 and by route/smoke tests viaapp.dependency_overrides. -
Step 1: Write the failing test for
GET /api/documents
# 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__.pyand writedocuments.pywithGET /api/documents
# backend/app/routes/__init__.py
# 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)
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}
# 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}
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}
# 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}
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}
# 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
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
# 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
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 withGET /api/search),get_search_client()dependency provider — both consumed bymain.pyin Task 8 and by route/smoke tests viaapp.dependency_overrides. -
Step 1: Write the failing test for
GET /api/searchwith matches
# 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.pywithGET /api/search
# 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/searchwith no matches
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
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
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 byuvicorn app.main:app(perDockerfilefrom 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
# 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.pyto include both routers
# 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
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 byfrontend/src/main.tsxand extended by Task 15's integration wiring. -
Step 1: Create the project configuration files
frontend/package.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:
{
"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:
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:
<!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:
import '@testing-library/jest-dom/vitest';
- Step 2: Write the failing test
frontend/tests/App.test.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:
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:
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:
# ---- 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:
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
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,UploadDocumentResponseinterfaces;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 byFileExplorer,DocumentEditor,SearchBar,SearchResults,UploadButton,App. -
Step 1: Write the failing tests for read operations
frontend/tests/api/client.test.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:
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:
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:
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:
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:
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:
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:
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
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>,DocumentFilefrom../api/client -
Produces:
export default function FileExplorer({ onSelectFile, selectedKey }: { onSelectFile: (key: string) => void; selectedKey?: string | null }): JSX.Element— consumed byApp.tsxin Task 15. -
Step 1: Write the failing tests for rendering and file selection
frontend/tests/components/FileExplorer.test.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:
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:
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:
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
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;Editordefault export from@monaco-editor/react -
Produces:
export default function DocumentEditor({ documentKey }: { documentKey: string }): JSX.Element— consumed byApp.tsxin Task 15. -
Step 1: Write the failing test for loading and displaying content
frontend/tests/components/DocumentEditor.test.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:
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:
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:
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
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>,SearchResultfrom../api/client -
Produces:
export default function SearchBar({ onQueryChange, onResults, debounceMs }: { onQueryChange: (query: string) => void; onResults: (results: SearchResult[]) => void; debounceMs?: number }): JSX.Elementandexport default function SearchResults({ results, query, onJumpToResult }: { results: SearchResult[]; query: string; onJumpToResult: (key: string) => void }): JSX.Element— both consumed byApp.tsxin Task 15. -
Step 1: Write the failing tests for SearchBar debounce behavior
frontend/tests/components/SearchBar.test.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:
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:
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:
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
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 byApp.tsxin Task 15. -
Step 1: Write the failing tests for the modal and click-to-select upload
frontend/tests/components/UploadButton.test.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:
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:
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:
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
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/SearchResulttypes from../api/client(Task 10) -
Produces: fully wired
export default function App(): JSX.Element— the complete frontend entrypoint rendered byfrontend/src/main.tsx. -
Step 1: Write the failing integration tests
Append to frontend/tests/App.test.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:
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
git add frontend/src/App.tsx frontend/tests/App.test.tsx
git commit -m "feat: wire FileExplorer, DocumentEditor, search and upload into App"