diff --git a/.gitignore b/.gitignore index aeb68e8..7499087 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ dist/ # Editor .DS_Store + +# Docs +docs/ diff --git a/docs/superpowers/plans/2026-08-01-dochub-implementation.md b/docs/superpowers/plans/2026-08-01-dochub-implementation.md deleted file mode 100644 index cade9b1..0000000 --- a/docs/superpowers/plans/2026-08-01-dochub-implementation.md +++ /dev/null @@ -1,3609 +0,0 @@ -# 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 `routers/search.py` in Task 7. - -- [ ] **Step 1: Write the failing test for `index_document`** - -```python -# backend/tests/test_search_client.py -from app.search_client import SearchClient - - -class FakeIndex: - def __init__(self): - self.documents = {} - self.deleted_ids = [] - - def add_documents(self, docs): - for doc in docs: - self.documents[doc["id"]] = doc - - def delete_document(self, doc_id): - self.documents.pop(doc_id, None) - self.deleted_ids.append(doc_id) - - def search(self, query, params=None): - hits = [] - for doc in self.documents.values(): - if query in doc["content"] or query in doc["title"]: - formatted = dict(doc) - if query in doc["content"]: - formatted["content"] = doc["content"].replace(query, f"{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 `routers/documents.py` in Task 6. - -- [ ] **Step 1: Write the failing test for `list_documents`** - -```python -# backend/tests/test_document_service.py -import pytest - -from app.document_service import DocumentService, DocumentNotFoundError - - -class FakeMinioClient: - def __init__(self): - self.entries = [] - self.stored = {} - self.deleted_keys = [] - self.put_calls = [] - - def list_objects(self, prefix): - return [e for e in self.entries if e["key"].startswith(prefix)] - - def get_object(self, key): - if key not in self.stored: - raise FileNotFoundError(key) - return self.stored[key] - - def put_object(self, key, data, content_type): - self.stored[key] = data - self.put_calls.append((key, content_type)) - - def delete_object(self, key): - if key not in self.stored: - raise FileNotFoundError(key) - del self.stored[key] - self.deleted_keys.append(key) - - -class FakeSearchClient: - def __init__(self, fail_on_index=False, fail_on_delete=False): - self.indexed = [] - self.deleted = [] - self.fail_on_index = fail_on_index - self.fail_on_delete = fail_on_delete - - def index_document(self, key, path, title, content): - if self.fail_on_index: - raise RuntimeError("meilisearch unavailable") - self.indexed.append({"key": key, "path": path, "title": title, "content": content}) - - def delete_document(self, key): - if self.fail_on_delete: - raise RuntimeError("meilisearch unavailable") - self.deleted.append(key) - - -def test_list_documents_at_root_returns_folders_and_files(): - minio_client = FakeMinioClient() - minio_client.entries = [ - {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None}, - {"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None}, - {"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, - ] - service = DocumentService(minio_client, FakeSearchClient()) - - result = service.list_documents("") - - assert result["folders"] == ["产品文档", "团队规范"] - assert result["files"] == [ - {"key": "README.md", "name": "README.md", "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, - ] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v` -Expected: FAIL with `ModuleNotFoundError: No module named 'app.document_service'` - -- [ ] **Step 3: Write minimal `DocumentService.list_documents`** - -```python -# backend/app/document_service.py -import logging -from datetime import datetime, timezone - -from app.text_extract import html_to_text, markdown_to_text - -logger = logging.getLogger(__name__) - - -class DocumentNotFoundError(Exception): - pass - - -def _guess_content_type(key: str) -> str: - if key.endswith(".md"): - return "text/markdown" - if key.endswith(".html") or key.endswith(".htm"): - return "text/html" - return "application/octet-stream" - - -class DocumentService: - def __init__(self, minio_client, search_client): - self._minio_client = minio_client - self._search_client = search_client - - def list_documents(self, path: str) -> dict: - prefix = path if not path or path.endswith("/") else f"{path}/" - entries = self._minio_client.list_objects(prefix) - folders = [] - files = [] - for entry in entries: - key = entry["key"] - if entry.get("is_dir"): - name = key[len(prefix):].rstrip("/") - if name: - folders.append(name) - else: - name = key[len(prefix):] - files.append({ - "key": key, - "name": name, - "size": entry["size"], - "modifiedAt": entry["modifiedAt"], - }) - return {"folders": folders, "files": files} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v` -Expected: PASS - -- [ ] **Step 5: Write the failing tests for `get_document` (found and not found)** - -```python -def test_get_document_returns_content_and_content_type(): - minio_client = FakeMinioClient() - minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8") - service = DocumentService(minio_client, FakeSearchClient()) - - result = service.get_document("产品文档/架构设计.md") - - assert result["key"] == "产品文档/架构设计.md" - assert result["contentType"] == "text/markdown" - assert "架构设计" in result["content"] - - -def test_get_document_raises_not_found_for_missing_key(): - service = DocumentService(FakeMinioClient(), FakeSearchClient()) - - with pytest.raises(DocumentNotFoundError): - service.get_document("不存在/文档.md") -``` - -- [ ] **Step 6: Run tests to verify they fail** - -Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v` -Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'get_document'` - -- [ ] **Step 7: Write `get_document`** - -```python -# backend/app/document_service.py (add inside DocumentService) - def get_document(self, key: str) -> dict: - try: - data = self._minio_client.get_object(key) - except FileNotFoundError as exc: - raise DocumentNotFoundError(key) from exc - return { - "key": key, - "content": data.decode("utf-8"), - "contentType": _guess_content_type(key), - } -``` - -- [ ] **Step 8: Run tests to verify they pass** - -Run: `cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v` -Expected: PASS - -- [ ] **Step 9: Write the failing tests for `save_document` (success and index-failure tolerance)** - -```python -def test_save_document_writes_to_minio_then_indexes_content(): - minio_client = FakeMinioClient() - search_client = FakeSearchClient() - service = DocumentService(minio_client, search_client) - content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8") - - result = service.save_document("产品文档/架构设计.md", content, "text/markdown") - - assert minio_client.stored["产品文档/架构设计.md"] == content - assert search_client.indexed[0]["key"] == "产品文档/架构设计.md" - assert search_client.indexed[0]["path"] == "产品文档" - assert search_client.indexed[0]["title"] == "架构设计.md" - assert "MinIO" in search_client.indexed[0]["content"] - assert "#" not in search_client.indexed[0]["content"] - assert "key" in result and "updatedAt" in result - - -def test_save_document_does_not_fail_when_index_update_fails(): - minio_client = FakeMinioClient() - search_client = FakeSearchClient(fail_on_index=True) - service = DocumentService(minio_client, search_client) - content = b"# doc" - - result = service.save_document("产品文档/架构设计.md", content, "text/markdown") - - assert minio_client.stored["产品文档/架构设计.md"] == content - assert result["key"] == "产品文档/架构设计.md" -``` - -- [ ] **Step 10: Run tests to verify they fail** - -Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v` -Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'save_document'` - -- [ ] **Step 11: Write `save_document`** - -```python -# backend/app/document_service.py (add inside DocumentService) - def _extract_text(self, content: bytes, content_type: str) -> str: - text = content.decode("utf-8", errors="ignore") - if content_type == "text/html": - return html_to_text(text) - if content_type == "text/markdown": - return markdown_to_text(text) - return text - - def save_document(self, key: str, content: bytes, content_type: str) -> dict: - self._minio_client.put_object(key, content, content_type) - try: - text = self._extract_text(content, content_type) - title = key.rsplit("/", 1)[-1] - path = key.rsplit("/", 1)[0] if "/" in key else "" - self._search_client.index_document(key, path, title, text) - except Exception: - logger.exception("Failed to update search index for %s", key) - return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()} -``` - -- [ ] **Step 12: Run tests to verify they pass** - -Run: `cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v` -Expected: PASS - -- [ ] **Step 13: Write the failing tests for `delete_document` (success and index-failure tolerance)** - -```python -def test_delete_document_removes_from_minio_and_search_index(): - minio_client = FakeMinioClient() - minio_client.stored["产品文档/旧文档.md"] = b"old content" - search_client = FakeSearchClient() - service = DocumentService(minio_client, search_client) - - service.delete_document("产品文档/旧文档.md") - - assert "产品文档/旧文档.md" not in minio_client.stored - assert search_client.deleted == ["产品文档/旧文档.md"] - - -def test_delete_document_does_not_fail_when_index_delete_fails(): - minio_client = FakeMinioClient() - minio_client.stored["产品文档/旧文档.md"] = b"old content" - search_client = FakeSearchClient(fail_on_delete=True) - service = DocumentService(minio_client, search_client) - - service.delete_document("产品文档/旧文档.md") - - assert "产品文档/旧文档.md" not in minio_client.stored -``` - -- [ ] **Step 14: Run tests to verify they fail** - -Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v` -Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'delete_document'` - -- [ ] **Step 15: Write `delete_document`** - -```python -# backend/app/document_service.py (add inside DocumentService) - def delete_document(self, key: str) -> None: - self._minio_client.delete_object(key) - try: - self._search_client.delete_document(key) - except Exception: - logger.exception("Failed to delete search index entry for %s", key) -``` - -- [ ] **Step 16: Run tests to verify they pass** - -Run: `cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v` -Expected: PASS - -- [ ] **Step 17: Write the failing test for `upload_document`** - -```python -def test_upload_document_writes_file_and_returns_metadata(): - minio_client = FakeMinioClient() - service = DocumentService(minio_client, FakeSearchClient()) - data = b"# uploaded doc" - - result = service.upload_document("产品文档/新上传.md", data, "text/markdown") - - assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)} - assert minio_client.stored["产品文档/新上传.md"] == data -``` - -- [ ] **Step 18: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_document_service.py::test_upload_document_writes_file_and_returns_metadata -v` -Expected: FAIL with `AttributeError: 'DocumentService' object has no attribute 'upload_document'` - -- [ ] **Step 19: Write `upload_document`** - -```python -# backend/app/document_service.py (add inside DocumentService) - def upload_document(self, key: str, data: bytes, content_type: str) -> dict: - self.save_document(key, data, content_type) - name = key.rsplit("/", 1)[-1] - return {"key": key, "name": name, "size": len(data)} -``` - -- [ ] **Step 20: Run full test file to verify everything passes** - -Run: `cd backend && pytest tests/test_document_service.py -v` -Expected: PASS (all 8 tests) - -- [ ] **Step 21: Commit** - -```bash -git add backend/app/document_service.py backend/tests/test_document_service.py -git commit -m "feat: add DocumentService coordinating MinIO writes and search indexing" -``` - ---- - -### Task 6: Documents Router (`routers/documents.py`) - -**Files:** -- Create: `backend/app/dependencies.py` -- Create: `backend/app/routers/__init__.py` -- Create: `backend/app/routers/documents.py` -- Test: `backend/tests/test_routers_documents.py` - -**Interfaces:** -- Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1) -- Produces: `app.dependencies.get_document_service()` dependency provider (FastAPI's official "Bigger Applications" layout keeps shared dependency providers in a dedicated `dependencies.py`, separate from routers); `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`) — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. - -- [ ] **Step 1: Write the failing test for `GET /api/documents`** - -```python -# backend/tests/test_routers_documents.py -import pytest -from fastapi.testclient import TestClient - -from app.main import app -from app.dependencies import get_document_service -from app.document_service import DocumentNotFoundError - - -class FakeDocumentService: - def __init__(self): - self.saved = [] - self.deleted = [] - self.uploaded = [] - - def list_documents(self, path): - return { - "folders": ["产品文档", "团队规范"], - "files": [ - {"key": "架构设计.md", "name": "架构设计.md", "size": 512, "modifiedAt": "2026-08-01T10:00:00Z"}, - ], - } - - def get_document(self, key): - if key == "不存在/文档.md": - raise DocumentNotFoundError(key) - return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"} - - def save_document(self, key, content, content_type): - self.saved.append((key, content, content_type)) - return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} - - def delete_document(self, key): - self.deleted.append(key) - - def upload_document(self, key, data, content_type): - self.uploaded.append((key, data, content_type)) - return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)} - - -@pytest.fixture -def client(): - fake_service = FakeDocumentService() - app.dependency_overrides[get_document_service] = lambda: fake_service - with TestClient(app) as test_client: - test_client.fake_service = fake_service - yield test_client - app.dependency_overrides.clear() - - -def test_list_documents_returns_folders_and_files(client): - response = client.get("/api/documents", params={"path": "产品文档"}) - assert response.status_code == 200 - body = response.json() - assert body["folders"] == ["产品文档", "团队规范"] - assert body["files"][0]["key"] == "架构设计.md" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v` -Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers'` - -- [ ] **Step 3: Create `backend/app/dependencies.py`, `backend/app/routers/__init__.py`, and write `documents.py` with `GET /api/documents`** - -```python -# backend/app/dependencies.py -from functools import lru_cache - -import meilisearch -from minio import Minio - -from app.config import settings -from app.document_service import DocumentService -from app.minio_client import MinioClient -from app.search_client import SearchClient - - -@lru_cache -def get_document_service() -> DocumentService: - minio_sdk = Minio( - settings.minio_endpoint, - access_key=settings.minio_access_key, - secret_key=settings.minio_secret_key, - secure=False, - ) - meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) - minio_client = MinioClient(minio_sdk, settings.minio_bucket) - search_client = SearchClient(meili_sdk, settings.meilisearch_index) - return DocumentService(minio_client, search_client) - - -@lru_cache -def get_search_client() -> SearchClient: - meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) - return SearchClient(meili_sdk, settings.meilisearch_index) -``` - -```python -# backend/app/routers/__init__.py -``` - -```python -# backend/app/routers/documents.py -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile -from pydantic import BaseModel - -from app.dependencies import get_document_service -from app.document_service import DocumentNotFoundError, DocumentService - -router = APIRouter() - - -class SaveDocumentRequest(BaseModel): - content: str - contentType: str - - -@router.get("/api/documents") -def list_documents(path: str = "", service: DocumentService = Depends(get_document_service)): - return service.list_documents(path) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v` -Expected: PASS - -- [ ] **Step 5: Write the failing tests for `GET /api/documents/{key}` (found and 404)** - -```python -def test_get_document_returns_content(client): - response = client.get("/api/documents/产品文档/架构设计.md") - assert response.status_code == 200 - assert response.json()["contentType"] == "text/markdown" - - -def test_get_document_returns_404_for_missing_key(client): - response = client.get("/api/documents/不存在/文档.md") - assert response.status_code == 404 -``` - -- [ ] **Step 6: Run tests to verify they fail** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v` -Expected: FAIL with `404 Not Found` (route not registered) - -- [ ] **Step 7: Add `GET /api/documents/{key:path}`** - -```python -# backend/app/routers/documents.py (add below list_documents) -@router.get("/api/documents/{key:path}") -def get_document(key: str, service: DocumentService = Depends(get_document_service)): - try: - return service.get_document(key) - except DocumentNotFoundError: - raise HTTPException(status_code=404, detail="Document not found") -``` - -- [ ] **Step 8: Run tests to verify they pass** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v` -Expected: PASS - -- [ ] **Step 9: Write the failing test for `PUT /api/documents/{key}`** - -```python -def test_put_document_saves_content(client): - response = client.put( - "/api/documents/产品文档/架构设计.md", - json={"content": "# 更新后的内容", "contentType": "text/markdown"}, - ) - assert response.status_code == 200 - assert response.json()["key"] == "产品文档/架构设计.md" - assert client.fake_service.saved[0][0] == "产品文档/架构设计.md" - assert client.fake_service.saved[0][1] == "# 更新后的内容".encode("utf-8") -``` - -- [ ] **Step 10: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v` -Expected: FAIL with `405 Method Not Allowed` - -- [ ] **Step 11: Add `PUT /api/documents/{key:path}`** - -```python -# backend/app/routers/documents.py (add below get_document) -@router.put("/api/documents/{key:path}") -def save_document( - key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service) -): - return service.save_document(key, body.content.encode("utf-8"), body.contentType) -``` - -- [ ] **Step 12: Run test to verify it passes** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v` -Expected: PASS - -- [ ] **Step 13: Write the failing test for `DELETE /api/documents/{key}`** - -```python -def test_delete_document_returns_204(client): - response = client.delete("/api/documents/产品文档/架构设计.md") - assert response.status_code == 204 - assert response.content == b"" - assert client.fake_service.deleted == ["产品文档/架构设计.md"] -``` - -- [ ] **Step 14: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v` -Expected: FAIL with `405 Method Not Allowed` - -- [ ] **Step 15: Add `DELETE /api/documents/{key:path}`** - -```python -# backend/app/routers/documents.py (add below save_document) -@router.delete("/api/documents/{key:path}", status_code=204) -def delete_document(key: str, service: DocumentService = Depends(get_document_service)): - service.delete_document(key) - return None -``` - -- [ ] **Step 16: Run test to verify it passes** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v` -Expected: PASS - -- [ ] **Step 17: Write the failing test for `POST /api/upload`** - -```python -def test_upload_document_returns_201_with_metadata(client): - response = client.post( - "/api/upload", - data={"path": "产品文档"}, - files={"file": ("新文档.md", b"# new content", "text/markdown")}, - ) - assert response.status_code == 201 - body = response.json() - assert body["key"] == "产品文档/新文档.md" - assert body["name"] == "新文档.md" - assert body["size"] == len(b"# new content") -``` - -- [ ] **Step 18: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_routers_documents.py::test_upload_document_returns_201_with_metadata -v` -Expected: FAIL with `404 Not Found` - -- [ ] **Step 19: Add `POST /api/upload`** - -```python -# backend/app/routers/documents.py (add below delete_document) -@router.post("/api/upload", status_code=201) -async def upload_document( - file: UploadFile = File(...), - path: str = Form(...), - service: DocumentService = Depends(get_document_service), -): - data = await file.read() - key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename - content_type = file.content_type or "application/octet-stream" - return service.upload_document(key, data, content_type) -``` - -- [ ] **Step 20: Run full test file to verify everything passes** - -Run: `cd backend && pytest tests/test_routers_documents.py -v` -Expected: PASS (all 6 tests) — note: at this point `app.main` does not yet include `documents.router`, so these tests will actually fail with 404s until Task 8 wires the router into `app`. Proceed to Task 8 before considering this task fully green end-to-end; the route functions themselves are complete and unit-correct. - -- [ ] **Step 21: Commit** - -```bash -git add backend/app/dependencies.py backend/app/routers/__init__.py backend/app/routers/documents.py backend/tests/test_routers_documents.py -git commit -m "feat: add documents router for list/get/save/delete/upload" -``` - ---- - -### Task 7: Search Router (`routers/search.py`) - -**Files:** -- Modify: `backend/app/dependencies.py` -- Create: `backend/app/routers/search.py` -- Test: `backend/tests/test_routers_search.py` - -**Interfaces:** -- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1), `app.dependencies.get_search_client` (Task 6, already defined) -- Produces: `router` (APIRouter with `GET /api/search`) — consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. - -- [ ] **Step 1: Write the failing test for `GET /api/search` with matches** - -```python -# backend/tests/test_routers_search.py -import pytest -from fastapi.testclient import TestClient - -from app.main import app -from app.dependencies import get_search_client - - -class FakeSearchClient: - def __init__(self): - self.queries = [] - - def search(self, query): - self.queries.append(query) - if query == "检索": - return [ - { - "key": "产品文档/架构设计.md", - "title": "架构设计.md", - "path": "产品文档", - "snippet": "支持全文检索功能", - } - ] - 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_routers_search.py::test_search_returns_matching_results -v` -Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers.search'` - -- [ ] **Step 3: Write `search.py` with `GET /api/search`, reusing `get_search_client` from `dependencies.py`** - -```python -# backend/app/routers/search.py -from fastapi import APIRouter, Depends - -from app.dependencies import get_search_client -from app.search_client import SearchClient - -router = APIRouter() - - -@router.get("/api/search") -def search(q: str = "", client: SearchClient = Depends(get_search_client)): - return {"results": client.search(q)} -``` - -Note: `get_search_client` was already added to `backend/app/dependencies.py` in Task 6's Step 3. This task only adds the router that consumes it. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v` -Expected: PASS — note: this test will actually return 404 until Task 8 wires `search.router` into `app.main`; the route logic itself is complete at this point. - -- [ ] **Step 5: Write the failing test for `GET /api/search` with no matches** - -```python -def test_search_returns_empty_results_for_no_match(client): - response = client.get("/api/search", params={"q": "不存在的关键词"}) - assert response.status_code == 200 - assert response.json() == {"results": []} -``` - -- [ ] **Step 6: Run test (already satisfied by Step 3's implementation once wired in Task 8)** - -Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_empty_results_for_no_match -v` -Expected: PASS after Task 8 wires the router - -- [ ] **Step 7: Write the failing test asserting the query string is forwarded** - -```python -def test_search_passes_query_string_to_search_client(client): - client.get("/api/search", params={"q": "MinIO"}) - assert client.fake_client.queries == ["MinIO"] -``` - -- [ ] **Step 8: Run test (already satisfied by Step 3's implementation once wired in Task 8)** - -Run: `cd backend && pytest tests/test_routers_search.py::test_search_passes_query_string_to_search_client -v` -Expected: PASS after Task 8 wires the router - -- [ ] **Step 9: Commit** - -```bash -git add backend/app/routers/search.py backend/tests/test_routers_search.py -git commit -m "feat: add search route forwarding queries to SearchClient" -``` - ---- - -### Task 8: Wire Routers into `main.py` and End-to-End Smoke Test - -**Files:** -- Edit: `backend/app/main.py` -- Edit: `backend/tests/test_main.py` - -**Interfaces:** -- Consumes: `documents.router` (Task 6); `search.router` (Task 7); `app.dependencies.get_document_service`, `app.dependencies.get_search_client` (Task 6); `DocumentNotFoundError` (Task 5) -- Produces: fully wired `app.main.app` — the production FastAPI entrypoint served by `uvicorn app.main:app` (per `Dockerfile` from Task 1) and the object the frontend's API client (built separately) will call against. - -- [ ] **Step 1: Write the failing end-to-end smoke test** - -```python -# backend/tests/test_main.py (append to existing file from Task 1) -from app.document_service import DocumentNotFoundError -from app.dependencies import get_document_service, get_search_client - - -class FakeDocumentService: - def __init__(self): - self.storage = {} - - def list_documents(self, path): - prefix = path if not path or path.endswith("/") else f"{path}/" - files = [ - {"key": key, "name": key[len(prefix):], "size": len(data), "modifiedAt": "2026-08-01T10:00:00Z"} - for key, data in self.storage.items() - if key.startswith(prefix) - ] - return {"folders": [], "files": files} - - def get_document(self, key): - if key not in self.storage: - raise DocumentNotFoundError(key) - return {"key": key, "content": self.storage[key].decode("utf-8"), "contentType": "text/markdown"} - - def save_document(self, key, content, content_type): - self.storage[key] = content - return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} - - def delete_document(self, key): - self.storage.pop(key, None) - - def upload_document(self, key, data, content_type): - self.storage[key] = data - return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)} - - -class FakeSearchClient: - def search(self, query): - return [ - { - "key": "产品文档/架构设计.md", - "title": "架构设计.md", - "path": "产品文档", - "snippet": "全文检索示例", - } - ] - - -@pytest.fixture -def e2e_client(): - fake_service = FakeDocumentService() - fake_search = FakeSearchClient() - app.dependency_overrides[get_document_service] = lambda: fake_service - app.dependency_overrides[get_search_client] = lambda: fake_search - with TestClient(app) as test_client: - yield test_client - app.dependency_overrides.clear() - - -def test_end_to_end_upload_get_list_search_delete_flow(e2e_client): - upload_response = e2e_client.post( - "/api/upload", - data={"path": "产品文档"}, - files={"file": ("架构设计.md", "# 架构设计\n\nDocHub 支持全文检索。".encode("utf-8"), "text/markdown")}, - ) - assert upload_response.status_code == 201 - key = upload_response.json()["key"] - assert key == "产品文档/架构设计.md" - - get_response = e2e_client.get(f"/api/documents/{key}") - assert get_response.status_code == 200 - assert "架构设计" in get_response.json()["content"] - - list_response = e2e_client.get("/api/documents", params={"path": "产品文档"}) - assert list_response.status_code == 200 - assert any(f["key"] == key for f in list_response.json()["files"]) - - search_response = e2e_client.get("/api/search", params={"q": "检索"}) - assert search_response.status_code == 200 - assert search_response.json()["results"][0]["key"] == "产品文档/架构设计.md" - - delete_response = e2e_client.delete(f"/api/documents/{key}") - assert delete_response.status_code == 204 - - missing_response = e2e_client.get(f"/api/documents/{key}") - assert missing_response.status_code == 404 -``` - -Also add `import pytest` at the top of `backend/tests/test_main.py` if not already present from Task 1. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd backend && pytest tests/test_main.py::test_end_to_end_upload_get_list_search_delete_flow -v` -Expected: FAIL with `404 Not Found` on the first `/api/upload` call, since `documents.router` and `search.router` are not yet included in `app` - -- [ ] **Step 3: Update `main.py` to include both routers** - -```python -# backend/app/main.py -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.config import settings -from app.routers import documents, search - -app = FastAPI(title="DocHub API") - -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(documents.router) -app.include_router(search.router) - - -@app.get("/health") -def health(): - return {"status": "ok"} -``` - -- [ ] **Step 4: Run the full backend test suite to verify everything passes** - -Run: `cd backend && pytest tests/ -v` -Expected: PASS (all tests across `test_main.py`, `test_text_extract.py`, `test_minio_client.py`, `test_search_client.py`, `test_document_service.py`, `test_routers_documents.py`, `test_routers_search.py`) - -- [ ] **Step 5: Commit** - -```bash -git add backend/app/main.py backend/tests/test_main.py -git commit -m "feat: wire documents and search routers into FastAPI app with e2e smoke test" -``` - ---- - -### Task 9: Project scaffolding + minimal App shell - -**Files:** -- Create: `frontend/package.json` -- Create: `frontend/vite.config.ts` -- Create: `frontend/tsconfig.json` -- Create: `frontend/index.html` -- Create: `frontend/tests/setup.ts` -- Create: `frontend/src/main.tsx` -- Create: `frontend/src/App.tsx` -- Create: `frontend/Dockerfile` -- Create: `frontend/nginx.conf` -- Test: `frontend/tests/App.test.tsx` - -**Interfaces:** -- Consumes: none -- Produces: `export default function App(): JSX.Element` — imported by `frontend/src/main.tsx` and extended by Task 15's integration wiring. - -- [ ] **Step 1: Create the project configuration files** - -`frontend/package.json`: - -```json -{ - "name": "dochub-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "test": "vitest run" - }, - "dependencies": { - "@monaco-editor/react": "4.6.0", - "react": "18.3.1", - "react-dom": "18.3.1" - }, - "devDependencies": { - "@testing-library/jest-dom": "6.4.8", - "@testing-library/react": "16.0.0", - "@testing-library/user-event": "14.5.2", - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", - "@vitejs/plugin-react": "4.3.1", - "jsdom": "24.1.1", - "typescript": "5.5.4", - "vite": "5.4.1", - "vitest": "2.0.5" - } -} -``` - -`frontend/tsconfig.json`: - -```json -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] - }, - "include": ["src", "tests", "vite.config.ts"] -} -``` - -`frontend/vite.config.ts`: - -```ts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./tests/setup.ts'], - }, -}); -``` - -`frontend/index.html`: - -```html - - - - - 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 }) => ( -