feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
210
apps/api/tests/test_projects.py
Normal file
210
apps/api/tests/test_projects.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。
|
||||
|
||||
DoD:端点入 OpenAPI;POST draft 回 SSE 流(mock 网关验证);PUT draft 幂等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_shared import ErrorCode
|
||||
|
||||
|
||||
class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
|
||||
class _EmptyCharacterRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyWorldEntityRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyDigestRepo:
|
||||
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyForeshadowRepo:
|
||||
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyStyleRepo:
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
|
||||
return None
|
||||
|
||||
|
||||
class _EmptyRulesRepo:
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
return []
|
||||
|
||||
|
||||
def _empty_memory_repos() -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
character=_EmptyCharacterRepo(),
|
||||
world_entity=_EmptyWorldEntityRepo(),
|
||||
digest=_EmptyDigestRepo(),
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
)
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo | None = None,
|
||||
chapter_repo: FakeChapterRepo | None = None,
|
||||
gateway: FakeWriterGateway | None = None,
|
||||
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_chapter_repo,
|
||||
get_memory_repos,
|
||||
get_project_repo,
|
||||
get_writer_gateway,
|
||||
)
|
||||
|
||||
project_repo = project_repo or FakeProjectRepo()
|
||||
chapter_repo = chapter_repo or FakeChapterRepo()
|
||||
gateway = gateway or FakeWriterGateway()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
|
||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||
app.dependency_overrides[get_writer_gateway] = lambda: gateway
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, project_repo, chapter_repo, gateway
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_returns_201() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/projects",
|
||||
json={"title": "我的网文", "genre": "玄幻", "selling_points": ["爽点密集"]},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["title"] == "我的网文"
|
||||
assert body["genre"] == "玄幻"
|
||||
assert body["selling_points"] == ["爽点密集"]
|
||||
assert uuid.UUID(body["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects() -> None:
|
||||
client, repo, _, _ = _make_client()
|
||||
async with client:
|
||||
await client.post("/projects", json={"title": "甲"})
|
||||
await client.post("/projects", json={"title": "乙"})
|
||||
resp = await client.get("/projects")
|
||||
assert resp.status_code == 200
|
||||
titles = {p["title"] for p in resp.json()["projects"]}
|
||||
assert titles == {"甲", "乙"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_detail() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
created = (await client.post("/projects", json={"title": "详情"})).json()
|
||||
resp = await client.get(f"/projects/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "详情"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unknown_project_404() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_blank_title() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.post("/projects", json={"title": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
|
||||
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
text = resp.text
|
||||
assert "event: token" in text
|
||||
assert '"text": "阿福"' in text
|
||||
assert "event: done" in text
|
||||
# done 带累计长度(不回灌全文)
|
||||
assert '"length": 6' in text
|
||||
# 网关确实被调用,且只传 tier(不传具体 model)
|
||||
assert len(gateway.requests) == 1
|
||||
assert gateway.requests[0].tier == "writer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
|
||||
from ww_shared import AppError
|
||||
|
||||
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
assert "event: token" in text
|
||||
assert "event: error" in text
|
||||
assert ErrorCode.LLM_UNAVAILABLE in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_draft_is_idempotent() -> None:
|
||||
chapter_repo = FakeChapterRepo()
|
||||
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
r1 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "初稿"})
|
||||
r2 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "改稿更长"})
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
# 同章节只一条草稿(版次不爆炸)
|
||||
assert len(chapter_repo.drafts) == 1
|
||||
saved = chapter_repo.drafts[(pid, 3)]
|
||||
assert saved.content == "改稿更长"
|
||||
assert saved.version == 1
|
||||
assert r2.json()["length"] == len("改稿更长")
|
||||
Reference in New Issue
Block a user