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

115 KiB
Raw Blame History

DocHub Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build DocHub, a small-team document hub that stores Markdown/HTML documents in MinIO, indexes their content in Meilisearch for full-text search, and lets users browse, edit, and upload documents through a React frontend.

Architecture: A FastAPI backend exposes REST endpoints that coordinate two thin SDK wrappers (MinioClient, SearchClient) through a DocumentService that writes to MinIO first and then synchronously updates the Meilisearch index (logging and swallowing index failures so a MinIO write is never rolled back for an index error). A React + Vite frontend consumes this API through a typed client.ts, rendering a lazy-loaded file tree, a Monaco-based editor with dirty-state tracking, a debounced global search bar with a results overlay, and an upload control supporting both click-to-select and drag-and-drop. No database and no authentication layer are used, per the approved design spec — MinIO holds content and metadata, Meilisearch holds the search index.

Tech Stack: Backend: Python, FastAPI, minio SDK, meilisearch SDK, pytest, httpx, python-multipart. Frontend: React 18, TypeScript, Vite, @monaco-editor/react, Vitest, Testing Library. Both services are containerized with Docker; MinIO is deployed separately per the design spec.

Global Constraints

  • No database and no authentication/authorization layer (per spec — trusted internal tool for a team of 10 or fewer).
  • Index updates happen synchronously in document_service.py: write to MinIO first, then update Meilisearch; if the Meilisearch update fails, log it and do not fail the request.
  • text_extract.py uses only the Python standard library (no third-party Markdown/HTML parsing dependency) since it only needs approximate text for indexing, not rendering.
  • Backend and frontend communicate through the fixed API contract below — both plan sections were written against this exact contract, so it must not be changed without updating both sides.
  • API contract:
    • GET /api/documents?path=<folder path>{"folders": [...], "files": [{"key", "name", "size", "modifiedAt"}]}
    • GET /api/documents/{key}{"key", "content", "contentType"} (404 if missing)
    • PUT /api/documents/{key} body {"content", "contentType"}{"key", "updatedAt"}
    • DELETE /api/documents/{key} → 204 empty body
    • POST /api/upload multipart fields file, path → 201 {"key", "name", "size"}
    • GET /api/search?q=<query>{"results": [{"key", "title", "path", "snippet"}]} (snippet contains <em> highlight tags)
  • TDD throughout: every task follows write-failing-test → verify failure → minimal implementation → verify pass → commit.

Task 1: Project Scaffolding, Config, and Health Endpoint

Files:

  • Create: backend/requirements.txt
  • Create: backend/pytest.ini
  • Create: backend/Dockerfile
  • Create: backend/app/__init__.py
  • Create: backend/app/config.py
  • Create: backend/app/main.py
  • Test: backend/tests/test_main.py

Interfaces:

  • Consumes: (none — first task)

  • Produces: app.config.settings (instance of Settings with attributes minio_endpoint, minio_access_key, minio_secret_key, minio_bucket, meilisearch_host, meilisearch_api_key, meilisearch_index, cors_origins); app.main.app (FastAPI instance) consumed by Task 6, 7, 8 and by uvicorn/Docker at runtime.

  • Step 1: Create backend/requirements.txt and install dependencies

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
[pytest]
pythonpath = .
  • Step 3: Write the failing test for the health endpoint
# backend/tests/test_main.py
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_health_returns_ok():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}
  • Step 4: Run test to verify it fails

Run: cd backend && pytest tests/test_main.py::test_health_returns_ok -v Expected: FAIL with ModuleNotFoundError: No module named 'app.main' (neither app/__init__.py nor app/main.py exist yet)

  • Step 5: Create backend/app/__init__.py
# backend/app/__init__.py
  • Step 6: Write backend/app/config.py
# backend/app/config.py
import os


class Settings:
    def __init__(self):
        self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000")
        self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
        self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
        self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub")
        self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
        self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
        self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
        self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")


settings = Settings()
  • Step 7: Write minimal backend/app/main.py
# backend/app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.config import settings

app = FastAPI(title="DocHub API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/health")
def health():
    return {"status": "ok"}
  • Step 8: Run test to verify it passes

Run: cd backend && pytest tests/test_main.py::test_health_returns_ok -v Expected: PASS

  • Step 9: Write backend/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • Step 10: Commit
git add backend/requirements.txt backend/pytest.ini backend/Dockerfile backend/app/__init__.py backend/app/config.py backend/app/main.py backend/tests/test_main.py
git commit -m "feat: scaffold FastAPI backend with config and health endpoint"

Task 2: Text Extraction (text_extract.py)

Files:

  • Create: backend/app/text_extract.py
  • Test: backend/tests/test_text_extract.py

Interfaces:

  • Consumes: (none — stdlib only, no dependency on earlier tasks)

  • Produces: markdown_to_text(text: str) -> str, html_to_text(text: str) -> str, both consumed by DocumentService in Task 5.

  • Step 1: Write the failing test for basic HTML tag stripping

# backend/tests/test_text_extract.py
from app.text_extract import html_to_text, markdown_to_text


def test_html_to_text_strips_tags():
    html = """
    <html>
      <body>
        <h1>架构设计</h1>
        <p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统。</p>
        <ul>
          <li>浏览文档</li>
          <li>全文检索</li>
        </ul>
      </body>
    </html>
    """
    result = html_to_text(html)
    assert "架构设计" in result
    assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result
    assert "浏览文档" in result
    assert "<h1>" not in result
    assert "<strong>" not in result
  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v Expected: FAIL with ModuleNotFoundError: No module named 'app.text_extract'

  • Step 3: Write minimal html_to_text implementation
# backend/app/text_extract.py
from html.parser import HTMLParser


class _HTMLTextExtractor(HTMLParser):
    def __init__(self):
        super().__init__()
        self._chunks = []

    def handle_data(self, data):
        self._chunks.append(data)

    def get_text(self):
        return "".join(self._chunks)


def html_to_text(text: str) -> str:
    parser = _HTMLTextExtractor()
    parser.feed(text)
    parser.close()
    return parser.get_text()
  • Step 4: Run test to verify it passes

Run: cd backend && pytest tests/test_text_extract.py::test_html_to_text_strips_tags -v Expected: PASS

  • Step 5: Write the failing test for whitespace collapsing
def test_html_to_text_collapses_whitespace():
    html = "<p>第一段</p>\n\n\n<p>第二段</p>"
    result = html_to_text(html)
    assert result == "第一段 第二段"
  • Step 6: Run test to verify it fails

Run: cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v Expected: FAIL with AssertionError: '第一段\n\n\n第二段' != '第一段 第二段'

  • Step 7: Extend html_to_text to collapse whitespace
# backend/app/text_extract.py
import re
from html.parser import HTMLParser


class _HTMLTextExtractor(HTMLParser):
    def __init__(self):
        super().__init__()
        self._chunks = []

    def handle_data(self, data):
        self._chunks.append(data)

    def get_text(self):
        return "".join(self._chunks)


def html_to_text(text: str) -> str:
    parser = _HTMLTextExtractor()
    parser.feed(text)
    parser.close()
    raw = parser.get_text()
    return re.sub(r"\s+", " ", raw).strip()
  • Step 8: Run test to verify it passes

Run: cd backend && pytest tests/test_text_extract.py::test_html_to_text_collapses_whitespace -v Expected: PASS

  • Step 9: Write the failing test for Markdown headers and emphasis
def test_markdown_to_text_strips_headers_and_emphasis():
    markdown = """# 架构设计

## 背景

DocHub 是一个 **基于 MinIO** 的 _文档管理系统_支持全文检索。
"""
    result = markdown_to_text(markdown)
    assert "#" not in result
    assert "**" not in result
    assert "_" not in result
    assert "架构设计" in result
    assert "背景" in result
    assert "基于 MinIO" in result
    assert "文档管理系统" in result
  • Step 10: Run test to verify it fails

Run: cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v Expected: FAIL with AttributeError: module 'app.text_extract' has no attribute 'markdown_to_text'

  • Step 11: Write minimal markdown_to_text for headers and emphasis
# backend/app/text_extract.py (add below html_to_text)
def markdown_to_text(text: str) -> str:
    result = text
    result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
    result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
    result = re.sub(r"\s+", " ", result).strip()
    return result
  • Step 12: Run test to verify it passes

Run: cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_headers_and_emphasis -v Expected: PASS

  • Step 13: Write the failing test for links and list markers
def test_markdown_to_text_strips_links_and_list_markers():
    markdown = """## 功能列表

- 浏览 [MinIO](https://min.io) 中存储的文档
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
1. 在线编辑并保存
"""
    result = markdown_to_text(markdown)
    assert "[" not in result
    assert "](" not in result
    assert "浏览 MinIO 中存储的文档" in result
    assert "支持 Meilisearch 全文检索" in result
    assert "在线编辑并保存" in result
  • Step 14: Run test to verify it fails

Run: cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v Expected: FAIL with AssertionError: assert '[' not in '功能列表 浏览 [MinIO](https://min.io) 中存储的文档 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 在线编辑并保存'

  • Step 15: Extend markdown_to_text with link and list-marker stripping
# backend/app/text_extract.py
def markdown_to_text(text: str) -> str:
    result = text
    result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result)
    result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result)
    result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
    result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
    result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE)
    result = re.sub(r"\s+", " ", result).strip()
    return result
  • Step 16: Run test to verify it passes

