From ba7ee21c283357697ba64ea760873f993a3d2b95 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 01:54:16 +0800 Subject: [PATCH] Add DocHub implementation plan --- .../plans/2026-08-01-dochub-implementation.md | 3602 +++++++++++++++++ 1 file changed, 3602 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-dochub-implementation.md diff --git a/docs/superpowers/plans/2026-08-01-dochub-implementation.md b/docs/superpowers/plans/2026-08-01-dochub-implementation.md new file mode 100644 index 0000000..9e05bae --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-dochub-implementation.md @@ -0,0 +1,3602 @@ +# DocHub Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build DocHub, a small-team document hub that stores Markdown/HTML documents in MinIO, indexes their content in Meilisearch for full-text search, and lets users browse, edit, and upload documents through a React frontend. + +**Architecture:** A FastAPI backend exposes REST endpoints that coordinate two thin SDK wrappers (`MinioClient`, `SearchClient`) through a `DocumentService` that writes to MinIO first and then synchronously updates the Meilisearch index (logging and swallowing index failures so a MinIO write is never rolled back for an index error). A React + Vite frontend consumes this API through a typed `client.ts`, rendering a lazy-loaded file tree, a Monaco-based editor with dirty-state tracking, a debounced global search bar with a results overlay, and an upload control supporting both click-to-select and drag-and-drop. No database and no authentication layer are used, per the approved design spec — MinIO holds content and metadata, Meilisearch holds the search index. + +**Tech Stack:** Backend: Python, FastAPI, `minio` SDK, `meilisearch` SDK, pytest, httpx, python-multipart. Frontend: React 18, TypeScript, Vite, `@monaco-editor/react`, Vitest, Testing Library. Both services are containerized with Docker; MinIO is deployed separately per the design spec. + +## Global Constraints + +- No database and no authentication/authorization layer (per spec — trusted internal tool for a team of 10 or fewer). +- Index updates happen synchronously in `document_service.py`: write to MinIO first, then update Meilisearch; if the Meilisearch update fails, log it and do not fail the request. +- `text_extract.py` uses only the Python standard library (no third-party Markdown/HTML parsing dependency) since it only needs approximate text for indexing, not rendering. +- Backend and frontend communicate through the fixed API contract below — both plan sections were written against this exact contract, so it must not be changed without updating both sides. +- API contract: + - `GET /api/documents?path=` → `{"folders": [...], "files": [{"key", "name", "size", "modifiedAt"}]}` + - `GET /api/documents/{key}` → `{"key", "content", "contentType"}` (404 if missing) + - `PUT /api/documents/{key}` body `{"content", "contentType"}` → `{"key", "updatedAt"}` + - `DELETE /api/documents/{key}` → 204 empty body + - `POST /api/upload` multipart fields `file`, `path` → 201 `{"key", "name", "size"}` + - `GET /api/search?q=` → `{"results": [{"key", "title", "path", "snippet"}]}` (snippet contains `` highlight tags) +- TDD throughout: every task follows write-failing-test → verify failure → minimal implementation → verify pass → commit. + +--- + +### Task 1: Project Scaffolding, Config, and Health Endpoint + +**Files:** +- Create: `backend/requirements.txt` +- Create: `backend/pytest.ini` +- Create: `backend/Dockerfile` +- Create: `backend/app/__init__.py` +- Create: `backend/app/config.py` +- Create: `backend/app/main.py` +- Test: `backend/tests/test_main.py` + +**Interfaces:** +- Consumes: (none — first task) +- Produces: `app.config.settings` (instance of `Settings` with attributes `minio_endpoint`, `minio_access_key`, `minio_secret_key`, `minio_bucket`, `meilisearch_host`, `meilisearch_api_key`, `meilisearch_index`, `cors_origins`); `app.main.app` (FastAPI instance) consumed by Task 6, 7, 8 and by `uvicorn`/Docker at runtime. + +- [ ] **Step 1: Create `backend/requirements.txt` and install dependencies** + +```text +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +minio==7.2.9 +meilisearch==0.31.5 +pytest==8.3.3 +httpx==0.27.2 +python-multipart==0.0.9 +``` + +Run: `cd backend && pip install -r requirements.txt` + +- [ ] **Step 2: Create `backend/pytest.ini` so `tests/` can import the `app` package** + +```ini +[pytest] +pythonpath = . +``` + +- [ ] **Step 3: Write the failing test for the health endpoint** + +```python +# backend/tests/test_main.py +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_returns_ok(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_main.py::test_health_returns_ok -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.main'` (neither `app/__init__.py` nor `app/main.py` exist yet) + +- [ ] **Step 5: Create `backend/app/__init__.py`** + +```python +# backend/app/__init__.py +``` + +- [ ] **Step 6: Write `backend/app/config.py`** + +```python +# backend/app/config.py +import os + + +class Settings: + def __init__(self): + self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000") + self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") + self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin") + self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub") + self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700") + self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "") + self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents") + self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",") + + +settings = Settings() +``` + +- [ ] **Step 7: Write minimal `backend/app/main.py`** + +```python +# backend/app/main.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings + +app = FastAPI(title="DocHub API") + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_main.py::test_health_returns_ok -v` +Expected: PASS + +- [ ] **Step 9: Write `backend/Dockerfile`** + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 10: Commit** + +```bash +git add backend/requirements.txt backend/pytest.ini backend/Dockerfile backend/app/__init__.py backend/app/config.py backend/app/main.py backend/tests/test_main.py +git commit -m "feat: scaffold FastAPI backend with config and health endpoint" +``` + +--- + +### Task 2: Text Extraction (`text_extract.py`) + +**Files:** +- Create: `backend/app/text_extract.py` +- Test: `backend/tests/test_text_extract.py` + +**Interfaces:** +- Consumes: (none — stdlib only, no dependency on earlier tasks) +- Produces: `markdown_to_text(text: str) -> str`, `html_to_text(text: str) -> str`, both consumed by `DocumentService` in Task 5. + +- [ ] **Step 1: Write the failing test for basic HTML tag stripping** + +```python +# backend/tests/test_text_extract.py +from app.text_extract import html_to_text, markdown_to_text + + +def test_html_to_text_strips_tags(): + html = """ + + +

架构设计

+

DocHub 是一个基于 MinIO 的文档管理系统。

+
    +
  • 浏览文档
  • +
  • 全文检索
  • +
