feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI

- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web)
- SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub)
- FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI
- Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api)
- CI(ruff/mypy/pytest + pg service + alembic 漂移校验)
- 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
commit d3dc620a71
74 changed files with 12960 additions and 0 deletions

0
tests/__init__.py Normal file
View File

14
tests/conftest.py Normal file
View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
from ww_api.main import app
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
def test_app_error_maps_to_http_status() -> None:
assert AppError(ErrorCode.NOT_FOUND, "x").http_status == 404
assert AppError(ErrorCode.CONFLICT_UNRESOLVED, "x").http_status == 409
def test_envelope_serializes_with_request_id() -> None:
env = ErrorEnvelope(
error=ErrorBody(code=ErrorCode.VALIDATION, message="bad", request_id="rid-1")
)
dumped = env.model_dump()
assert dumped["error"]["code"] == "VALIDATION"
assert dumped["error"]["request_id"] == "rid-1"

24
tests/test_health.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import httpx
async def test_root_returns_ok(client: httpx.AsyncClient) -> None:
resp = await client.get("/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_health_endpoint(client: httpx.AsyncClient) -> None:
resp = await client.get("/health")
assert resp.status_code == 200
async def test_response_carries_request_id_header(client: httpx.AsyncClient) -> None:
resp = await client.get("/health")
assert resp.headers.get("x-request-id")
async def test_propagates_incoming_request_id(client: httpx.AsyncClient) -> None:
resp = await client.get("/health", headers={"x-request-id": "abc123"})
assert resp.headers["x-request-id"] == "abc123"

View File

@@ -0,0 +1,55 @@
"""集成测试GET /jobs/:id 走真实 DB无 DB 时跳过)。
get_sessionmaker 缓存的 async engine 绑定首个事件循环pytest-asyncio 每个
测试用新循环,故每个 DB 测试清缓存以在当前循环上重建 engine并在结束 dispose。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_db import get_sessionmaker
from ww_db.models import Job
@pytest.fixture
async def sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(text("select 1"))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
async def test_get_job_returns_row(
client: httpx.AsyncClient, sm: async_sessionmaker[AsyncSession]
) -> None:
async with sm() as s:
job = Job(kind="style_learn", status="running", progress=42)
s.add(job)
await s.commit()
job_id = job.id
resp = await client.get(f"/jobs/{job_id}")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "style_learn"
assert body["progress"] == 42
async def test_get_missing_job_returns_envelope(
client: httpx.AsyncClient, sm: async_sessionmaker[AsyncSession]
) -> None:
resp = await client.get("/jobs/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
err = resp.json()["error"]
assert err["code"] == "NOT_FOUND"
assert err["request_id"]

10
tests/test_openapi.py Normal file
View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from ww_api.main import app
def test_openapi_exposes_phase0_endpoints() -> None:
paths = app.openapi()["paths"]
assert "/" in paths
assert "/health" in paths
assert "/jobs/{job_id}" in paths