Run: cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_links_and_list_markers -v Expected: PASS

  • Step 17: Write the failing test for code fences and inline code
def test_markdown_to_text_strips_code_fences_and_inline_code():
    markdown = """使用 `pip install -r requirements.txt` 安装依赖。

```python
def hello():
    return "world"

安装完成后启动服务。 """ result = markdown_to_text(markdown) assert "```" not in result assert "def hello" not in result assert "使用 pip install -r requirements.txt 安装依赖。" in result assert "安装完成后启动服务。" in result


- [ ] **Step 18: Run test to verify it fails**

Run: `cd backend && pytest tests/test_text_extract.py::test_markdown_to_text_strips_code_fences_and_inline_code -v`
Expected: FAIL with `AssertionError: assert 'def hello' not in '...def hello():...'`

- [ ] **Step 19: Extend `markdown_to_text` with code-fence and inline-code stripping (applied first)**

```python
# backend/app/text_extract.py
def markdown_to_text(text: str) -> str:
    result = text
    result = re.sub(r"```.*?```", " ", result, flags=re.DOTALL)
    result = re.sub(r"`([^`]*)`", r"\1", result)
    result = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", result)
    result = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", result)
    result = re.sub(r"^\s{0,3}#{1,6}\s*", "", result, flags=re.MULTILINE)
    result = re.sub(r"(\*\*\*|___)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*\*|__)(.+?)\1", r"\2", result)
    result = re.sub(r"(\*|_)(.+?)\1", r"\2", result)
    result = re.sub(r"^\s*([-*+]|\d+\.)\s+", "", result, flags=re.MULTILINE)
    result = re.sub(r"^\s*>\s*", "", result, flags=re.MULTILINE)
    result = re.sub(r"\s+", " ", result).strip()
    return result
  • Step 20: Run full test file to verify everything passes

Run: cd backend && pytest tests/test_text_extract.py -v Expected: PASS (all 6 tests)

  • Step 21: Commit
git add backend/app/text_extract.py backend/tests/test_text_extract.py
git commit -m "feat: add stdlib-only markdown and html text extraction for indexing"

Task 3: MinIO Client Wrapper (minio_client.py)

Files:

  • Create: backend/app/minio_client.py
  • Test: backend/tests/test_minio_client.py

Interfaces:

  • Consumes: minio.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

# backend/tests/test_minio_client.py
from datetime import datetime, timezone

import pytest
from minio.error import S3Error

from app.minio_client import MinioClient


class _FakeObject:
    def __init__(self, object_name, size, last_modified):
        self.object_name = object_name
        self.size = size
        self.last_modified = last_modified


class _FakeResponse:
    def __init__(self, data: bytes):
        self._data = data
        self.closed = False
        self.released = False

    def read(self):
        return self._data

    def close(self):
        self.closed = True

    def release_conn(self):
        self.released = True


class FakeMinioSDK:
    def __init__(self):
        self.objects = {}
        self.put_calls = []
        self.removed_keys = []

    def list_objects(self, bucket, prefix="", recursive=False):
        for key, (data, content_type, last_modified) in self.objects.items():
            if key.startswith(prefix):
                yield _FakeObject(key, len(data), last_modified)

    def get_object(self, bucket, key):
        if key not in self.objects:
            raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None)
        data, _, _ = self.objects[key]
        return _FakeResponse(data)

    def put_object(self, bucket, key, stream, length, content_type):
        data = stream.read()
        self.objects[key] = (data, content_type, datetime.now(timezone.utc))
        self.put_calls.append((bucket, key, content_type))

    def remove_object(self, bucket, key):
        del self.objects[key]
        self.removed_keys.append(key)


def test_list_objects_returns_keys_matching_prefix():
    sdk = FakeMinioSDK()
    sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
    sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
    client = MinioClient(sdk, bucket="dochub")

    result = client.list_objects("产品文档/")

    assert len(result) == 1
    assert result[0]["key"] == "产品文档/架构设计.md"
    assert result[0]["size"] == 4
  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v Expected: FAIL with ModuleNotFoundError: No module named 'app.minio_client'

  • Step 3: Write minimal MinioClient.list_objects
# backend/app/minio_client.py
import io


class MinioClient:
    def __init__(self, client, bucket: str):
        self._client = client
        self._bucket = bucket

    def list_objects(self, prefix: str) -> list[dict]:
        objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False)
        result = []
        for obj in objects:
            result.append({
                "key": obj.object_name,
                "size": obj.size,
                "is_dir": obj.object_name.endswith("/"),
                "modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None,
            })
        return result
  • Step 4: Run test to verify it passes

Run: cd backend && pytest tests/test_minio_client.py::test_list_objects_returns_keys_matching_prefix -v Expected: PASS

  • Step 5: Write the failing test for get_object success
def test_get_object_returns_bytes():
    sdk = FakeMinioSDK()
    sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
    client = MinioClient(sdk, bucket="dochub")

    data = client.get_object("产品文档/架构设计.md")

    assert data == b"# architecture"
  • Step 6: Run test to verify it fails

Run: cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v Expected: FAIL with AttributeError: 'MinioClient' object has no attribute 'get_object'

  • Step 7: Write get_object
# backend/app/minio_client.py (add inside MinioClient)
from minio.error import S3Error

    def get_object(self, key: str) -> bytes:
        try:
            response = self._client.get_object(self._bucket, key)
        except S3Error as exc:
            if exc.code == "NoSuchKey":
                raise FileNotFoundError(key) from exc
            raise
        try:
            return response.read()
        finally:
            response.close()
            response.release_conn()
  • Step 8: Run test to verify it passes

Run: cd backend && pytest tests/test_minio_client.py::test_get_object_returns_bytes -v Expected: PASS

  • Step 9: Write the failing test for get_object on a missing key
