M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
8.1 KiB
Python
248 lines
8.1 KiB
Python
"""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,
|
||
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="测试前提")
|
||
|
||
|
||
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_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("改稿更长")
|
||
|
||
|
||
@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
|