+ + + """ + result = html_to_text(html) + assert "架构设计" in result + assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result + assert "浏览文档" in result + assert "

" not in result + assert "" not in result +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.text_extract'` + +- [ ] **Step 3: Write minimal `html_to_text` implementation** + +```python +# backend/app/text_extract.py +from html.parser import HTMLParser + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self): + super().__init__() + self._chunks = [] + + def handle_data(self, data): + self._chunks.append(data) + + def get_text(self): + return "".join(self._chunks) + + +def html_to_text(text: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(text) + parser.close() + return parser.get_text() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v` +Expected: PASS + +- [ ] **Step 5: Write the failing test for whitespace collapsing** + +```python +def test_html_to_text_collapses_whitespace(): + html = "

第一段

\n\n\n

第二段

" + result = html_to_text(html) + assert result == "第一段 第二段" +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v` +Expected: FAIL with `AssertionError: '第一段\n\n\n第二段' != '第一段 第二段'` + +- [ ] **Step 7: Extend `html_to_text` to collapse whitespace** + +```python +# backend/app/text_extract.py +import re +from html.parser import HTMLParser + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self): + super().__init__() + self._chunks = [] + + def handle_data(self, data): + self._chunks.append(data) + + def get_text(self): + return "".join(self._chunks) + + +def html_to_text(text: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(text) + parser.close() + raw = parser.get_text() + return re.sub(r"\s+", " ", raw).strip() +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v` +Expected: PASS + +- [ ] **Step 9: Write the failing test for Markdown headers and emphasis** + +```python +def test_markdown_to_text_strips_headers_and_emphasis(): + markdown = """# 架构设计 + +## 背景 + +DocHub 是一个 **基于 MinIO** 的 _文档管理系统_,支持全文检索。 +""" + result = markdown_to_text(markdown) + assert "#" not in result + assert "**" not in result + assert "_" not in result + assert "架构设计" in result + assert "背景" in result + assert "基于 MinIO" in result + assert "文档管理系统" in result +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v` +Expected: FAIL with `AttributeError: module 'app.text_extract' has no attribute 'markdown_to_text'` + +- [ ] **Step 11: Write minimal `markdown_to_text` for headers and emphasis** + +```python +# backend/app/text_extract.py (add below html_to_text) +def markdown_to_text(text: str) -> str: + result = text + result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE) + result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result) + result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result) + result = re.sub(r"(\*|_)(.+?)\1", r"\2", result) + result = re.sub(r"\s+", " ", result).strip() + return result +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v` +Expected: PASS + +- [ ] **Step 13: Write the failing test for links and list markers** + +```python +def test_markdown_to_text_strips_links_and_list_markers(): + markdown = """## 功能列表 + +- 浏览 [MinIO](https://min.io) 中存储的文档 +- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 +1. 在线编辑并保存 +""" + result = markdown_to_text(markdown) + assert "[" not in result + assert "](" not in result + assert "浏览 MinIO 中存储的文档" in result + assert "支持 Meilisearch 全文检索" in result + assert "在线编辑并保存" in result +``` + +- [ ] **Step 14: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v` +Expected: FAIL with `AssertionError: assert '[' not in '功能列表 浏览 [MinIO](https://min.io) 中存储的文档 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 在线编辑并保存'` + +- [ ] **Step 15: Extend `markdown_to_text` with link and list-marker stripping** + +```python +# backend/app/text_extract.py +def markdown_to_text(text: str) -> str: + result = text + result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result) + result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result) + result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE) + result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result) + result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result) + result = re.sub(r"(\*|_)(.+?)\1", r"\2", result) + result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE) + result = re.sub(r"\s+", " ", result).strip() + return result +``` + +- [ ] **Step 16: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v` +Expected: PASS + +- [ ] **Step 17: Write the failing test for code fences and inline code** + +```python +def test_markdown_to_text_strips_code_fences_and_inline_code(): + markdown = """使用 `pip install -r requirements.txt` 安装依赖。 + +```python +def hello(): + return "world" +``` + +安装完成后启动服务。 +""" + result = markdown_to_text(markdown) + assert "```" not in result + assert "def hello" not in result + assert "使用 pip install -r requirements.txt 安装依赖。" in result + assert "安装完成后启动服务。" in result +``` + +- [ ] **Step 18: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_code_fences_and_inline_code -v` +Expected: FAIL with `AssertionError: assert 'def hello' not in '...def hello():...'` + +- [ ] **Step 19: Extend `markdown_to_text` with code-fence and inline-code stripping (applied first)** + +```python +# backend/app/text_extract.py +def markdown_to_text(text: str) -> str: + result = text + result = re.sub(r"```.*?```", " ", result, flags=re.DOTALL) + result = re.sub(r"`([^`]*)`", r"\1", result) + result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result) + result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result) + result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE) + result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result) + result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result) + result = re.sub(r"(\*|_)(.+?)\1", r"\2", result) + result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE) + result = re.sub(r"^\s*>\s*", "", result, flags=re.MULTILINE) + result = re.sub(r"\s+", " ", result).strip() + return result +``` + +- [ ] **Step 20: Run full test file to verify everything passes** + +Run: `cd backend && pytest tests/test_text_extract.py -v` +Expected: PASS (all 6 tests) + +- [ ] **Step 21: Commit** + +```bash +git add backend/app/text_extract.py backend/tests/test_text_extract.py +git commit -m "feat: add stdlib-only markdown and html text extraction for indexing" +``` + +--- + +### Task 3: MinIO Client Wrapper (`minio_client.py`) + +**Files:** +- Create: `backend/app/minio_client.py` +- Test: `backend/tests/test_minio_client.py` + +**Interfaces:** +- Consumes: `minio.Minio` SDK instance (constructor-injected; tests inject a hand-written fake), `minio.error.S3Error` +- Produces: `MinioClient(client, bucket: str)` with `list_objects(prefix: str) -> list[dict]` (each dict has `key`, `size`, `is_dir`, `modifiedAt`), `get_object(key: str) -> bytes` (raises `FileNotFoundError` if missing), `put_object(key: str, data: bytes, content_type: str) -> None`, `delete_object(key: str) -> None` — consumed by `DocumentService` in Task 5. + +- [ ] **Step 1: Write the failing test for `list_objects`** + +```python +# backend/tests/test_minio_client.py +from datetime import datetime, timezone + +import pytest +from minio.error import S3Error + +from app.minio_client import MinioClient + + +class _FakeObject: + def __init__(self, object_name, size, last_modified): + self.object_name = object_name + self.size = size + self.last_modified = last_modified + + +class _FakeResponse: + def __init__(self, data: bytes): + self._data = data + self.closed = False + self.released = False + + def read(self): + return self._data + + def close(self): + self.closed = True + + def release_conn(self): + self.released = True + + +class FakeMinioSDK: + def __init__(self): + self.objects = {} + self.put_calls = [] + self.removed_keys = [] + + def list_objects(self, bucket, prefix="", recursive=False): + for key, (data, content_type, last_modified) in self.objects.items(): + if key.startswith(prefix): + yield _FakeObject(key, len(data), last_modified) + + def get_object(self, bucket, key): + if key not in self.objects: + raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None) + data, _, _ = self.objects[key] + return _FakeResponse(data) + + def put_object(self, bucket, key, stream, length, content_type): + data = stream.read() + self.objects[key] = (data, content_type, datetime.now(timezone.utc)) + self.put_calls.append((bucket, key, content_type)) + + def remove_object(self, bucket, key): + del self.objects[key] + self.removed_keys.append(key) + + +def test_list_objects_returns_keys_matching_prefix(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc)) + sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + result = client.list_objects("产品文档/") + + assert len(result) == 1 + assert result[0]["key"] == "产品文档/架构设计.md" + assert result[0]["size"] == 4 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.minio_client'` + +- [ ] **Step 3: Write minimal `MinioClient.list_objects`** + +```python +# backend/app/minio_client.py +import io + + +class MinioClient: + def __init__(self, client, bucket: str): + self._client = client + self._bucket = bucket + + def list_objects(self, prefix: str) -> list[dict]: + objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False) + result = [] + for obj in objects: + result.append({ + "key": obj.object_name, + "size": obj.size, + "is_dir": obj.object_name.endswith("/"), + "modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None, + }) + return result +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `get_object` success** + +```python +def test_get_object_returns_bytes(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + data = client.get_object("产品文档/架构设计.md") + + assert data == b"# architecture" +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v` +Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'get_object'` + +- [ ] **Step 7: Write `get_object`** + +```python +# backend/app/minio_client.py (add inside MinioClient) +from minio.error import S3Error + + def get_object(self, key: str) -> bytes: + try: + response = self._client.get_object(self._bucket, key) + except S3Error as exc: + if exc.code == "NoSuchKey": + raise FileNotFoundError(key) from exc + raise + try: + return response.read() + finally: + response.close() + response.release_conn() +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v` +Expected: PASS + +- [ ] **Step 9: Write the failing test for `get_object` on a missing key** + +```python +def test_get_object_raises_file_not_found_for_missing_key(): + sdk = FakeMinioSDK() + client = MinioClient(sdk, bucket="dochub") + + with pytest.raises(FileNotFoundError): + client.get_object("不存在/文档.md") +``` + +- [ ] **Step 10: Run test to verify it passes (already satisfied by Step 7's implementation)** + +Run: `cd backend && pytest tests/test_minio_client.py::test_get_object_raises_file_not_found_for_missing_key -v` +Expected: PASS + +- [ ] **Step 11: Write the failing test for `put_object`** + +```python +def test_put_object_stores_data_with_content_type(): + sdk = FakeMinioSDK() + client = MinioClient(sdk, bucket="dochub") + + client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown") + + assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc" + assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")] +``` + +- [ ] **Step 12: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v` +Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'put_object'` + +- [ ] **Step 13: Write `put_object`** + +```python +# backend/app/minio_client.py (add inside MinioClient) + def put_object(self, key: str, data: bytes, content_type: str) -> None: + self._client.put_object( + self._bucket, + key, + io.BytesIO(data), + length=len(data), + content_type=content_type, + ) +``` + +- [ ] **Step 14: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v` +Expected: PASS + +- [ ] **Step 15: Write the failing test for `delete_object`** + +```python +def test_delete_object_removes_key(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + client.delete_object("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in sdk.objects + assert sdk.removed_keys == ["产品文档/旧文档.md"] +``` + +- [ ] **Step 16: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_minio_client.py::test_delete_object_removes_key -v` +Expected: FAIL with `AttributeError: 'MinioClient' object has no attribute 'delete_object'` + +- [ ] **Step 17: Write `delete_object`** + +```python +# backend/app/minio_client.py (add inside MinioClient) + def delete_object(self, key: str) -> None: + self._client.remove_object(self._bucket, key) +``` + +- [ ] **Step 18: Run full test file to verify everything passes** + +Run: `cd backend && pytest tests/test_minio_client.py -v` +Expected: PASS (all 5 tests) + +- [ ] **Step 19: Commit** + +```bash +git add backend/app/minio_client.py backend/tests/test_minio_client.py +git commit -m "feat: add MinioClient wrapper for list/get/put/delete object operations" +``` + +--- + +### Task 4: Search Client Wrapper (`search_client.py`) + +**Files:** +- Create: `backend/app/search_client.py` +- Test: `backend/tests/test_search_client.py` + +**Interfaces:** +- Consumes: `meilisearch.Client` SDK instance (constructor-injected; tests inject a hand-written fake) +- Produces: `SearchClient(client, index_name: str)` with `index_document(key: str, path: str, title: str, content: str) -> None`, `delete_document(key: str) -> None`, `search(query: str) -> list[dict]` (each dict has `key`, `title`, `path`, `snippet`) — consumed by `DocumentService` in Task 5 and `routes/search.py` in Task 7. + +- [ ] **Step 1: Write the failing test for `index_document`** + +```python +# backend/tests/test_search_client.py +from app.search_client import SearchClient + + +class FakeIndex: + def __init__(self): + self.documents = {} + self.deleted_ids = [] + + def add_documents(self, docs): + for doc in docs: + self.documents[doc["id"]] = doc + + def delete_document(self, doc_id): + self.documents.pop(doc_id, None) + self.deleted_ids.append(doc_id) + + def search(self, query, params=None): + hits = [] + for doc in self.documents.values(): + if query in doc["content"] or query in doc["title"]: + formatted = dict(doc) + if query in doc["content"]: + formatted["content"] = doc["content"].replace(query, f"{query}") + hits.append({**doc, "_formatted": formatted}) + return {"hits": hits} + + +class FakeMeilisearchSDK: + def __init__(self): + self.indexes = {} + + def index(self, name): + if name not in self.indexes: + self.indexes[name] = FakeIndex() + return self.indexes[name] + + +def test_index_document_adds_document_with_encoded_id(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + + client.index_document( + "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档" + ) + + index = sdk.indexes["documents"] + assert len(index.documents) == 1 + stored = list(index.documents.values())[0] + assert stored["key"] == "产品文档/架构设计.md" + assert stored["path"] == "产品文档" + assert stored["title"] == "架构设计.md" + assert stored["content"] == "DocHub 使用 MinIO 存储文档" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.search_client'` + +- [ ] **Step 3: Write minimal `SearchClient.index_document`** + +```python +# backend/app/search_client.py +import base64 + + +def _encode_key(key: str) -> str: + return base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii").rstrip("=") + + +class SearchClient: + def __init__(self, client, index_name: str): + self._client = client + self._index_name = index_name + + def index_document(self, key: str, path: str, title: str, content: str) -> None: + index = self._client.index(self._index_name) + index.add_documents([{ + "id": _encode_key(key), + "key": key, + "path": path, + "title": title, + "content": content, + }]) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v` +Expected: PASS + +- [ ] **Step 5: Write the failing test for `delete_document`** + +```python +def test_delete_document_removes_document(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + client.index_document("产品文档/旧文档.md", "产品文档", "旧文档.md", "旧内容") + + client.delete_document("产品文档/旧文档.md") + + index = sdk.indexes["documents"] + assert index.documents == {} + assert len(index.deleted_ids) == 1 +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v` +Expected: FAIL with `AttributeError: 'SearchClient' object has no attribute 'delete_document'` + +- [ ] **Step 7: Write `delete_document`** + +```python +# backend/app/search_client.py (add inside SearchClient) + def delete_document(self, key: str) -> None: + index = self._client.index(self._index_name) + index.delete_document(_encode_key(key)) +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v` +Expected: PASS + +- [ ] **Step 9: Write the failing test for `search` with highlighted snippet** + +```python +def test_search_returns_matching_documents_with_highlighted_snippet(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + client.index_document( + "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 支持全文检索功能" + ) + + results = client.search("检索") + + assert len(results) == 1 + assert results[0]["key"] == "产品文档/架构设计.md" + assert results[0]["title"] == "架构设计.md" + assert results[0]["path"] == "产品文档" + assert "检索" in results[0]["snippet"] +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_search_client.py::test_search_returns_matching_documents_with_highlighted_snippet -v` +Expected: FAIL with `AttributeError: 'SearchClient' object has no attribute 'search'` + +- [ ] **Step 11: Write `search`** + +```python +# backend/app/search_client.py (add inside SearchClient) + def search(self, query: str) -> list[dict]: + index = self._client.index(self._index_name) + response = index.search(query, { + "attributesToHighlight": ["content"], + "highlightPreTag": "", + "highlightPostTag": "", + }) + results = [] + for hit in response.get("hits", []): + formatted = hit.get("_formatted", {}) + results.append({ + "key": hit["key"], + "title": hit["title"], + "path": hit["path"], + "snippet": formatted.get("content", hit.get("content", "")), + }) + return results +``` + +- [ ] **Step 12: Run full test file to verify everything passes** + +Run: `cd backend && pytest tests/test_search_client.py -v` +Expected: PASS (all 3 tests) + +- [ ] **Step 13: Commit** + +```bash +git add backend/app/search_client.py backend/tests/test_search_client.py +git commit -m "feat: add SearchClient wrapper for index/delete/search operations" +``` + +--- + +### Task 5: Document Service (`document_service.py`) + +**Files:** +- Create: `backend/app/document_service.py` +- Test: `backend/tests/test_document_service.py` + +**Interfaces:** +- Consumes: `MinioClient.list_objects/get_object/put_object/delete_object` (Task 3), `SearchClient.index_document/delete_document` (Task 4), `markdown_to_text`/`html_to_text` (Task 2) +- Produces: `DocumentService(minio_client, search_client)` with `list_documents(path: str) -> dict`, `get_document(key: str) -> dict`, `save_document(key: str, content: bytes, content_type: str) -> dict`, `delete_document(key: str) -> None`, `upload_document(key: str, data: bytes, content_type: str) -> dict`; `DocumentNotFoundError` exception — all consumed by `routes/documents.py` in Task 6. + +- [ ] **Step 1: Write the failing test for `list_documents`** + +```python +# backend/tests/test_document_service.py +import pytest + +from app.document_service import DocumentService, DocumentNotFoundError + + +class FakeMinioClient: + def __init__(self): + self.entries = [] + self.stored = {} + self.deleted_keys = [] + self.put_calls = [] + + def list_objects(self, prefix): + return [e for e in self.entries if e["key"].startswith(prefix)] + + def get_object(self, key): + if key not in self.stored: + raise FileNotFoundError(key) + return self.stored[key] + + def put_object(self, key, data, content_type): + self.stored[key] = data + self.put_calls.append((key, content_type)) + + def delete_object(self, key): + if key not in self.stored: + raise FileNotFoundError(key) + del self.stored[key] + self.deleted_keys.append(key) + + +class FakeSearchClient: + def __init__(self, fail_on_index=False, fail_on_delete=False): + self.indexed = [] + self.deleted = [] + self.fail_on_index = fail_on_index + self.fail_on_delete = fail_on_delete + + def index_document(self, key, path, title, content): + if self.fail_on_index: + raise RuntimeError("meilisearch unavailable") + self.indexed.append({"key": key, "path": path, "title": title, "content": content}) + + def delete_document(self, key): + if self.fail_on_delete: + raise RuntimeError("meilisearch unavailable") + self.deleted.append(key) + + +def test_list_documents_at_root_returns_folders_and_files(): + minio_client = FakeMinioClient() + minio_client.entries = [ + {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None}, + {"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None}, + {"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, + ] + service = DocumentService(minio_client, FakeSearchClient()) + + result = service.list_documents("") + + assert result["folders"] == ["产品文档", "团队规范"] + assert result["files"] == [ + {"key": "README.md", "name": "README.md", "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, + ] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.document_service'` + +- [ ] **Step 3: Write minimal `DocumentService.list_documents`** + +```python +# backend/app/document_service.py +import logging +from datetime import datetime, timezone + +from app.text_extract import html_to_text, markdown_to_text + +logger = logging.getLogger(__name__) + + +class DocumentNotFoundError(Exception): + pass + + +def _guess_content_type(key: str) -> str: + if key.endswith(".md"): + return "text/markdown" + if key.endswith(".html") or key.endswith(".htm"): + return "text/html" + return "application/octet-stream" + + +class DocumentService: + def __init__(self, minio_client, search_client): + self._minio_client = minio_client + self._search_client = search_client + + def list_documents(self, path: str) -> dict: + prefix = path if not path or path.endswith("/") else f"{path}/" + entries = self._minio_client.list_objects(prefix) + folders = [] + files = [] + for entry in entries: + key = entry["key"] + if entry.get("is_dir"): + name = key[len(prefix):].rstrip("/") + if name: + folders.append(name) + else: + name = key[len(prefix):] + files.append({ + "key": key, + "name": name, + "size": entry["size"], + "modifiedAt": entry["modifiedAt"], + }) + return {"folders": folders, "files": files} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v` +Expected: PASS + +- [ ] **Step 5: Write the failing tests for `get_document` (found and not found)** + +```python +def test_get_document_returns_content_and_content_type(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8") + service = DocumentService(minio_client, FakeSearchClient()) + + result = service.get_document("产品文档/架构设计.md") + + assert result["key"] == "产品文档/架构设计.md" + assert result["contentType"] == "text/markdown" + assert "架构设计" in result["content"] + + +def test_get_document_raises_not_found_for_missing_key(): + service = DocumentService(FakeMinioClient(), FakeSearchClient()) + + with pytest.raises(DocumentNotFoundError): + service.get_document("不存在/文档.md") +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v` +Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'get_document'` + +- [ ] **Step 7: Write `get_document`** + +```python +# backend/app/document_service.py (add inside DocumentService) + def get_document(self, key: str) -> dict: + try: + data = self._minio_client.get_object(key) + except FileNotFoundError as exc: + raise DocumentNotFoundError(key) from exc + return { + "key": key, + "content": data.decode("utf-8"), + "contentType": _guess_content_type(key), + } +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v` +Expected: PASS + +- [ ] **Step 9: Write the failing tests for `save_document` (success and index-failure tolerance)** + +```python +def test_save_document_writes_to_minio_then_indexes_content(): + minio_client = FakeMinioClient() + search_client = FakeSearchClient() + service = DocumentService(minio_client, search_client) + content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8") + + result = service.save_document("产品文档/架构设计.md", content, "text/markdown") + + assert minio_client.stored["产品文档/架构设计.md"] == content + assert search_client.indexed[0]["key"] == "产品文档/架构设计.md" + assert search_client.indexed[0]["path"] == "产品文档" + assert search_client.indexed[0]["title"] == "架构设计.md" + assert "MinIO" in search_client.indexed[0]["content"] + assert "#" not in search_client.indexed[0]["content"] + assert "key" in result and "updatedAt" in result + + +def test_save_document_does_not_fail_when_index_update_fails(): + minio_client = FakeMinioClient() + search_client = FakeSearchClient(fail_on_index=True) + service = DocumentService(minio_client, search_client) + content = b"# doc" + + result = service.save_document("产品文档/架构设计.md", content, "text/markdown") + + assert minio_client.stored["产品文档/架构设计.md"] == content + assert result["key"] == "产品文档/架构设计.md" +``` + +- [ ] **Step 10: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v` +Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'save_document'` + +- [ ] **Step 11: Write `save_document`** + +```python +# backend/app/document_service.py (add inside DocumentService) + def _extract_text(self, content: bytes, content_type: str) -> str: + text = content.decode("utf-8", errors="ignore") + if content_type == "text/html": + return html_to_text(text) + if content_type == "text/markdown": + return markdown_to_text(text) + return text + + def save_document(self, key: str, content: bytes, content_type: str) -> dict: + self._minio_client.put_object(key, content, content_type) + try: + text = self._extract_text(content, content_type) + title = key.rsplit("/", 1)[-1] + path = key.rsplit("/", 1)[0] if "/" in key else "" + self._search_client.index_document(key, path, title, text) + except Exception: + logger.exception("Failed to update search index for %s", key) + return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()} +``` + +- [ ] **Step 12: Run tests to verify they pass** + +Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v` +Expected: PASS + +- [ ] **Step 13: Write the failing tests for `delete_document` (success and index-failure tolerance)** + +```python +def test_delete_document_removes_from_minio_and_search_index(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/旧文档.md"] = b"old content" + search_client = FakeSearchClient() + service = DocumentService(minio_client, search_client) + + service.delete_document("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in minio_client.stored + assert search_client.deleted == ["产品文档/旧文档.md"] + + +def test_delete_document_does_not_fail_when_index_delete_fails(): + minio_client = FakeMinioClient() + minio_client.stored["产品文档/旧文档.md"] = b"old content" + search_client = FakeSearchClient(fail_on_delete=True) + service = DocumentService(minio_client, search_client) + + service.delete_document("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in minio_client.stored +``` + +- [ ] **Step 14: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v` +Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'delete_document'` + +- [ ] **Step 15: Write `delete_document`** + +```python +# backend/app/document_service.py (add inside DocumentService) + def delete_document(self, key: str) -> None: + self._minio_client.delete_object(key) + try: + self._search_client.delete_document(key) + except Exception: + logger.exception("Failed to delete search index entry for %s", key) +``` + +- [ ] **Step 16: Run tests to verify they pass** + +Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v` +Expected: PASS + +- [ ] **Step 17: Write the failing test for `upload_document`** + +```python +def test_upload_document_writes_file_and_returns_metadata(): + minio_client = FakeMinioClient() + service = DocumentService(minio_client, FakeSearchClient()) + data = b"# uploaded doc" + + result = service.upload_document("产品文档/新上传.md", data, "text/markdown") + + assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)} + assert minio_client.stored["产品文档/新上传.md"] == data +``` + +- [ ] **Step 18: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_document_service.py::test_upload_document_writes_file_and_returns_metadata -v` +Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'upload_document'` + +- [ ] **Step 19: Write `upload_document`** + +```python +# backend/app/document_service.py (add inside DocumentService) + def upload_document(self, key: str, data: bytes, content_type: str) -> dict: + self.save_document(key, data, content_type) + name = key.rsplit("/", 1)[-1] + return {"key": key, "name": name, "size": len(data)} +``` + +- [ ] **Step 20: Run full test file to verify everything passes** + +Run: `cd backend && pytest tests/test_document_service.py -v` +Expected: PASS (all 8 tests) + +- [ ] **Step 21: Commit** + +```bash +git add backend/app/document_service.py backend/tests/test_document_service.py +git commit -m "feat: add DocumentService coordinating MinIO writes and search indexing" +``` + +--- + +### Task 6: Documents Routes (`routes/documents.py`) + +**Files:** +- Create: `backend/app/routes/__init__.py` +- Create: `backend/app/routes/documents.py` +- Test: `backend/tests/test_routes_documents.py` + +**Interfaces:** +- Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1) +- Produces: `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`), `get_document_service()` dependency provider — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. + +- [ ] **Step 1: Write the failing test for `GET /api/documents`** + +```python +# backend/tests/test_routes_documents.py +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.routes.documents import get_document_service +from app.document_service import DocumentNotFoundError + + +class FakeDocumentService: + def __init__(self): + self.saved = [] + self.deleted = [] + self.uploaded = [] + + def list_documents(self, path): + return { + "folders": ["产品文档", "团队规范"], + "files": [ + {"key": "架构设计.md", "name": "架构设计.md", "size": 512, "modifiedAt": "2026-08-01T10:00:00Z"}, + ], + } + + def get_document(self, key): + if key == "不存在/文档.md": + raise DocumentNotFoundError(key) + return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"} + + def save_document(self, key, content, content_type): + self.saved.append((key, content, content_type)) + return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} + + def delete_document(self, key): + self.deleted.append(key) + + def upload_document(self, key, data, content_type): + self.uploaded.append((key, data, content_type)) + return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)} + + +@pytest.fixture +def client(): + fake_service = FakeDocumentService() + app.dependency_overrides[get_document_service] = lambda: fake_service + with TestClient(app) as test_client: + test_client.fake_service = fake_service + yield test_client + app.dependency_overrides.clear() + + +def test_list_documents_returns_folders_and_files(client): + response = client.get("/api/documents", params={"path": "产品文档"}) + assert response.status_code == 200 + body = response.json() + assert body["folders"] == ["产品文档", "团队规范"] + assert body["files"][0]["key"] == "架构设计.md" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_list_documents_returns_folders_and_files -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes'` + +- [ ] **Step 3: Create `backend/app/routes/__init__.py` and write `documents.py` with `GET /api/documents`** + +```python +# backend/app/routes/__init__.py +``` + +```python +# backend/app/routes/documents.py +from functools import lru_cache + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from minio import Minio +import meilisearch +from pydantic import BaseModel + +from app.config import settings +from app.document_service import DocumentNotFoundError, DocumentService +from app.minio_client import MinioClient +from app.search_client import SearchClient + +router = APIRouter() + + +@lru_cache +def get_document_service() -> DocumentService: + minio_sdk = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=False, + ) + meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) + minio_client = MinioClient(minio_sdk, settings.minio_bucket) + search_client = SearchClient(meili_sdk, settings.meilisearch_index) + return DocumentService(minio_client, search_client) + + +class SaveDocumentRequest(BaseModel): + content: str + contentType: str + + +@router.get("/api/documents") +def list_documents(path: str = "", service: DocumentService = Depends(get_document_service)): + return service.list_documents(path) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_list_documents_returns_folders_and_files -v` +Expected: PASS + +- [ ] **Step 5: Write the failing tests for `GET /api/documents/{key}` (found and 404)** + +```python +def test_get_document_returns_content(client): + response = client.get("/api/documents/产品文档/架构设计.md") + assert response.status_code == 200 + assert response.json()["contentType"] == "text/markdown" + + +def test_get_document_returns_404_for_missing_key(client): + response = client.get("/api/documents/不存在/文档.md") + assert response.status_code == 404 +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_get_document_returns_content tests/test_routes_documents.py::test_get_document_returns_404_for_missing_key -v` +Expected: FAIL with `404 Not Found` (route not registered) + +- [ ] **Step 7: Add `GET /api/documents/{key:path}`** + +```python +# backend/app/routes/documents.py (add below list_documents) +@router.get("/api/documents/{key:path}") +def get_document(key: str, service: DocumentService = Depends(get_document_service)): + try: + return service.get_document(key) + except DocumentNotFoundError: + raise HTTPException(status_code=404, detail="Document not found") +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_get_document_returns_content tests/test_routes_documents.py::test_get_document_returns_404_for_missing_key -v` +Expected: PASS + +- [ ] **Step 9: Write the failing test for `PUT /api/documents/{key}`** + +```python +def test_put_document_saves_content(client): + response = client.put( + "/api/documents/产品文档/架构设计.md", + json={"content": "# 更新后的内容", "contentType": "text/markdown"}, + ) + assert response.status_code == 200 + assert response.json()["key"] == "产品文档/架构设计.md" + assert client.fake_service.saved[0][0] == "产品文档/架构设计.md" + assert client.fake_service.saved[0][1] == "# 更新后的内容".encode("utf-8") +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_put_document_saves_content -v` +Expected: FAIL with `405 Method Not Allowed` + +- [ ] **Step 11: Add `PUT /api/documents/{key:path}`** + +```python +# backend/app/routes/documents.py (add below get_document) +@router.put("/api/documents/{key:path}") +def save_document( + key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service) +): + return service.save_document(key, body.content.encode("utf-8"), body.contentType) +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_put_document_saves_content -v` +Expected: PASS + +- [ ] **Step 13: Write the failing test for `DELETE /api/documents/{key}`** + +```python +def test_delete_document_returns_204(client): + response = client.delete("/api/documents/产品文档/架构设计.md") + assert response.status_code == 204 + assert response.content == b"" + assert client.fake_service.deleted == ["产品文档/架构设计.md"] +``` + +- [ ] **Step 14: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_delete_document_returns_204 -v` +Expected: FAIL with `405 Method Not Allowed` + +- [ ] **Step 15: Add `DELETE /api/documents/{key:path}`** + +```python +# backend/app/routes/documents.py (add below save_document) +@router.delete("/api/documents/{key:path}", status_code=204) +def delete_document(key: str, service: DocumentService = Depends(get_document_service)): + service.delete_document(key) + return None +``` + +- [ ] **Step 16: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_delete_document_returns_204 -v` +Expected: PASS + +- [ ] **Step 17: Write the failing test for `POST /api/upload`** + +```python +def test_upload_document_returns_201_with_metadata(client): + response = client.post( + "/api/upload", + data={"path": "产品文档"}, + files={"file": ("新文档.md", b"# new content", "text/markdown")}, + ) + assert response.status_code == 201 + body = response.json() + assert body["key"] == "产品文档/新文档.md" + assert body["name"] == "新文档.md" + assert body["size"] == len(b"# new content") +``` + +- [ ] **Step 18: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_routes_documents.py::test_upload_document_returns_201_with_metadata -v` +Expected: FAIL with `404 Not Found` + +- [ ] **Step 19: Add `POST /api/upload`** + +```python +# backend/app/routes/documents.py (add below delete_document) +@router.post("/api/upload", status_code=201) +async def upload_document( + file: UploadFile = File(...), + path: str = Form(...), + service: DocumentService = Depends(get_document_service), +): + data = await file.read() + key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename + content_type = file.content_type or "application/octet-stream" + return service.upload_document(key, data, content_type) +``` + +- [ ] **Step 20: Run full test file to verify everything passes** + +Run: `cd backend && pytest tests/test_routes_documents.py -v` +Expected: PASS (all 6 tests) — note: at this point `app.main` does not yet include `documents.router`, so these tests will actually fail with 404s until Task 8 wires the router into `app`. Proceed to Task 8 before considering this task fully green end-to-end; the route functions themselves are complete and unit-correct. + +- [ ] **Step 21: Commit** + +```bash +git add backend/app/routes/__init__.py backend/app/routes/documents.py backend/tests/test_routes_documents.py +git commit -m "feat: add documents routes for list/get/save/delete/upload" +``` + +--- + +### Task 7: Search Route (`routes/search.py`) + +**Files:** +- Create: `backend/app/routes/search.py` +- Test: `backend/tests/test_routes_search.py` + +**Interfaces:** +- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1) +- Produces: `router` (APIRouter with `GET /api/search`), `get_search_client()` dependency provider — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. + +- [ ] **Step 1: Write the failing test for `GET /api/search` with matches** + +```python +# backend/tests/test_routes_search.py +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.routes.search import get_search_client + + +class FakeSearchClient: + def __init__(self): + self.queries = [] + + def search(self, query): + self.queries.append(query) + if query == "检索": + return [ + { + "key": "产品文档/架构设计.md", + "title": "架构设计.md", + "path": "产品文档", + "snippet": "支持全文检索功能", + } + ] + 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 "检索" in body["results"][0]["snippet"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_matching_results -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes.search'` + +- [ ] **Step 3: Write `search.py` with `GET /api/search`** + +```python +# backend/app/routes/search.py +from functools import lru_cache + +import meilisearch +from fastapi import APIRouter, Depends + +from app.config import settings +from app.search_client import SearchClient + +router = APIRouter() + + +@lru_cache +def get_search_client() -> SearchClient: + meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) + return SearchClient(meili_sdk, settings.meilisearch_index) + + +@router.get("/api/search") +def search(q: str = "", client: SearchClient = Depends(get_search_client)): + return {"results": client.search(q)} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_matching_results -v` +Expected: PASS — note: this test will actually return 404 until Task 8 wires `search.router` into `app.main`; the route logic itself is complete at this point. + +- [ ] **Step 5: Write the failing test for `GET /api/search` with no matches** + +```python +def test_search_returns_empty_results_for_no_match(client): + response = client.get("/api/search", params={"q": "不存在的关键词"}) + assert response.status_code == 200 + assert response.json() == {"results": []} +``` + +- [ ] **Step 6: Run test (already satisfied by Step 3's implementation once wired in Task 8)** + +Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_empty_results_for_no_match -v` +Expected: PASS after Task 8 wires the router + +- [ ] **Step 7: Write the failing test asserting the query string is forwarded** + +```python +def test_search_passes_query_string_to_search_client(client): + client.get("/api/search", params={"q": "MinIO"}) + assert client.fake_client.queries == ["MinIO"] +``` + +- [ ] **Step 8: Run test (already satisfied by Step 3's implementation once wired in Task 8)** + +Run: `cd backend && pytest tests/test_routes_search.py::test_search_passes_query_string_to_search_client -v` +Expected: PASS after Task 8 wires the router + +- [ ] **Step 9: Commit** + +```bash +git add backend/app/routes/search.py backend/tests/test_routes_search.py +git commit -m "feat: add search route forwarding queries to SearchClient" +``` + +--- + +### Task 8: Wire Routers into `main.py` and End-to-End Smoke Test + +**Files:** +- Edit: `backend/app/main.py` +- Edit: `backend/tests/test_main.py` + +**Interfaces:** +- Consumes: `documents.router`, `documents.get_document_service` (Task 6); `search.router`, `search.get_search_client` (Task 7); `DocumentNotFoundError` (Task 5) +- Produces: fully wired `app.main.app` — the production FastAPI entrypoint served by `uvicorn app.main:app` (per `Dockerfile` from Task 1) and the object the frontend's API client (built separately) will call against. + +- [ ] **Step 1: Write the failing end-to-end smoke test** + +```python +# backend/tests/test_main.py (append to existing file from Task 1) +from app.document_service import DocumentNotFoundError +from app.routes.documents import get_document_service +from app.routes.search import get_search_client + + +class FakeDocumentService: + def __init__(self): + self.storage = {} + + def list_documents(self, path): + prefix = path if not path or path.endswith("/") else f"{path}/" + files = [ + {"key": key, "name": key[len(prefix):], "size": len(data), "modifiedAt": "2026-08-01T10:00:00Z"} + for key, data in self.storage.items() + if key.startswith(prefix) + ] + return {"folders": [], "files": files} + + def get_document(self, key): + if key not in self.storage: + raise DocumentNotFoundError(key) + return {"key": key, "content": self.storage[key].decode("utf-8"), "contentType": "text/markdown"} + + def save_document(self, key, content, content_type): + self.storage[key] = content + return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} + + def delete_document(self, key): + self.storage.pop(key, None) + + def upload_document(self, key, data, content_type): + self.storage[key] = data + return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)} + + +class FakeSearchClient: + def search(self, query): + return [ + { + "key": "产品文档/架构设计.md", + "title": "架构设计.md", + "path": "产品文档", + "snippet": "全文检索示例", + } + ] + + +@pytest.fixture +def e2e_client(): + fake_service = FakeDocumentService() + fake_search = FakeSearchClient() + app.dependency_overrides[get_document_service] = lambda: fake_service + app.dependency_overrides[get_search_client] = lambda: fake_search + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +def test_end_to_end_upload_get_list_search_delete_flow(e2e_client): + upload_response = e2e_client.post( + "/api/upload", + data={"path": "产品文档"}, + files={"file": ("架构设计.md", "# 架构设计\n\nDocHub 支持全文检索。".encode("utf-8"), "text/markdown")}, + ) + assert upload_response.status_code == 201 + key = upload_response.json()["key"] + assert key == "产品文档/架构设计.md" + + get_response = e2e_client.get(f"/api/documents/{key}") + assert get_response.status_code == 200 + assert "架构设计" in get_response.json()["content"] + + list_response = e2e_client.get("/api/documents", params={"path": "产品文档"}) + assert list_response.status_code == 200 + assert any(f["key"] == key for f in list_response.json()["files"]) + + search_response = e2e_client.get("/api/search", params={"q": "检索"}) + assert search_response.status_code == 200 + assert search_response.json()["results"][0]["key"] == "产品文档/架构设计.md" + + delete_response = e2e_client.delete(f"/api/documents/{key}") + assert delete_response.status_code == 204 + + missing_response = e2e_client.get(f"/api/documents/{key}") + assert missing_response.status_code == 404 +``` + +Also add `import pytest` at the top of `backend/tests/test_main.py` if not already present from Task 1. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/test_main.py::test_end_to_end_upload_get_list_search_delete_flow -v` +Expected: FAIL with `404 Not Found` on the first `/api/upload` call, since `documents.router` and `search.router` are not yet included in `app` + +- [ ] **Step 3: Update `main.py` to include both routers** + +```python +# backend/app/main.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.routes import documents, search + +app = FastAPI(title="DocHub API") + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(documents.router) +app.include_router(search.router) + + +@app.get("/health") +def health(): + return {"status": "ok"} +``` + +- [ ] **Step 4: Run the full backend test suite to verify everything passes** + +Run: `cd backend && pytest tests/ -v` +Expected: PASS (all tests across `test_main.py`, `test_text_extract.py`, `test_minio_client.py`, `test_search_client.py`, `test_document_service.py`, `test_routes_documents.py`, `test_routes_search.py`) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/main.py backend/tests/test_main.py +git commit -m "feat: wire documents and search routers into FastAPI app with e2e smoke test" +``` + +--- + +### Task 9: Project scaffolding + minimal App shell + +**Files:** +- Create: `frontend/package.json` +- Create: `frontend/vite.config.ts` +- Create: `frontend/tsconfig.json` +- Create: `frontend/index.html` +- Create: `frontend/tests/setup.ts` +- Create: `frontend/src/main.tsx` +- Create: `frontend/src/App.tsx` +- Create: `frontend/Dockerfile` +- Create: `frontend/nginx.conf` +- Test: `frontend/tests/App.test.tsx` + +**Interfaces:** +- Consumes: none +- Produces: `export default function App(): JSX.Element` — imported by `frontend/src/main.tsx` and extended by Task 15's integration wiring. + +- [ ] **Step 1: Create the project configuration files** + +`frontend/package.json`: + +```json +{ + "name": "dochub-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@monaco-editor/react": "4.6.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.4.8", + "@testing-library/react": "16.0.0", + "@testing-library/user-event": "14.5.2", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "@vitejs/plugin-react": "4.3.1", + "jsdom": "24.1.1", + "typescript": "5.5.4", + "vite": "5.4.1", + "vitest": "2.0.5" + } +} +``` + +`frontend/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "tests", "vite.config.ts"] +} +``` + +`frontend/vite.config.ts`: + +```ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + }, +}); +``` + +`frontend/index.html`: + +```html + + + + + DocHub + + +
+ + + +``` + +`frontend/tests/setup.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +- [ ] **Step 2: Write the failing test** + +`frontend/tests/App.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { test, expect } from 'vitest'; +import App from '../src/App'; + +test('renders the DocHub heading', () => { + render(); + expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm install && npm run test -- App.test.tsx` +Expected: FAIL with "Cannot find module '../src/App' or its corresponding type declarations." + +- [ ] **Step 4: Write minimal implementation** + +`frontend/src/main.tsx`: + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +); +``` + +`frontend/src/App.tsx`: + +```tsx +export default function App() { + return ( +
+

DocHub

+
+ ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm run test -- App.test.tsx` +Expected: PASS + +- [ ] **Step 6: Add Docker build/serve files** + +`frontend/Dockerfile`: + +```dockerfile +# ---- build stage ---- +FROM node:20.16-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +RUN npm run build + +# ---- serve stage ---- +FROM nginx:1.27-alpine AS serve +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +`frontend/nginx.conf`: + +```nginx +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/package.json frontend/vite.config.ts frontend/tsconfig.json frontend/index.html frontend/tests/setup.ts frontend/src/main.tsx frontend/src/App.tsx frontend/tests/App.test.tsx frontend/Dockerfile frontend/nginx.conf +git commit -m "feat: scaffold frontend project with Vite, Vitest and Docker build" +``` + +--- + +### Task 10: api/client.ts + +**Files:** +- Create: `frontend/src/api/client.ts` +- Test: `frontend/tests/api/client.test.ts` + +**Interfaces:** +- Consumes: global `fetch`, `import.meta.env.VITE_API_BASE_URL` +- Produces: `DocumentFile`, `DocumentListResponse`, `DocumentContent`, `SearchResult`, `SearchResponse`, `SaveDocumentResponse`, `UploadDocumentResponse` interfaces; `listDocuments(path: string): Promise`, `getDocument(key: string): Promise`, `saveDocument(key: string, content: string, contentType: string): Promise`, `deleteDocument(key: string): Promise`, `uploadDocument(file: File, path: string): Promise`, `search(query: string): Promise` — consumed by `FileExplorer`, `DocumentEditor`, `SearchBar`, `SearchResults`, `UploadButton`, `App`. + +- [ ] **Step 1: Write the failing tests for read operations** + +`frontend/tests/api/client.test.ts`: + +```ts +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { listDocuments, getDocument } from '../../src/api/client'; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +test('listDocuments calls GET /api/documents with the path query param and returns parsed JSON', async () => { + const mockResponse = { + folders: ['产品文档'], + files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }], + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await listDocuments('产品文档'); + + expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`); + expect(result).toEqual(mockResponse); +}); + +test('listDocuments throws when the response is not ok', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch; + + await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500'); +}); + +test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => { + const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await getDocument('产品文档/架构设计.md'); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}` + ); + expect(result).toEqual(mockResponse); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- client.test.ts` +Expected: FAIL with "Cannot find module '../../src/api/client'" + +- [ ] **Step 3: Write minimal implementation for read operations** + +`frontend/src/api/client.ts`: + +```ts +export interface DocumentFile { + key: string; + name: string; + size: number; + modifiedAt: string; +} + +export interface DocumentListResponse { + folders: string[]; + files: DocumentFile[]; +} + +export interface DocumentContent { + key: string; + content: string; + contentType: string; +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; + +function encodeKeyPath(key: string): string { + return key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +export async function listDocuments(path: string): Promise { + 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 { + const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`); + if (!response.ok) { + throw new Error(`Failed to get document: ${response.status}`); + } + return response.json(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- client.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing tests for write operations** + +Append to `frontend/tests/api/client.test.ts`: + +```ts +import { saveDocument, deleteDocument } from '../../src/api/client'; + +test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => { + const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown'); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '# 新内容', contentType: 'text/markdown' }), + } + ); + expect(result).toEqual(mockResponse); +}); + +test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch; + + await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined(); + + expect(global.fetch).toHaveBeenCalledWith( + `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, + { method: 'DELETE' } + ); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npm run test -- client.test.ts` +Expected: FAIL with "saveDocument is not a function" / "deleteDocument is not a function" + +- [ ] **Step 7: Write minimal implementation for write operations** + +Append to `frontend/src/api/client.ts`: + +```ts +export interface SaveDocumentResponse { + key: string; + updatedAt: string; +} + +export async function saveDocument( + key: string, + content: string, + contentType: string +): Promise { + 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 { + const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error(`Failed to delete document: ${response.status}`); + } +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `npm run test -- client.test.ts` +Expected: PASS + +- [ ] **Step 9: Write the failing test for upload** + +Append to `frontend/tests/api/client.test.ts`: + +```ts +import { uploadDocument } from '../../src/api/client'; + +test('uploadDocument posts a multipart form with file and path fields', async () => { + const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' }); + + const result = await uploadDocument(file, '产品文档'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (global.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/api/upload'); + expect(init.method).toBe('POST'); + const body = init.body as FormData; + expect(body.get('file')).toBe(file); + expect(body.get('path')).toBe('产品文档'); + expect(result).toEqual(mockResponse); +}); +``` + +- [ ] **Step 10: Run test to verify it fails** + +Run: `npm run test -- client.test.ts` +Expected: FAIL with "uploadDocument is not a function" + +- [ ] **Step 11: Write minimal implementation for upload** + +Append to `frontend/src/api/client.ts`: + +```ts +export interface UploadDocumentResponse { + key: string; + name: string; + size: number; +} + +export async function uploadDocument(file: File, path: string): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('path', path); + const response = await fetch(`${API_BASE_URL}/api/upload`, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + throw new Error(`Failed to upload document: ${response.status}`); + } + return response.json(); +} +``` + +- [ ] **Step 12: Run test to verify it passes** + +Run: `npm run test -- client.test.ts` +Expected: PASS + +- [ ] **Step 13: Write the failing test for search** + +Append to `frontend/tests/api/client.test.ts`: + +```ts +import { search } from '../../src/api/client'; + +test('search calls GET /api/search with the q query param and returns parsed JSON', async () => { + const mockResponse = { + results: [ + { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...检索...' }, + ], + }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + + const result = await search('检索'); + + expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`); + expect(result).toEqual(mockResponse); +}); +``` + +- [ ] **Step 14: Run test to verify it fails** + +Run: `npm run test -- client.test.ts` +Expected: FAIL with "search is not a function" + +- [ ] **Step 15: Write minimal implementation for search** + +Append to `frontend/src/api/client.ts`: + +```ts +export interface SearchResult { + key: string; + title: string; + path: string; + snippet: string; +} + +export interface SearchResponse { + results: SearchResult[]; +} + +export async function search(query: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/search?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error(`Failed to search: ${response.status}`); + } + return response.json(); +} +``` + +- [ ] **Step 16: Run test to verify it passes** + +Run: `npm run test -- client.test.ts` +Expected: PASS + +- [ ] **Step 17: Commit** + +```bash +git add frontend/src/api/client.ts frontend/tests/api/client.test.ts +git commit -m "feat: add typed API client for documents, upload and search" +``` + +--- + +### Task 11: FileExplorer.tsx + +**Files:** +- Create: `frontend/src/components/FileExplorer.tsx` +- Test: `frontend/tests/components/FileExplorer.test.tsx` + +**Interfaces:** +- Consumes: `listDocuments(path: string): Promise`, `DocumentFile` from `../api/client` +- Produces: `export default function FileExplorer({ onSelectFile, selectedKey }: { onSelectFile: (key: string) => void; selectedKey?: string | null }): JSX.Element` — consumed by `App.tsx` in Task 15. + +- [ ] **Step 1: Write the failing tests for rendering and file selection** + +`frontend/tests/components/FileExplorer.test.tsx`: + +```tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, expect, beforeEach } from 'vitest'; +import FileExplorer from '../../src/components/FileExplorer'; +import { listDocuments } from '../../src/api/client'; + +vi.mock('../../src/api/client'); + +beforeEach(() => { + vi.mocked(listDocuments).mockReset(); +}); + +test('renders folders and files returned by listDocuments for the root path', async () => { + vi.mocked(listDocuments).mockResolvedValue({ + folders: ['产品文档'], + files: [{ key: '需求说明.md', name: '需求说明.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }], + }); + + render(); + + 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(); + + const fileRow = await screen.findByText('架构设计.md'); + fireEvent.click(fileRow); + + expect(onSelectFile).toHaveBeenCalledWith('架构设计.md'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test -- FileExplorer.test.tsx` +Expected: FAIL with "Cannot find module '../../src/components/FileExplorer'" + +- [ ] **Step 3: Write minimal implementation** + +`frontend/src/components/FileExplorer.tsx`: + +```tsx +import { useEffect, useState } from 'react'; +import { listDocuments, type DocumentFile } from '../api/client'; + +interface FileExplorerProps { + onSelectFile: (key: string) => void; + selectedKey?: string | null; +} + +export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) { + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + listDocuments('') + .then((data) => { + setFolders(data.folders); + setFiles(data.files); + }) + .catch(() => setError('无法加载文档目录')); + }, []); + + if (error) { + return
{error}
; + } + + return ( +
+ {folders.map((folder) => ( +
+ {folder} +
+ ))} + {files.map((file) => ( +
onSelectFile(file.key)} + > + {file.name} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- FileExplorer.test.tsx` +Expected: PASS + +- [ ] **Step 5: Write the failing tests for folder expansion and error state** + +Append to `frontend/tests/components/FileExplorer.test.tsx`: + +```tsx +test('clicking a folder loads and shows its nested contents', async () => { + vi.mocked(listDocuments).mockImplementation((path: string) => { + if (path === '') { + return Promise.resolve({ folders: ['产品文档'], files: [] }); + } + if (path === '产品文档') { + return Promise.resolve({ + folders: [], + files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 300, modifiedAt: '2026-08-01T10:00:00Z' }], + }); + } + return Promise.resolve({ folders: [], files: [] }); + }); + + render(); + + 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(); + + expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npm run test -- FileExplorer.test.tsx` +Expected: FAIL with "Unable to find an element with the text: 架构设计.md" (folder click is not wired to load nested contents yet) + +- [ ] **Step 7: Write the full implementation with lazy-loaded nested folders** + +Replace the contents of `frontend/src/components/FileExplorer.tsx`: + +```tsx +import { useEffect, useState } from 'react'; +import { listDocuments, type DocumentFile } from '../api/client'; + +interface FileExplorerProps { + onSelectFile: (key: string) => void; + selectedKey?: string | null; +} + +export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) { + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + listDocuments('') + .then((data) => { + setFolders(data.folders); + setFiles(data.files); + }) + .catch(() => setError('无法加载文档目录')); + }, []); + + if (error) { + return
{error}
; + } + + return ( +
+ {folders.map((folder) => ( + + ))} + {files.map((file) => ( + + ))} +
+ ); +} + +function FileRow({ + file, + onSelectFile, + selectedKey, +}: { + file: DocumentFile; + onSelectFile: (key: string) => void; + selectedKey?: string | null; +}) { + return ( +
onSelectFile(file.key)} + > + {file.name} +
+ ); +} + +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([]); + const [files, setFiles] = useState([]); + + function toggle() { + if (!expanded && !loaded) { + listDocuments(path).then((data) => { + setFolders(data.folders); + setFiles(data.files); + setLoaded(true); + }); + } + setExpanded((prev) => !prev); + } + + return ( +
+
+ {name} +
+ {expanded && ( +
+ {folders.map((folder) => ( + + ))} + {files.map((file) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `npm run test -- FileExplorer.test.tsx` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add frontend/src/components/FileExplorer.tsx frontend/tests/components/FileExplorer.test.tsx +git commit -m "feat: add FileExplorer with lazy-loaded folder tree" +``` + +--- + +### Task 12: DocumentEditor.tsx + +**Files:** +- Create: `frontend/src/components/DocumentEditor.tsx` +- Test: `frontend/tests/components/DocumentEditor.test.tsx` + +**Interfaces:** +- Consumes: `getDocument(key: string): Promise`, `saveDocument(key: string, content: string, contentType: string): Promise` from `../api/client`; `Editor` default export from `@monaco-editor/react` +- Produces: `export default function DocumentEditor({ documentKey }: { documentKey: string }): JSX.Element` — consumed by `App.tsx` in Task 15. + +- [ ] **Step 1: Write the failing test for loading and displaying content** + +`frontend/tests/components/DocumentEditor.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { vi, test, expect, beforeEach } from 'vitest'; +import DocumentEditor from '../../src/components/DocumentEditor'; +import { getDocument, saveDocument } from '../../src/api/client'; + +vi.mock('../../src/api/client'); + +vi.mock('@monaco-editor/react', () => ({ + default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => ( +