def test_get_object_raises_file_not_found_for_missing_key():
    sdk = FakeMinioSDK()
    client = MinioClient(sdk, bucket="dochub")

    with pytest.raises(FileNotFoundError):
        client.get_object("不存在/文档.md")
  • Step 10: Run test to verify it passes (already satisfied by Step 7's implementation)

Run: cd backend && pytest tests/test_minio_client.py::test_get_object_raises_file_not_found_for_missing_key -v Expected: PASS

  • Step 11: Write the failing test for put_object
def test_put_object_stores_data_with_content_type():
    sdk = FakeMinioSDK()
    client = MinioClient(sdk, bucket="dochub")

    client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")

    assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
    assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
  • Step 12: Run test to verify it fails

Run: cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v Expected: FAIL with AttributeError: 'MinioClient' object has no attribute 'put_object'

  • Step 13: Write put_object
# backend/app/minio_client.py (add inside MinioClient)
    def put_object(self, key: str, data: bytes, content_type: str) -> None:
        self._client.put_object(
            self._bucket,
            key,
            io.BytesIO(data),
            length=len(data),
            content_type=content_type,
        )
  • Step 14: Run test to verify it passes

Run: cd backend && pytest tests/test_minio_client.py::test_put_object_stores_data_with_content_type -v Expected: PASS

  • Step 15: Write the failing test for delete_object
def test_delete_object_removes_key():
    sdk = FakeMinioSDK()
    sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
    client = MinioClient(sdk, bucket="dochub")

    client.delete_object("产品文档/旧文档.md")

    assert "产品文档/旧文档.md" not in sdk.objects
    assert sdk.removed_keys == ["产品文档/旧文档.md"]
  • Step 16: Run test to verify it fails

Run: cd backend && pytest tests/test_minio_client.py::test_delete_object_removes_key -v Expected: FAIL with AttributeError: 'MinioClient' object has no attribute 'delete_object'

  • Step 17: Write delete_object
# backend/app/minio_client.py (add inside MinioClient)
    def delete_object(self, key: str) -> None:
        self._client.remove_object(self._bucket, key)
  • Step 18: Run full test file to verify everything passes

Run: cd backend && pytest tests/test_minio_client.py -v Expected: PASS (all 5 tests)

  • Step 19: Commit
git add backend/app/minio_client.py backend/tests/test_minio_client.py
git commit -m "feat: add MinioClient wrapper for list/get/put/delete object operations"

Task 4: Search Client Wrapper (search_client.py)

Files:

  • Create: backend/app/search_client.py
  • Test: backend/tests/test_search_client.py

Interfaces:

  • Consumes: meilisearch.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

# backend/tests/test_search_client.py
from app.search_client import SearchClient


class FakeIndex:
    def __init__(self):
        self.documents = {}
        self.deleted_ids = []

    def add_documents(self, docs):
        for doc in docs:
            self.documents[doc["id"]] = doc

    def delete_document(self, doc_id):
        self.documents.pop(doc_id, None)
        self.deleted_ids.append(doc_id)

    def search(self, query, params=None):
        hits = []
        for doc in self.documents.values():
            if query in doc["content"] or query in doc["title"]:
                formatted = dict(doc)
                if query in doc["content"]:
                    formatted["content"] = doc["content"].replace(query, f"<em>{query}</em>")
                hits.append({**doc, "_formatted": formatted})
        return {"hits": hits}


class FakeMeilisearchSDK:
    def __init__(self):
        self.indexes = {}

    def index(self, name):
        if name not in self.indexes:
            self.indexes[name] = FakeIndex()
        return self.indexes[name]


def test_index_document_adds_document_with_encoded_id():
    sdk = FakeMeilisearchSDK()
    client = SearchClient(sdk, index_name="documents")

    client.index_document(
        "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
    )

    index = sdk.indexes["documents"]
    assert len(index.documents) == 1
    stored = list(index.documents.values())[0]
    assert stored["key"] == "产品文档/架构设计.md"
    assert stored["path"] == "产品文档"
    assert stored["title"] == "架构设计.md"
    assert stored["content"] == "DocHub 使用 MinIO 存储文档"
  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v Expected: FAIL with ModuleNotFoundError: No module named 'app.search_client'

  • Step 3: Write minimal SearchClient.index_document
# backend/app/search_client.py
import base64


def _encode_key(key: str) -> str:
    return base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii").rstrip("=")


class SearchClient:
    def __init__(self, client, index_name: str):
        self._client = client
        self._index_name = index_name

    def index_document(self, key: str, path: str, title: str, content: str) -> None:
        index = self._client.index(self._index_name)
        index.add_documents([{
            "id": _encode_key(key),
            "key": key,
            "path": path,
            "title": title,
            "content": content,
        }])
  • Step 4: Run test to verify it passes

Run: cd backend && pytest tests/test_search_client.py::test_index_document_adds_document_with_encoded_id -v Expected: PASS

  • Step 5: Write the failing test for delete_document
def test_delete_document_removes_document():
    sdk = FakeMeilisearchSDK()
    client = SearchClient(sdk, index_name="documents")
    client.index_document("产品文档/旧文档.md", "产品文档", "旧文档.md", "旧内容")

    client.delete_document("产品文档/旧文档.md")

    index = sdk.indexes["documents"]
    assert index.documents == {}
    assert len(index.deleted_ids) == 1
  • Step 6: Run test to verify it fails

Run: cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v Expected: FAIL with AttributeError: 'SearchClient' object has no attribute 'delete_document'

  • Step 7: Write delete_document
# backend/app/search_client.py (add inside SearchClient)
    def delete_document(self, key: str) -> None:
        index = self._client.index(self._index_name)
        index.delete_document(_encode_key(key))
  • Step 8: Run test to verify it passes

Run: cd backend && pytest tests/test_search_client.py::test_delete_document_removes_document -v Expected: PASS

  • Step 9: Write the failing test for search with highlighted snippet
def test_search_returns_matching_documents_with_highlighted_snippet():
    sdk = FakeMeilisearchSDK()
    client = SearchClient(sdk, index_name="documents")
    client.index_document(
        "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 支持全文检索功能"
    )

    results = client.search("检索")

    assert len(results) == 1
    assert results[0]["key"] == "产品文档/架构设计.md"
    assert results[0]["title"] == "架构设计.md"
    assert results[0]["path"] == "产品文档"
    assert "<em>检索</em>" in results[0]["snippet"]
  • Step 10: Run test to verify it fails

Run: cd backend && pytest tests/test_search_client.py::test_search_returns_matching_documents_with_highlighted_snippet -v Expected: FAIL with AttributeError: 'SearchClient' object has no attribute 'search'

  • Step 11: Write search
# backend/app/search_client.py (add inside SearchClient)
    def search(self, query: str) -> list[dict]:
        index = self._client.index(self._index_name)
        response = index.search(query, {
            "attributesToHighlight": ["content"],
            "highlightPreTag": "<em>",
            "highlightPostTag": "</em>",
        })
        results = []
        for hit in response.get("hits", []):
            formatted = hit.get("_formatted", {})
            results.append({
                "key": hit["key"],
                "title": hit["title"],
                "path": hit["path"],
                "snippet": formatted.get("content", hit.get("content", "")),
            })
        return results
  • Step 12: Run full test file to verify everything passes

Run: cd backend && pytest tests/test_search_client.py -v Expected: PASS (all 3 tests)

  • Step 13: Commit
git add backend/app/search_client.py backend/tests/test_search_client.py
git commit -m "feat: add SearchClient wrapper for index/delete/search operations"

Task 5: Document Service (document_service.py)

Files:

  • Create: backend/app/document_service.py
  • Test: backend/tests/test_document_service.py

Interfaces:

  • Consumes: MinioClient.list_objects/get_object/put_object/delete_object (Task 3), SearchClient.index_document/delete_document (Task 4), markdown_to_text/html_to_text (Task 2)

  • Produces: DocumentService(minio_client, search_client) 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

# backend/tests/test_document_service.py
import pytest

from app.document_service import DocumentService, DocumentNotFoundError


class FakeMinioClient:
    def __init__(self):
        self.entries = []
        self.stored = {}
        self.deleted_keys = []
        self.put_calls = []

    def list_objects(self, prefix):
        return [e for e in self.entries if e["key"].startswith(prefix)]

    def get_object(self, key):
        if key not in self.stored:
            raise FileNotFoundError(key)
        return self.stored[key]

    def put_object(self, key, data, content_type):
        self.stored[key] = data
        self.put_calls.append((key, content_type))

    def delete_object(self, key):
        if key not in self.stored:
            raise FileNotFoundError(key)
        del self.stored[key]
        self.deleted_keys.append(key)


class FakeSearchClient:
    def __init__(self, fail_on_index=False, fail_on_delete=False):
        self.indexed = []
        self.deleted = []
        self.fail_on_index = fail_on_index
        self.fail_on_delete = fail_on_delete

    def index_document(self, key, path, title, content):
        if self.fail_on_index:
            raise RuntimeError("meilisearch unavailable")
        self.indexed.append({"key": key, "path": path, "title": title, "content": content})

    def delete_document(self, key):
        if self.fail_on_delete:
            raise RuntimeError("meilisearch unavailable")
        self.deleted.append(key)


def test_list_documents_at_root_returns_folders_and_files():
    minio_client = FakeMinioClient()
    minio_client.entries = [
        {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
        {"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
        {"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
    ]
    service = DocumentService(minio_client, FakeSearchClient())

    result = service.list_documents("")

    assert result["folders"] == ["产品文档", "团队规范"]
    assert result["files"] == [
        {"key": "README.md", "name": "README.md", "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
    ]
  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v Expected: FAIL with ModuleNotFoundError: No module named 'app.document_service'

  • Step 3: Write minimal DocumentService.list_documents
# backend/app/document_service.py
import logging
from datetime import datetime, timezone

from app.text_extract import html_to_text, markdown_to_text

logger = logging.getLogger(__name__)


class DocumentNotFoundError(Exception):
    pass


def _guess_content_type(key: str) -> str:
    if key.endswith(".md"):
        return "text/markdown"
    if key.endswith(".html") or key.endswith(".htm"):
        return "text/html"
    return "application/octet-stream"


class DocumentService:
    def __init__(self, minio_client, search_client):
        self._minio_client = minio_client
        self._search_client = search_client

    def list_documents(self, path: str) -> dict:
        prefix = path if not path or path.endswith("/") else f"{path}/"
        entries = self._minio_client.list_objects(prefix)
        folders = []
        files = []
        for entry in entries:
            key = entry["key"]
            if entry.get("is_dir"):
                name = key[len(prefix):].rstrip("/")
                if name:
                    folders.append(name)
            else:
                name = key[len(prefix):]
                files.append({
                    "key": key,
                    "name": name,
                    "size": entry["size"],
                    "modifiedAt": entry["modifiedAt"],
                })
        return {"folders": folders, "files": files}
  • Step 4: Run test to verify it passes

Run: cd backend && pytest tests/test_document_service.py::test_list_documents_at_root_returns_folders_and_files -v Expected: PASS

  • Step 5: Write the failing tests for get_document (found and not found)
def test_get_document_returns_content_and_content_type():
    minio_client = FakeMinioClient()
    minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8")
    service = DocumentService(minio_client, FakeSearchClient())

    result = service.get_document("产品文档/架构设计.md")

    assert result["key"] == "产品文档/架构设计.md"
    assert result["contentType"] == "text/markdown"
    assert "架构设计" in result["content"]


def test_get_document_raises_not_found_for_missing_key():
    service = DocumentService(FakeMinioClient(), FakeSearchClient())

    with pytest.raises(DocumentNotFoundError):
        service.get_document("不存在/文档.md")
  • Step 6: Run tests to verify they fail

Run: cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v Expected: FAIL with AttributeError: 'DocumentService' object has no attribute 'get_document'

  • Step 7: Write get_document
# backend/app/document_service.py (add inside DocumentService)
    def get_document(self, key: str) -> dict:
        try:
            data = self._minio_client.get_object(key)
        except FileNotFoundError as exc:
            raise DocumentNotFoundError(key) from exc
        return {
            "key": key,
            "content": data.decode("utf-8"),
            "contentType": _guess_content_type(key),
        }
  • Step 8: Run tests to verify they pass

Run: cd backend && pytest tests/test_document_service.py::test_get_document_returns_content_and_content_type tests/test_document_service.py::test_get_document_raises_not_found_for_missing_key -v Expected: PASS

  • Step 9: Write the failing tests for save_document (success and index-failure tolerance)
def test_save_document_writes_to_minio_then_indexes_content():
    minio_client = FakeMinioClient()
    search_client = FakeSearchClient()
    service = DocumentService(minio_client, search_client)
    content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")

    result = service.save_document("产品文档/架构设计.md", content, "text/markdown")

    assert minio_client.stored["产品文档/架构设计.md"] == content
    assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
    assert search_client.indexed[0]["path"] == "产品文档"
    assert search_client.indexed[0]["title"] == "架构设计.md"
    assert "MinIO" in search_client.indexed[0]["content"]
    assert "#" not in search_client.indexed[0]["content"]
    assert "key" in result and "updatedAt" in result


def test_save_document_does_not_fail_when_index_update_fails():
    minio_client = FakeMinioClient()
    search_client = FakeSearchClient(fail_on_index=True)
    service = DocumentService(minio_client, search_client)
    content = b"# doc"

    result = service.save_document("产品文档/架构设计.md", content, "text/markdown")

    assert minio_client.stored["产品文档/架构设计.md"] == content
    assert result["key"] == "产品文档/架构设计.md"
  • Step 10: Run tests to verify they fail

Run: cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v Expected: FAIL with AttributeError: 'DocumentService' object has no attribute 'save_document'

  • Step 11: Write save_document
# backend/app/document_service.py (add inside DocumentService)
    def _extract_text(self, content: bytes, content_type: str) -> str:
        text = content.decode("utf-8", errors="ignore")
        if content_type == "text/html":
            return html_to_text(text)
        if content_type == "text/markdown":
            return markdown_to_text(text)
        return text

    def save_document(self, key: str, content: bytes, content_type: str) -> dict:
        self._minio_client.put_object(key, content, content_type)
        try:
            text = self._extract_text(content, content_type)
            title = key.rsplit("/", 1)[-1]
            path = key.rsplit("/", 1)[0] if "/" in key else ""
            self._search_client.index_document(key, path, title, text)
        except Exception:
            logger.exception("Failed to update search index for %s", key)
        return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
  • Step 12: Run tests to verify they pass

Run: cd backend && pytest tests/test_document_service.py::test_save_document_writes_to_minio_then_indexes_content tests/test_document_service.py::test_save_document_does_not_fail_when_index_update_fails -v Expected: PASS

  • Step 13: Write the failing tests for delete_document (success and index-failure tolerance)
def test_delete_document_removes_from_minio_and_search_index():
    minio_client = FakeMinioClient()
    minio_client.stored["产品文档/旧文档.md"] = b"old content"
    search_client = FakeSearchClient()
    service = DocumentService(minio_client, search_client)

    service.delete_document("产品文档/旧文档.md")

    assert "产品文档/旧文档.md" not in minio_client.stored
    assert search_client.deleted == ["产品文档/旧文档.md"]


def test_delete_document_does_not_fail_when_index_delete_fails():
    minio_client = FakeMinioClient()
    minio_client.stored["产品文档/旧文档.md"] = b"old content"
    search_client = FakeSearchClient(fail_on_delete=True)
    service = DocumentService(minio_client, search_client)

    service.delete_document("产品文档/旧文档.md")

    assert "产品文档/旧文档.md" not in minio_client.stored
  • Step 14: Run tests to verify they fail

Run: cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v Expected: FAIL with AttributeError: 'DocumentService' object has no attribute 'delete_document'

  • Step 15: Write delete_document
# backend/app/document_service.py (add inside DocumentService)
    def delete_document(self, key: str) -> None:
        self._minio_client.delete_object(key)
        try:
            self._search_client.delete_document(key)
        except Exception:
            logger.exception("Failed to delete search index entry for %s", key)
  • Step 16: Run tests to verify they pass

Run: cd backend && pytest tests/test_document_service.py::test_delete_document_removes_from_minio_and_search_index tests/test_document_service.py::test_delete_document_does_not_fail_when_index_delete_fails -v Expected: PASS

  • Step 17: Write the failing test for upload_document
def test_upload_document_writes_file_and_returns_metadata():
    minio_client = FakeMinioClient()
    service = DocumentService(minio_client, FakeSearchClient())
    data = b"# uploaded doc"

    result = service.upload_document("产品文档/新上传.md", data, "text/markdown")

    assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
    assert minio_client.stored["产品文档/新上传.md"] == data
  • Step 18: Run test to verify it fails

Run: cd backend && pytest tests/test_document_service.py::test_upload_document_writes_file_and_returns_metadata -v Expected: FAIL with AttributeError: 'DocumentService' object has no attribute 'upload_document'

  • Step 19: Write upload_document
# backend/app/document_service.py (add inside DocumentService)
    def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
        self.save_document(key, data, content_type)
        name = key.rsplit("/", 1)[-1]
        return {"key": key, "name": name, "size": len(data)}
  • Step 20: Run full test file to verify everything passes

Run: cd backend && pytest tests/test_document_service.py -v Expected: PASS (all 8 tests)

  • Step 21: Commit
git add backend/app/document_service.py backend/tests/test_document_service.py
git commit -m "feat: add DocumentService coordinating MinIO writes and search indexing"

Task 6: Documents 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

# 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
# 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)
# backend/app/routers/__init__.py
# 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)
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}
# 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}
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}
# 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}
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}
# 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
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
# 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
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

# 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": "支持全文<em>检索</em>功能",
                }
            ]
        return []


