Files
writer-work-flow/apps/api/tests/test_projects.py
Yaojia Wang 0d473e726e feat(b0): 注入透明可控版后端 — PUT override(pin/排除/recent_n) + 选择函数加参 + draft 同读
- selection.select_relevant_entities 加 pinned/excluded frozenset 入参;新理由 author_pin(末位)
- assemble 加 override 关键字参(recent_n 覆盖回看章数 + pin/排除);GET/PUT/draft 同读同一覆盖(不变量 #6 作者兜底)
- 新 domain/injection_repo.py(InjectionOverride/EntityRef/SqlInjectionOverrideRepo,upsert 只 flush)
- 新表 chapter_injection(迁移 ad2c4c663daf,唯一 project_id+chapter_no;复用 outline 行已否决)
- PUT/GET injection 端点 + InjectionOverrideRequest/回显 pinned/excluded;recent_n 1..20 校验 422
- 单测:selection pin/排除/exclude>pin + assemble override + 端点 PUT/404/422;regen TS 客户端
- 门禁绿:ruff/format · mypy 163 · alembic 无漂移 · pytest 476

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:56:54 +02:00

258 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。
DoD端点入 OpenAPIPOST 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,
ProjectSpecView,
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
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return []
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 []
class _StubProjectSpecRepo:
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return ProjectSpecView(title="测试作品", premise="测试前提")
class _NoInjectionRepo:
"""空注入覆盖 stubdraft 端点读它得 None纯自动选择不打真 DB。"""
async def get(self, project_id: uuid.UUID, chapter_no: int) -> None:
return None
def _empty_memory_repos() -> MemoryRepos:
return MemoryRepos(
outline=_EmptyOutlineRepo(),
character=_EmptyCharacterRepo(),
world_entity=_EmptyWorldEntityRepo(),
digest=_EmptyDigestRepo(),
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
)
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_injection_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
# draft 端点现读注入覆盖:注空 stub避免单测打真 DB无覆盖 → 纯自动选择)。
app.dependency_overrides[get_injection_repo] = lambda: _NoInjectionRepo()
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("改稿更长")
@pytest.mark.asyncio
async def test_get_draft_returns_saved_content() -> None:
chapter_repo = FakeChapterRepo()
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
pid = uuid.uuid4()
async with client:
await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"})
resp = await client.get(f"/projects/{pid}/chapters/5/draft")
assert resp.status_code == 200
body = resp.json()
assert body["content"] == "阿福重访工作台"
assert body["status"] == "draft"
assert body["version"] == 1
assert body["chapter_no"] == 5
assert body["length"] == len("阿福重访工作台")
@pytest.mark.asyncio
async def test_get_draft_404_when_no_draft() -> None:
client, _, _, _ = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND