# 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 }) => (