@pytest.fixture
def client():
    fake_client = FakeSearchClient()
    app.dependency_overrides[get_search_client] = lambda: fake_client
    with TestClient(app) as test_client:
        test_client.fake_client = fake_client
        yield test_client
    app.dependency_overrides.clear()


def test_search_returns_matching_results(client):
    response = client.get("/api/search", params={"q": "检索"})
    assert response.status_code == 200
    body = response.json()
    assert body["results"][0]["key"] == "产品文档/架构设计.md"
    assert "<em>检索</em>" in body["results"][0]["snippet"]
  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_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
# 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
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
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
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

# 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": "全文<em>检索</em>示例",
            }
        ]


@pytest.fixture
def e2e_client():
    fake_service = FakeDocumentService()
    fake_search = FakeSearchClient()
    app.dependency_overrides[get_document_service] = lambda: fake_service
    app.dependency_overrides[get_search_client] = lambda: fake_search
    with TestClient(app) as test_client:
        yield test_client
    app.dependency_overrides.clear()


def test_end_to_end_upload_get_list_search_delete_flow(e2e_client):
    upload_response = e2e_client.post(
        "/api/upload",
        data={"path": "产品文档"},
        files={"file": ("架构设计.md", "# 架构设计\n\nDocHub 支持全文检索。".encode("utf-8"), "text/markdown")},
    )
    assert upload_response.status_code == 201
    key = upload_response.json()["key"]
    assert key == "产品文档/架构设计.md"

    get_response = e2e_client.get(f"/api/documents/{key}")
    assert get_response.status_code == 200
    assert "架构设计" in get_response.json()["content"]

    list_response = e2e_client.get("/api/documents", params={"path": "产品文档"})
    assert list_response.status_code == 200
    assert any(f["key"] == key for f in list_response.json()["files"])

    search_response = e2e_client.get("/api/search", params={"q": "检索"})
    assert search_response.status_code == 200
    assert search_response.json()["results"][0]["key"] == "产品文档/架构设计.md"

    delete_response = e2e_client.delete(f"/api/documents/{key}")
    assert delete_response.status_code == 204

    missing_response = e2e_client.get(f"/api/documents/{key}")
    assert missing_response.status_code == 404

Also add import pytest at the top of backend/tests/test_main.py if not already present from Task 1.

  • Step 2: Run test to verify it fails

Run: cd backend && pytest tests/test_main.py::test_end_to_end_upload_get_list_search_delete_flow -v Expected: FAIL with 404 Not Found on the first /api/upload call, since documents.router and search.router are not yet included in app

  • Step 3: Update main.py to include both routers
# 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
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:

{
  "name": "dochub-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },
  "dependencies": {
    "@monaco-editor/react": "4.6.0",
    "react": "18.3.1",
    "react-dom": "18.3.1"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "6.4.8",
    "@testing-library/react": "16.0.0",
    "@testing-library/user-event": "14.5.2",
    "@types/react": "18.3.3",
    "@types/react-dom": "18.3.0",
    "@vitejs/plugin-react": "4.3.1",
    "jsdom": "24.1.1",
    "typescript": "5.5.4",
    "vite": "5.4.1",
    "vitest": "2.0.5"
  }
}

frontend/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
  },
  "include": ["src", "tests", "vite.config.ts"]
}

frontend/vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
  },
});

frontend/index.html:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>DocHub</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

frontend/tests/setup.ts:

import '@testing-library/jest-dom/vitest';
  • Step 2: Write the failing test

frontend/tests/App.test.tsx:

import { render, screen } from '@testing-library/react';
import { test, expect } from 'vitest';
import App from '../src/App';

test('renders the DocHub heading', () => {
  render(<App />);
  expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
});
  • Step 3: Run test to verify it fails

Run: npm install && npm run test -- App.test.tsx Expected: FAIL with "Cannot find module '../src/App' or its corresponding type declarations."

  • Step 4: Write minimal implementation

frontend/src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

frontend/src/App.tsx:

export default function App() {
  return (
    <div className="app">
      <h1>DocHub</h1>
    </div>
  );
}
  • Step 5: Run test to verify it passes

Run: npm run test -- App.test.tsx Expected: PASS

  • Step 6: Add Docker build/serve files

frontend/Dockerfile:

# ---- build stage ----
FROM node:20.16-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build

# ---- serve stage ----
FROM nginx:1.27-alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

frontend/nginx.conf:

server {
  listen 80;
  server_name _;
  root /usr/share/nginx/html;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }

  location /api/ {
    proxy_pass http://backend:8000/api/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}
  • Step 7: Commit
git add frontend/package.json frontend/vite.config.ts frontend/tsconfig.json frontend/index.html frontend/tests/setup.ts frontend/src/main.tsx frontend/src/App.tsx frontend/tests/App.test.tsx frontend/Dockerfile frontend/nginx.conf
git commit -m "feat: scaffold frontend project with Vite, Vitest and Docker build"

Task 10: api/client.ts

Files:

  • Create: frontend/src/api/client.ts
  • Test: frontend/tests/api/client.test.ts

Interfaces:

  • Consumes: global fetch, import.meta.env.VITE_API_BASE_URL

  • Produces: DocumentFile, DocumentListResponse, DocumentContent, SearchResult, SearchResponse, SaveDocumentResponse, UploadDocumentResponse interfaces; listDocuments(path: string): Promise<DocumentListResponse>, getDocument(key: string): Promise<DocumentContent>, saveDocument(key: string, content: string, contentType: string): Promise<SaveDocumentResponse>, deleteDocument(key: string): Promise<void>, uploadDocument(file: File, path: string): Promise<UploadDocumentResponse>, search(query: string): Promise<SearchResponse> — consumed by FileExplorer, DocumentEditor, SearchBar, SearchResults, UploadButton, App.

  • Step 1: Write the failing tests for read operations

frontend/tests/api/client.test.ts:

import { describe, test, expect, vi, beforeEach } from 'vitest';
import { listDocuments, getDocument } from '../../src/api/client';

beforeEach(() => {
  vi.restoreAllMocks();
});

test('listDocuments calls GET /api/documents with the path query param and returns parsed JSON', async () => {
  const mockResponse = {
    folders: ['产品文档'],
    files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
  };
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockResponse),
  }) as unknown as typeof fetch;

  const result = await listDocuments('产品文档');

  expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
  expect(result).toEqual(mockResponse);
});

test('listDocuments throws when the response is not ok', async () => {
  global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;

  await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
});

test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
  const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockResponse),
  }) as unknown as typeof fetch;

  const result = await getDocument('产品文档/架构设计.md');

  expect(global.fetch).toHaveBeenCalledWith(
    `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
  );
  expect(result).toEqual(mockResponse);
});
  • Step 2: Run test to verify it fails

Run: npm run test -- client.test.ts Expected: FAIL with "Cannot find module '../../src/api/client'"

  • Step 3: Write minimal implementation for read operations

frontend/src/api/client.ts:

export interface DocumentFile {
  key: string;
  name: string;
  size: number;
  modifiedAt: string;
}

export interface DocumentListResponse {
  folders: string[];
  files: DocumentFile[];
}

export interface DocumentContent {
  key: string;
  content: string;
  contentType: string;
}

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';

function encodeKeyPath(key: string): string {
  return key
    .split('/')
    .map((segment) => encodeURIComponent(segment))
    .join('/');
}

export async function listDocuments(path: string): Promise<DocumentListResponse> {
  const response = await fetch(`${API_BASE_URL}/api/documents?path=${encodeURIComponent(path)}`);
  if (!response.ok) {
    throw new Error(`Failed to list documents: ${response.status}`);
  }
  return response.json();
}

export async function getDocument(key: string): Promise<DocumentContent> {
  const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`);
  if (!response.ok) {
    throw new Error(`Failed to get document: ${response.status}`);
  }
  return response.json();
}
  • Step 4: Run test to verify it passes

Run: npm run test -- client.test.ts Expected: PASS

  • Step 5: Write the failing tests for write operations

Append to frontend/tests/api/client.test.ts:

import { saveDocument, deleteDocument } from '../../src/api/client';

test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
  const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockResponse),
  }) as unknown as typeof fetch;

  const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');

  expect(global.fetch).toHaveBeenCalledWith(
    `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: '# 新内容', contentType: 'text/markdown' }),
    }
  );
  expect(result).toEqual(mockResponse);
});

test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
  global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;

  await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();

  expect(global.fetch).toHaveBeenCalledWith(
    `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
    { method: 'DELETE' }
  );
});
  • Step 6: Run test to verify it fails

Run: npm run test -- client.test.ts Expected: FAIL with "saveDocument is not a function" / "deleteDocument is not a function"

  • Step 7: Write minimal implementation for write operations

Append to frontend/src/api/client.ts:

export interface SaveDocumentResponse {
  key: string;
  updatedAt: string;
}

export async function saveDocument(
  key: string,
  content: string,
  contentType: string
): Promise<SaveDocumentResponse> {
  const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content, contentType }),
  });
  if (!response.ok) {
    throw new Error(`Failed to save document: ${response.status}`);
  }
  return response.json();
}

export async function deleteDocument(key: string): Promise<void> {
  const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, {
    method: 'DELETE',
  });
  if (!response.ok) {
    throw new Error(`Failed to delete document: ${response.status}`);
  }
}
  • Step 8: Run test to verify it passes

Run: npm run test -- client.test.ts Expected: PASS

  • Step 9: Write the failing test for upload

Append to frontend/tests/api/client.test.ts:

import { uploadDocument } from '../../src/api/client';

test('uploadDocument posts a multipart form with file and path fields', async () => {
  const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockResponse),
  }) as unknown as typeof fetch;
  const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });

  const result = await uploadDocument(file, '产品文档');

  expect(global.fetch).toHaveBeenCalledTimes(1);
  const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
  expect(url).toBe('/api/upload');
  expect(init.method).toBe('POST');
  const body = init.body as FormData;
  expect(body.get('file')).toBe(file);
  expect(body.get('path')).toBe('产品文档');
  expect(result).toEqual(mockResponse);
});
  • Step 10: Run test to verify it fails

Run: npm run test -- client.test.ts Expected: FAIL with "uploadDocument is not a function"

  • Step 11: Write minimal implementation for upload

Append to frontend/src/api/client.ts:

export interface UploadDocumentResponse {
  key: string;
  name: string;
  size: number;
}

export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('path', path);
  const response = await fetch(`${API_BASE_URL}/api/upload`, {
    method: 'POST',
    body: formData,
  });
  if (!response.ok) {
    throw new Error(`Failed to upload document: ${response.status}`);
  }
  return response.json();
}
  • Step 12: Run test to verify it passes

Run: npm run test -- client.test.ts Expected: PASS

  • Step 13: Write the failing test for search

Append to frontend/tests/api/client.test.ts:

import { search } from '../../src/api/client';

test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
  const mockResponse = {
    results: [
      { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
    ],
  };
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockResponse),
  }) as unknown as typeof fetch;

  const result = await search('检索');

  expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
  expect(result).toEqual(mockResponse);
});
  • Step 14: Run test to verify it fails

Run: npm run test -- client.test.ts Expected: FAIL with "search is not a function"

  • Step 15: Write minimal implementation for search

Append to frontend/src/api/client.ts:

export interface SearchResult {
  key: string;
  title: string;
  path: string;
  snippet: string;
}

export interface SearchResponse {
  results: SearchResult[];
}

export async function search(query: string): Promise<SearchResponse> {
  const response = await fetch(`${API_BASE_URL}/api/search?q=${encodeURIComponent(query)}`);
  if (!response.ok) {
    throw new Error(`Failed to search: ${response.status}`);
  }
  return response.json();
}
  • Step 16: Run test to verify it passes

Run: npm run test -- client.test.ts Expected: PASS

  • Step 17: Commit
git add frontend/src/api/client.ts frontend/tests/api/client.test.ts
git commit -m "feat: add typed API client for documents, upload and search"

Task 11: FileExplorer.tsx

Files:

  • Create: frontend/src/components/FileExplorer.tsx
  • Test: frontend/tests/components/FileExplorer.test.tsx

Interfaces:

  • Consumes: listDocuments(path: string): Promise<DocumentListResponse>, 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:

import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import FileExplorer from '../../src/components/FileExplorer';
import { listDocuments } from '../../src/api/client';

vi.mock('../../src/api/client');

beforeEach(() => {
  vi.mocked(listDocuments).mockReset();
});

