"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。 DoD:端点入 OpenAPI;POST draft 回 SSE 流(mock 网关验证);PUT draft 幂等。 """ from __future__ import annotations import uuid import httpx import pytest from cryptography.fernet import Fernet from fakes_projects import ( FakeChapterRepo, FakeProjectRepo, FakeReviewGateway, FakeSession, FakeWriterGateway, ) from fastapi import FastAPI from ww_api.services.credentials import STUB_OWNER_ID from ww_core.domain.project_repo import ProjectView 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, *, volume: int | None = None ) -> 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: """空注入覆盖 stub:draft 端点读它得 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", Fernet.generate_key().decode()) 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 def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID: """Seed 一个属于 STUB_OWNER_ID 的项目,返回其 pid。 stream_draft 现先校验项目存在(QA C1),流式用例须用已存在项目,否则 404。 """ pid = uuid.uuid4() project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试")) return pid @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_create_project_persists_tone_ending_pov() -> None: # ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显(snake_case 契约)。 client, repo, _, _ = _make_client() async with client: resp = await client.post( "/projects", json={ "title": "基调作品", "tone": "热血", "ending_type": "HE", "narrative_pov": "第一人称", }, ) assert resp.status_code == 201 body = resp.json() assert body["tone"] == "热血" assert body["ending_type"] == "HE" assert body["narrative_pov"] == "第一人称" (_owner, view) = next(iter(repo.rows.values())) assert view.tone == "热血" assert view.ending_type == "HE" assert view.narrative_pov == "第一人称" @pytest.mark.asyncio async def test_get_project_exposes_tone_ending_pov() -> None: client, _, _, _ = _make_client() async with client: created = ( await client.post( "/projects", json={ "title": "书", "tone": "沉重", "ending_type": "BE", "narrative_pov": "多视角", }, ) ).json() resp = await client.get(f"/projects/{created['id']}") assert resp.status_code == 200 body = resp.json() assert body["tone"] == "沉重" assert body["ending_type"] == "BE" assert body["narrative_pov"] == "多视角" @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 projects = resp.json()["projects"] titles = {p["title"] for p in projects} assert titles == {"甲", "乙"} assert all("updated_at" in p for p in projects) assert all(p["pending_review_count"] == 0 for p in projects) @pytest.mark.asyncio async def test_list_projects_paginates_with_limit_and_offset() -> None: client, _, _, _ = _make_client() async with client: for t in ("甲", "乙", "丙"): await client.post("/projects", json={"title": t}) page1 = await client.get("/projects?limit=2") page2 = await client.get("/projects?limit=2&offset=2") assert page1.status_code == 200 assert [p["title"] for p in page1.json()["projects"]] == ["甲", "乙"] assert page2.status_code == 200 assert [p["title"] for p in page2.json()["projects"]] == ["丙"] @pytest.mark.asyncio async def test_list_projects_rejects_out_of_range_pagination() -> None: client, _, _, _ = _make_client() async with client: too_small = await client.get("/projects?limit=0") too_large = await client.get("/projects?limit=201") neg_offset = await client.get("/projects?offset=-1") # 边界越界 → FastAPI 422(limit∈[1,200]、offset≥0)。 assert too_small.status_code == 422 assert too_large.status_code == 422 assert neg_offset.status_code == 422 @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 body = resp.json() assert body["title"] == "详情" assert "updated_at" in body assert body["pending_review_count"] == 0 @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, project_repo, _, _ = _make_client(gateway=gateway) pid = _seed_project(project_repo) 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_threads_directive_into_volatile() -> None: gateway = FakeWriterGateway(chunks=["正文"]) client, project_repo, _, _ = _make_client(gateway=gateway) pid = _seed_project(project_repo) async with client: resp = await client.post( f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"} ) assert resp.status_code == 200 assert len(gateway.requests) == 1 # 指令直通 assemble→volatile(write 节点把 volatile 放进 LlmRequest.input)。 assert "多写战斗" in str(gateway.requests[0].input) @pytest.mark.asyncio async def test_draft_stream_backward_compatible_without_body() -> None: gateway = FakeWriterGateway(chunks=["正文"]) client, project_repo, _, _ = _make_client(gateway=gateway) pid = _seed_project(project_repo) async with client: resp = await client.post(f"/projects/{pid}/chapters/1/draft") assert resp.status_code == 200 assert len(gateway.requests) == 1 assert "本章指令" not in str(gateway.requests[0].input) @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, project_repo, _, _ = _make_client(gateway=gateway) pid = _seed_project(project_repo) 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_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None: # QA C1 回归:对不存在的 project 流式写章必须 404,且绝不触网关(不烧 LLM 调用)。 gateway = FakeWriterGateway(chunks=["不该被生成"]) client, _project_repo, _, _ = _make_client(gateway=gateway) async with client: resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft") assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND assert len(gateway.requests) == 0 # 未触达网关 @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 # ---- POST /rewrite/clarify(整章重写预检澄清,WFW-9 M2)---- # 摘录上界外的哨兵:喂超长 prior_draft,断言超界正文不进网关 input(不喂全章、不泄漏)。 _LEAK_SENTINEL = "泄漏哨兵段落禁止外泄" def _need_clarification_decision() -> object: from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion return ClarifyDecision( need_clarification=True, questions=[ ClarifyQuestion( question="你说本章节奏太慢,是想往哪个方向重写整章?", options=[ ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"), ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"), ], allow_free_text=True, ) ], ) def _clear_rewrite_decision() -> object: from ww_agents import ClarifyDecision return ClarifyDecision( need_clarification=False, verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?", ) def _clarify_app( *, project_repo: FakeProjectRepo, session: FakeSession | None = None, clarify_gateway: object | None = None, ) -> FastAPI: """构造只挂 rewrite/clarify 端点依赖的 app(project_repo + session + clarify 网关)。""" import os os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from ww_api.main import create_app from ww_api.services.project_deps import get_clarify_gateway, get_project_repo from ww_db import get_session app = create_app() app.dependency_overrides[get_project_repo] = lambda: project_repo app.dependency_overrides[get_session] = lambda: session or FakeSession() if clarify_gateway is not None: app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway return app def _clarify_client(app: FastAPI) -> httpx.AsyncClient: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) return httpx.AsyncClient(transport=transport, base_url="http://test") @pytest.mark.asyncio async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None: # (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。 project_repo = FakeProjectRepo() pid = _seed_project(project_repo) session = FakeSession() gateway = FakeReviewGateway(parsed=_need_clarification_decision()) app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) async with _clarify_client(app) as client: resp = await client.post( f"/projects/{pid}/chapters/3/rewrite/clarify", json={"feedback": "节奏太慢", "prior_draft": "本章开头……"}, ) assert resp.status_code == 200 body = resp.json() assert body["need_clarification"] is True assert len(body["questions"]) == 1 assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅" assert body["questions"][0]["allow_free_text"] is True # 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。 assert gateway.requests[0].tier == "analyst" assert "节奏太慢" in str(gateway.requests[0].input) assert session.commits == 1 @pytest.mark.asyncio async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None: # (b) 明确意见 → need_clarification False + verification;只读、无回滚。 project_repo = FakeProjectRepo() pid = _seed_project(project_repo) session = FakeSession() gateway = FakeReviewGateway(parsed=_clear_rewrite_decision()) app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) async with _clarify_client(app) as client: resp = await client.post( f"/projects/{pid}/chapters/3/rewrite/clarify", json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"}, ) assert resp.status_code == 200 body = resp.json() assert body["need_clarification"] is False assert body["questions"] == [] assert body["verification"] is not None # 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。 assert session.commits == 1 assert session.rollbacks == 0 @pytest.mark.asyncio async def test_rewrite_clarify_unknown_project_404() -> None: # (c) 项目不存在 → 404(触网关前 fail-fast)。 app = _clarify_app( project_repo=FakeProjectRepo(), clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()), ) async with _clarify_client(app) as client: resp = await client.post( f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify", json={"feedback": "节奏太慢"}, ) assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None: # (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。 from ww_agents import WorldGenResult project_repo = FakeProjectRepo() pid = _seed_project(project_repo) session = FakeSession() gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[])) app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) async with _clarify_client(app) as client: resp = await client.post( f"/projects/{pid}/chapters/3/rewrite/clarify", json={"feedback": "重写一遍"}, ) assert resp.status_code == 200 assert resp.json()["need_clarification"] is False @pytest.mark.asyncio async def test_rewrite_clarify_empty_feedback_422() -> None: project_repo = FakeProjectRepo() pid = _seed_project(project_repo) app = _clarify_app( project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()), ) async with _clarify_client(app) as client: resp = await client.post( f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""} ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None: # 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。 project_repo = FakeProjectRepo() pid = _seed_project(project_repo) session = FakeSession() gateway = FakeReviewGateway(parsed=_clear_rewrite_decision()) app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway) # 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。 prior_draft = ("正" * 10_000) + _LEAK_SENTINEL async with _clarify_client(app) as client: resp = await client.post( f"/projects/{pid}/chapters/3/rewrite/clarify", json={"feedback": "重写本章", "prior_draft": prior_draft}, ) assert resp.status_code == 200 # 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。 assert _LEAK_SENTINEL not in str(gateway.requests[0].input) assert _LEAK_SENTINEL not in resp.text