test('renders folders and files returned by listDocuments for the root path', async () => {
  vi.mocked(listDocuments).mockResolvedValue({
    folders: ['产品文档'],
    files: [{ key: '需求说明.md', name: '需求说明.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
  });

  render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);

  expect(await screen.findByText('产品文档')).toBeInTheDocument();
  expect(await screen.findByText('需求说明.md')).toBeInTheDocument();
  expect(listDocuments).toHaveBeenCalledWith('');
});

test('clicking a file calls onSelectFile with its key', async () => {
  vi.mocked(listDocuments).mockResolvedValue({
    folders: [],
    files: [{ key: '架构设计.md', name: '架构设计.md', size: 200, modifiedAt: '2026-08-01T10:00:00Z' }],
  });
  const onSelectFile = vi.fn();

  render(<FileExplorer onSelectFile={onSelectFile} selectedKey={null} />);

  const fileRow = await screen.findByText('架构设计.md');
  fireEvent.click(fileRow);

  expect(onSelectFile).toHaveBeenCalledWith('架构设计.md');
});
  • Step 2: Run test to verify it fails

Run: npm run test -- FileExplorer.test.tsx Expected: FAIL with "Cannot find module '../../src/components/FileExplorer'"

  • Step 3: Write minimal implementation

frontend/src/components/FileExplorer.tsx:

import { useEffect, useState } from 'react';
import { listDocuments, type DocumentFile } from '../api/client';

interface FileExplorerProps {
  onSelectFile: (key: string) => void;
  selectedKey?: string | null;
}

export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
  const [folders, setFolders] = useState<string[]>([]);
  const [files, setFiles] = useState<DocumentFile[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    listDocuments('')
      .then((data) => {
        setFolders(data.folders);
        setFiles(data.files);
      })
      .catch(() => setError('无法加载文档目录'));
  }, []);

  if (error) {
    return <div data-testid="file-explorer-error">{error}</div>;
  }

  return (
    <div data-testid="file-explorer">
      {folders.map((folder) => (
        <div key={folder} data-testid={`folder-${folder}`} className="folder">
          {folder}
        </div>
      ))}
      {files.map((file) => (
        <div
          key={file.key}
          data-testid={`file-${file.key}`}
          className={selectedKey === file.key ? 'file-row active' : 'file-row'}
          onClick={() => onSelectFile(file.key)}
        >
          {file.name}
        </div>
      ))}
    </div>
  );
}
  • Step 4: Run test to verify it passes

Run: npm run test -- FileExplorer.test.tsx Expected: PASS

  • Step 5: Write the failing tests for folder expansion and error state

Append to frontend/tests/components/FileExplorer.test.tsx:

test('clicking a folder loads and shows its nested contents', async () => {
  vi.mocked(listDocuments).mockImplementation((path: string) => {
    if (path === '') {
      return Promise.resolve({ folders: ['产品文档'], files: [] });
    }
    if (path === '产品文档') {
      return Promise.resolve({
        folders: [],
        files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 300, modifiedAt: '2026-08-01T10:00:00Z' }],
      });
    }
    return Promise.resolve({ folders: [], files: [] });
  });

  render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);

  const folderRow = await screen.findByText('产品文档');
  fireEvent.click(folderRow);

  expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
  expect(listDocuments).toHaveBeenCalledWith('产品文档');
});

test('shows an error message when listDocuments rejects', async () => {
  vi.mocked(listDocuments).mockRejectedValue(new Error('network error'));

  render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} />);

  expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
});
  • Step 6: Run test to verify it fails

Run: npm run test -- FileExplorer.test.tsx Expected: FAIL with "Unable to find an element with the text: 架构设计.md" (folder click is not wired to load nested contents yet)

  • Step 7: Write the full implementation with lazy-loaded nested folders

Replace the contents of frontend/src/components/FileExplorer.tsx:

import { useEffect, useState } from 'react';
import { listDocuments, type DocumentFile } from '../api/client';

interface FileExplorerProps {
  onSelectFile: (key: string) => void;
  selectedKey?: string | null;
}

export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
  const [folders, setFolders] = useState<string[]>([]);
  const [files, setFiles] = useState<DocumentFile[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    listDocuments('')
      .then((data) => {
        setFolders(data.folders);
        setFiles(data.files);
      })
      .catch(() => setError('无法加载文档目录'));
  }, []);

  if (error) {
    return <div data-testid="file-explorer-error">{error}</div>;
  }

  return (
    <div data-testid="file-explorer">
      {folders.map((folder) => (
        <FolderNode key={folder} name={folder} path={folder} onSelectFile={onSelectFile} selectedKey={selectedKey} />
      ))}
      {files.map((file) => (
        <FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
      ))}
    </div>
  );
}

function FileRow({
  file,
  onSelectFile,
  selectedKey,
}: {
  file: DocumentFile;
  onSelectFile: (key: string) => void;
  selectedKey?: string | null;
}) {
  return (
    <div
      data-testid={`file-${file.key}`}
      className={selectedKey === file.key ? 'file-row active' : 'file-row'}
      onClick={() => onSelectFile(file.key)}
    >
      {file.name}
    </div>
  );
}

function FolderNode({
  name,
  path,
  onSelectFile,
  selectedKey,
}: {
  name: string;
  path: string;
  onSelectFile: (key: string) => void;
  selectedKey?: string | null;
}) {
  const [expanded, setExpanded] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [folders, setFolders] = useState<string[]>([]);
  const [files, setFiles] = useState<DocumentFile[]>([]);

  function toggle() {
    if (!expanded && !loaded) {
      listDocuments(path).then((data) => {
        setFolders(data.folders);
        setFiles(data.files);
        setLoaded(true);
      });
    }
    setExpanded((prev) => !prev);
  }

  return (
    <div>
      <div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
        {name}
      </div>
      {expanded && (
        <div className="tree-children">
          {folders.map((folder) => (
            <FolderNode
              key={`${path}/${folder}`}
              name={folder}
              path={`${path}/${folder}`}
              onSelectFile={onSelectFile}
              selectedKey={selectedKey}
            />
          ))}
          {files.map((file) => (
            <FileRow key={file.key} file={file} onSelectFile={onSelectFile} selectedKey={selectedKey} />
          ))}
        </div>
      )}
    </div>
  );
}
  • Step 8: Run test to verify it passes

Run: npm run test -- FileExplorer.test.tsx Expected: PASS

  • Step 9: Commit
git add frontend/src/components/FileExplorer.tsx frontend/tests/components/FileExplorer.test.tsx
git commit -m "feat: add FileExplorer with lazy-loaded folder tree"

Task 12: DocumentEditor.tsx

Files:

  • Create: frontend/src/components/DocumentEditor.tsx
  • Test: frontend/tests/components/DocumentEditor.test.tsx

Interfaces:

  • Consumes: getDocument(key: string): Promise<DocumentContent>, saveDocument(key: string, content: string, contentType: string): Promise<SaveDocumentResponse> from ../api/client; 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:

import { render, screen } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor';
import { getDocument, saveDocument } from '../../src/api/client';

vi.mock('../../src/api/client');

vi.mock('@monaco-editor/react', () => ({
  default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
    <textarea
      data-testid="monaco-editor-mock"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  ),
}));

beforeEach(() => {
  vi.mocked(getDocument).mockReset();
  vi.mocked(saveDocument).mockReset();
});

test('shows a loading state, then loads and displays document content', async () => {
  vi.mocked(getDocument).mockResolvedValue({
    key: '产品文档/架构设计.md',
    content: '# 架构设计',
    contentType: 'text/markdown',
  });

  render(<DocumentEditor documentKey="产品文档/架构设计.md" />);

  expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();

  const textarea = await screen.findByTestId('monaco-editor-mock');
  expect(textarea).toHaveValue('# 架构设计');
  expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
  expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
  • Step 2: Run test to verify it fails

Run: npm run test -- DocumentEditor.test.tsx Expected: FAIL with "Cannot find module '../../src/components/DocumentEditor'"

  • Step 3: Write minimal implementation

frontend/src/components/DocumentEditor.tsx:

import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { getDocument } from '../api/client';

interface DocumentEditorProps {
  documentKey: string;
}

export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
  const [content, setContent] = useState('');
  const [contentType, setContentType] = useState('text/markdown');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    getDocument(documentKey)
      .then((doc) => {
        setContent(doc.content);
        setContentType(doc.contentType);
        setLoading(false);
      })
      .catch(() => {
        setError('无法加载文档');
        setLoading(false);
      });
  }, [documentKey]);

  if (loading) {
    return <div data-testid="document-editor-loading">加载中…</div>;
  }

  if (error) {
    return <div data-testid="document-editor-error">{error}</div>;
  }

  return (
    <div data-testid="document-editor">
      <span data-testid="save-status">已归档</span>
      <Editor value={content} onChange={() => {}} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
    </div>
  );
}
  • Step 4: Run test to verify it passes

Run: npm run test -- DocumentEditor.test.tsx Expected: PASS

  • Step 5: Write the failing test for dirty tracking and save

Append to frontend/tests/components/DocumentEditor.test.tsx:

import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';

test('typing marks the document dirty and saving clears the dirty state', async () => {
  vi.mocked(getDocument).mockResolvedValue({
    key: '产品文档/架构设计.md',
    content: '# 架构设计',
    contentType: 'text/markdown',
  });
  vi.mocked(saveDocument).mockResolvedValue({
    key: '产品文档/架构设计.md',
    updatedAt: '2026-08-01T10:05:00Z',
  });
  const user = userEvent.setup();

  render(<DocumentEditor documentKey="产品文档/架构设计.md" />);

  const textarea = await screen.findByTestId('monaco-editor-mock');
  await user.clear(textarea);
  await user.type(textarea, '# 新内容');

  expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');

  const saveButton = screen.getByRole('button', { name: '保存' });
  await user.click(saveButton);

  await waitFor(() => {
    expect(saveDocument).toHaveBeenCalledWith('产品文档/架构设计.md', '# 新内容', 'text/markdown');
  });
  await waitFor(() => {
    expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
  });
});
  • Step 6: Run test to verify it fails

Run: npm run test -- DocumentEditor.test.tsx Expected: FAIL with "Unable to find an accessible element with the role "button" and name "保存""

  • Step 7: Write the full implementation with dirty state and save

Replace the contents of frontend/src/components/DocumentEditor.tsx:

import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { getDocument, saveDocument } from '../api/client';

interface DocumentEditorProps {
  documentKey: string;
}

export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
  const [content, setContent] = useState('');
  const [originalContent, setOriginalContent] = useState('');
  const [contentType, setContentType] = useState('text/markdown');
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    getDocument(documentKey)
      .then((doc) => {
        setContent(doc.content);
        setOriginalContent(doc.content);
        setContentType(doc.contentType);
        setLoading(false);
      })
      .catch(() => {
        setError('无法加载文档');
        setLoading(false);
      });
  }, [documentKey]);

  const isDirty = content !== originalContent;

  function handleChange(value: string | undefined) {
    setContent(value ?? '');
  }

  function handleSave() {
    setSaving(true);
    saveDocument(documentKey, content, contentType)
      .then(() => {
        setOriginalContent(content);
        setSaving(false);
      })
      .catch(() => {
        setError('保存失败');
        setSaving(false);
      });
  }

  if (loading) {
    return <div data-testid="document-editor-loading">加载中…</div>;
  }

  if (error) {
    return <div data-testid="document-editor-error">{error}</div>;
  }

  return (
    <div data-testid="document-editor">
      <div className="doc-header">
        <span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
        <button onClick={handleSave} disabled={!isDirty || saving}>
          {saving ? '保存中…' : '保存'}
        </button>
      </div>
      <Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
    </div>
  );
}
  • Step 8: Run test to verify it passes

Run: npm run test -- DocumentEditor.test.tsx Expected: PASS

  • Step 9: Commit
git add frontend/src/components/DocumentEditor.tsx frontend/tests/components/DocumentEditor.test.tsx
git commit -m "feat: add DocumentEditor with dirty tracking and save"

Task 13: SearchBar.tsx + SearchResults.tsx

Files:

  • Create: frontend/src/components/SearchBar.tsx
  • Create: frontend/src/components/SearchResults.tsx
  • Test: frontend/tests/components/SearchBar.test.tsx
  • Test: frontend/tests/components/SearchResults.test.tsx

Interfaces:

  • Consumes: search(query: string): Promise<SearchResponse>, SearchResult from ../api/client

  • Produces: export default function SearchBar({ onQueryChange, onResults, debounceMs }: { onQueryChange: (query: string) => void; onResults: (results: SearchResult[]) => void; debounceMs?: number }): JSX.Element and export default function SearchResults({ results, query, onJumpToResult }: { results: SearchResult[]; query: string; onJumpToResult: (key: string) => void }): JSX.Element — both consumed by App.tsx in Task 15.

  • Step 1: Write the failing tests for SearchBar debounce behavior

frontend/tests/components/SearchBar.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import SearchBar from '../../src/components/SearchBar';
import { search } from '../../src/api/client';

vi.mock('../../src/api/client');

beforeEach(() => {
  vi.mocked(search).mockReset();
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

test('debounces input for 300ms before calling search', async () => {
  vi.mocked(search).mockResolvedValue({ results: [] });
  const onQueryChange = vi.fn();
  const onResults = vi.fn();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

  render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);

  const input = screen.getByRole('textbox');
  await user.type(input, '检索');

  expect(onQueryChange).toHaveBeenLastCalledWith('检索');
  expect(search).not.toHaveBeenCalled();

  vi.advanceTimersByTime(300);

  expect(search).toHaveBeenCalledWith('检索');
});

test('clearing the input immediately reports empty results without calling search', async () => {
  const onQueryChange = vi.fn();
  const onResults = vi.fn();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

  render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);

  const input = screen.getByRole('textbox');
  await user.type(input, 'x');
  vi.advanceTimersByTime(300);
  await user.clear(input);

  expect(onResults).toHaveBeenCalledWith([]);
});
  • Step 2: Run test to verify it fails

Run: npm run test -- SearchBar.test.tsx Expected: FAIL with "Cannot find module '../../src/components/SearchBar'"

  • Step 3: Write minimal implementation

frontend/src/components/SearchBar.tsx:

import { useEffect, useRef, useState } from 'react';
import { search, type SearchResult } from '../api/client';

interface SearchBarProps {
  onQueryChange: (query: string) => void;
  onResults: (results: SearchResult[]) => void;
  debounceMs?: number;
}

export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
  const [value, setValue] = useState('');
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const nextValue = event.target.value;
    setValue(nextValue);
    onQueryChange(nextValue);

    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }

    if (nextValue.trim() === '') {
      onResults([]);
      return;
    }

    timerRef.current = setTimeout(() => {
      search(nextValue).then((response) => {
        onResults(response.results);
      });
    }, debounceMs);
  }

  return (
    <input
      type="text"
      placeholder="检索全部文档内容…"
      value={value}
      onChange={handleChange}
      aria-label="搜索文档"
    />
  );
}
  • Step 4: Run test to verify it passes

Run: npm run test -- SearchBar.test.tsx Expected: PASS

  • Step 5: Write the failing tests for SearchResults

frontend/tests/components/SearchResults.test.tsx:

import { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, vi } from 'vitest';
import SearchResults from '../../src/components/SearchResults';

test('renders result count, titles, and highlighted snippets', () => {
  render(
    <SearchResults
      query="检索"
      results={[
        {
          key: '产品文档/架构设计.md',
          title: '架构设计.md',
          path: '产品文档',
          snippet: '检索Meilisearch支持 <em>中文全文检索</em>。',
        },
      ]}
      onJumpToResult={vi.fn()}
    />
  );

  expect(screen.getByTestId('search-results-count')).toHaveTextContent('1 条与「检索」相关的结果');
  expect(screen.getByText('架构设计.md')).toBeInTheDocument();
  expect(document.querySelector('em')).toHaveTextContent('中文全文检索');
});

test('clicking a result calls onJumpToResult with its key', () => {
  const onJumpToResult = vi.fn();
  render(
    <SearchResults
      query="检索"
      results={[{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...' }]}
      onJumpToResult={onJumpToResult}
    />
  );

  fireEvent.click(screen.getByTestId('search-result-产品文档/架构设计.md'));

  expect(onJumpToResult).toHaveBeenCalledWith('产品文档/架构设计.md');
});

test('shows an empty state when there are no results', () => {
  render(<SearchResults query="xyz" results={[]} onJumpToResult={vi.fn()} />);
  expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
});
  • Step 6: Run test to verify it fails

Run: npm run test -- SearchResults.test.tsx Expected: FAIL with "Cannot find module '../../src/components/SearchResults'"

  • Step 7: Write minimal implementation

frontend/src/components/SearchResults.tsx:

import type { SearchResult } from '../api/client';

interface SearchResultsProps {
  results: SearchResult[];
  query: string;
  onJumpToResult: (key: string) => void;
}

export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
  if (results.length === 0) {
    return <div data-testid="search-no-results">没有找到匹配的文档</div>;
  }

  return (
    <div data-testid="search-results">
      <div data-testid="search-results-count">
        {results.length} 条与「{query}」相关的结果
      </div>
      {results.map((result) => (
        <div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
          <div className="result-title">{result.title}</div>
          <div className="result-path">{result.path}</div>
          <div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
        </div>
      ))}
    </div>
  );
}
  • Step 8: Run test to verify it passes

Run: npm run test -- SearchResults.test.tsx Expected: PASS

  • Step 9: Commit
git add frontend/src/components/SearchBar.tsx frontend/src/components/SearchResults.tsx frontend/tests/components/SearchBar.test.tsx frontend/tests/components/SearchResults.test.tsx
git commit -m "feat: add debounced SearchBar and highlighted SearchResults"

Task 14: UploadButton.tsx

Files:

  • Create: frontend/src/components/UploadButton.tsx
  • Test: frontend/tests/components/UploadButton.test.tsx

Interfaces:

  • Consumes: uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> from ../api/client

  • Produces: export default function UploadButton({ path, onUploaded }: { path: string; onUploaded: (result: UploadDocumentResponse) => void }): JSX.Element — consumed by App.tsx in Task 15.

  • Step 1: Write the failing tests for the modal and click-to-select upload

frontend/tests/components/UploadButton.test.tsx:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import UploadButton from '../../src/components/UploadButton';
import { uploadDocument } from '../../src/api/client';

vi.mock('../../src/api/client');

beforeEach(() => {
  vi.mocked(uploadDocument).mockReset();
});

test('opens the upload modal when the button is clicked', async () => {
  const user = userEvent.setup();
  render(<UploadButton path="产品文档" onUploaded={vi.fn()} />);

  expect(screen.queryByTestId('upload-modal')).not.toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: '上传文档' }));

  expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
});

test('selecting a file through the hidden input uploads it', async () => {
  vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 });
  const onUploaded = vi.fn();
  const user = userEvent.setup();

  render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
  await user.click(screen.getByRole('button', { name: '上传文档' }));

  const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
  const input = screen.getByTestId('upload-file-input');
  await user.upload(input, file);

  expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档');
  await waitFor(() => {
    expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 });
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- UploadButton.test.tsx Expected: FAIL with "Cannot find module '../../src/components/UploadButton'"

  • Step 3: Write minimal implementation (modal + click-to-select only)

frontend/src/components/UploadButton.tsx:

import { useRef, useState } from 'react';
import { uploadDocument, type UploadDocumentResponse } from '../api/client';

interface UploadButtonProps {
  path: string;
  onUploaded: (result: UploadDocumentResponse) => void;
}

export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
  const [isOpen, setIsOpen] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  function openModal() {
    setIsOpen(true);
  }

  function closeModal() {
    setIsOpen(false);
  }

  function uploadFiles(files: FileList | File[]) {
    Array.from(files).forEach((file) => {
      uploadDocument(file, path).then((result) => {
        onUploaded(result);
      });
    });
  }

  function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    if (event.target.files) {
      uploadFiles(event.target.files);
    }
    event.target.value = '';
  }

  return (
    <>
      <button onClick={openModal}>上传文档</button>
      {isOpen && (
        <div className="modal-backdrop show" data-testid="upload-modal">
          <div className="modal">
            <div className="dropzone" data-testid="dropzone" onClick={() => fileInputRef.current?.click()}>
              拖拽文件到此处,或点击选择
            </div>
            <input
              ref={fileInputRef}
              type="file"
              multiple
              data-testid="upload-file-input"
              style={{ display: 'none' }}
              onChange={handleFileInputChange}
            />
            <button onClick={closeModal}>取消</button>
          </div>
        </div>
      )}
    </>
  );
}
  • Step 4: Run test to verify it passes

Run: npm run test -- UploadButton.test.tsx Expected: PASS

  • Step 5: Write the failing test for drag-and-drop upload

Append to frontend/tests/components/UploadButton.test.tsx:

import { fireEvent } from '@testing-library/react';

test('dropping a file onto the dropzone uploads it', async () => {
  vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
  const onUploaded = vi.fn();
  const user = userEvent.setup();

  render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
  await user.click(screen.getByRole('button', { name: '上传文档' }));

  const file = new File(['内容'], '拖拽文档.md', { type: 'text/markdown' });
  const dropzone = screen.getByTestId('dropzone');

  fireEvent.drop(dropzone, {
    dataTransfer: { files: [file] },
  });

  expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档');
  await waitFor(() => {
    expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
  });
});
  • Step 6: Run test to verify it fails

Run: npm run test -- UploadButton.test.tsx Expected: FAIL with "expected "spy" to be called with arguments: [ File, '产品文档' ]" (uploadDocument was never called because the dropzone has no onDrop handler)

  • Step 7: Write the full implementation with drag-and-drop support

Replace the contents of frontend/src/components/UploadButton.tsx:

import { useRef, useState } from 'react';
import { uploadDocument, type UploadDocumentResponse } from '../api/client';

interface UploadButtonProps {
  path: string;
  onUploaded: (result: UploadDocumentResponse) => void;
}

export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [isDragOver, setIsDragOver] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  function openModal() {
    setIsOpen(true);
  }

  function closeModal() {
    setIsOpen(false);
    setIsDragOver(false);
  }

  function uploadFiles(files: FileList | File[]) {
    Array.from(files).forEach((file) => {
      uploadDocument(file, path).then((result) => {
        onUploaded(result);
      });
    });
  }

  function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    if (event.target.files) {
      uploadFiles(event.target.files);
    }
    event.target.value = '';
  }

  function handleDrop(event: React.DragEvent<HTMLDivElement>) {
    event.preventDefault();
    setIsDragOver(false);
    if (event.dataTransfer.files) {
      uploadFiles(event.dataTransfer.files);
    }
  }

  function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
    event.preventDefault();
    setIsDragOver(true);
  }

  function handleDragLeave() {
    setIsDragOver(false);
  }

  return (
    <>
      <button onClick={openModal}>上传文档</button>
      {isOpen && (
        <div className="modal-backdrop show" data-testid="upload-modal">
          <div className="modal">
            <div
              className={isDragOver ? 'dropzone dragover' : 'dropzone'}
              data-testid="dropzone"
              onClick={() => fileInputRef.current?.click()}
              onDrop={handleDrop}
              onDragOver={handleDragOver}
              onDragLeave={handleDragLeave}
            >
              拖拽文件到此处,或点击选择
            </div>
            <input
              ref={fileInputRef}
              type="file"
              multiple
              data-testid="upload-file-input"
              style={{ display: 'none' }}
              onChange={handleFileInputChange}
            />
            <button onClick={closeModal}>取消</button>
          </div>
        </div>
      )}
    </>
  );
}
  • Step 8: Run test to verify it passes

Run: npm run test -- UploadButton.test.tsx Expected: PASS

  • Step 9: Commit
git add frontend/src/components/UploadButton.tsx frontend/tests/components/UploadButton.test.tsx
git commit -m "feat: add UploadButton with click-to-select and drag-and-drop"

Task 15: App.tsx integration wiring

Files:

  • Modify: frontend/src/App.tsx
  • Modify: frontend/tests/App.test.tsx

Interfaces:

  • Consumes: FileExplorer (Task 11), DocumentEditor (Task 12), SearchBar, SearchResults (Task 13), UploadButton (Task 14), UploadDocumentResponse/SearchResult types from ../api/client (Task 10)

  • Produces: fully wired export default function App(): JSX.Element — the complete frontend entrypoint rendered by frontend/src/main.tsx.

  • Step 1: Write the failing integration tests

Append to frontend/tests/App.test.tsx:

import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { listDocuments, getDocument, search } from '../src/api/client';

vi.mock('../src/api/client');

vi.mock('@monaco-editor/react', () => ({
  default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
    <textarea
      data-testid="monaco-editor-mock"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  ),
}));

beforeEach(() => {
  vi.mocked(listDocuments).mockReset();
  vi.mocked(getDocument).mockReset();
  vi.mocked(search).mockReset();
});

test('shows an empty state and a file tree when no document is selected', async () => {
  vi.mocked(listDocuments).mockResolvedValue({
    folders: [],
    files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
  });

  render(<App />);

  expect(screen.getByTestId('empty-state')).toBeInTheDocument();
  expect(await screen.findByText('架构设计.md')).toBeInTheDocument();
});

test('selecting a file from the tree loads it into the editor', async () => {
  vi.mocked(listDocuments).mockResolvedValue({
    folders: [],
    files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 100, modifiedAt: '2026-08-01T10:00:00Z' }],
  });
  vi.mocked(getDocument).mockResolvedValue({
    key: '产品文档/架构设计.md',
    content: '# 架构设计',
    contentType: 'text/markdown',
  });
  const user = userEvent.setup();

  render(<App />);

  const fileRow = await screen.findByText('架构设计.md');
  await user.click(fileRow);

  const textarea = await screen.findByTestId('monaco-editor-mock');
  expect(textarea).toHaveValue('# 架构设计');
});

test('searching shows the results overlay, and clicking a result opens the document', async () => {
  vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
  vi.mocked(search).mockResolvedValue({
    results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
  });
  vi.mocked(getDocument).mockResolvedValue({
    key: '产品文档/架构设计.md',
    content: '# 架构设计',
    contentType: 'text/markdown',
  });
  const user = userEvent.setup();

  render(<App />);

  const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
  await user.type(searchInput, '检索');

  await waitFor(
    () => {
      expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
    },
    { timeout: 1000 }
  );

  await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));

  await waitFor(() => {
    expect(screen.queryByTestId('search-results')).not.toBeInTheDocument();
  });
  const textarea = await screen.findByTestId('monaco-editor-mock');
  expect(textarea).toHaveValue('# 架构设计');
});
  • Step 2: Run test to verify it fails

Run: npm run test -- App.test.tsx Expected: FAIL with "Unable to find an element by: [data-testid="empty-state"]" (the scaffolded App.tsx from Task 9 only renders a heading)

  • Step 3: Write the full implementation wiring all components

Replace the contents of frontend/src/App.tsx:

import { useState } from 'react';
import FileExplorer from './components/FileExplorer';
import DocumentEditor from './components/DocumentEditor';
import SearchBar from './components/SearchBar';
import SearchResults from './components/SearchResults';
import UploadButton from './components/UploadButton';
import type { SearchResult, UploadDocumentResponse } from './api/client';

export default function App() {
  const [selectedKey, setSelectedKey] = useState<string | null>(null);
  const [searchQuery, setSearchQuery] = useState('');
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);

  function handleJumpToResult(key: string) {
    setSelectedKey(key);
    setSearchQuery('');
    setSearchResults([]);
  }

  function handleUploaded(result: UploadDocumentResponse) {
    setSelectedKey(result.key);
  }

  return (
    <div className="app">
      <div className="topbar">
        <h1>DocHub</h1>
        <SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
        <UploadButton path="" onUploaded={handleUploaded} />
      </div>
      <div className="body">
        <FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
        <div className="main" data-testid="main-area">
          {selectedKey ? (
            <DocumentEditor documentKey={selectedKey} />
          ) : (
            <div data-testid="empty-state">未选择文档</div>
          )}
        </div>
        {searchQuery !== '' && (
          <SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
        )}
      </div>
    </div>
  );
}
  • Step 4: Run test to verify it passes

Run: npm run test -- App.test.tsx Expected: PASS (including the original renders the DocHub heading test from Task 9)

  • Step 5: Run the full test suite

Run: npm run test Expected: PASS for all suites (client.test.ts, FileExplorer.test.tsx, DocumentEditor.test.tsx, SearchBar.test.tsx, SearchResults.test.tsx, UploadButton.test.tsx, App.test.tsx)

  • Step 6: Commit
git add frontend/src/App.tsx frontend/tests/App.test.tsx
git commit -m "feat: wire FileExplorer, DocumentEditor, search and upload into App"