"""T2.5 review API + 历史 / T2.4 验收事务 + 冲突 gate(内存替身,无 DB/无网络)。 覆盖:review SSE 帧序列(section/conflict/done)、reviews 历史、accept happy path (晋升 + digest + 裁决)、冲突 gate 拦截(缺判 → CONFLICT_UNRESOLVED 不写库)、 事务回滚(某步失败全回滚不 commit)。审稿/digest 网关均注 mock(产 parsed),绝不联网。 """ from __future__ import annotations import uuid import httpx import pytest from cryptography.fernet import Fernet from fakes_projects import ( FakeChapterRepo, FakeDigestAppendRepo, FakeReviewGateway, FakeReviewRepo, FakeSession, FakeSessionFactory, ) from test_projects import _empty_memory_repos from ww_agents import ContinuityReview from ww_agents.schemas import Conflict from ww_shared import ErrorCode def _make_client( *, chapter_repo: FakeChapterRepo | None = None, review_repo: FakeReviewRepo | None = None, digest_repo: FakeDigestAppendRepo | None = None, review_gateway: FakeReviewGateway | None = None, digest_gateway: FakeReviewGateway | None = None, session: FakeSession | None = None, ) -> tuple[ httpx.AsyncClient, FakeChapterRepo, FakeReviewRepo, FakeDigestAppendRepo, FakeSession, ]: 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_digest_append_repo, get_digest_gateway, get_memory_repos, get_review_gateway, get_review_repo, get_session_factory, ) from ww_db import get_session chapter_repo = chapter_repo or FakeChapterRepo() review_repo = review_repo or FakeReviewRepo() digest_repo = digest_repo or FakeDigestAppendRepo() review_gateway = review_gateway or FakeReviewGateway(parsed=ContinuityReview()) digest_gateway = digest_gateway or FakeReviewGateway(parsed=ContinuityReview()) session = session or FakeSession() app = create_app() app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo app.dependency_overrides[get_review_repo] = lambda: review_repo app.dependency_overrides[get_digest_append_repo] = lambda: digest_repo app.dependency_overrides[get_memory_repos] = _empty_memory_repos app.dependency_overrides[get_review_gateway] = lambda: review_gateway app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway app.dependency_overrides[get_session] = lambda: session app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory() transport = httpx.ASGITransport(app=app) client = httpx.AsyncClient(transport=transport, base_url="http://test") return client, chapter_repo, review_repo, digest_repo, session # ---- T2.5 review SSE ---- @pytest.mark.asyncio async def test_review_stream_yields_section_and_done() -> None: gw = FakeReviewGateway(parsed=ContinuityReview()) client, _, review_repo, _, session = _make_client(review_gateway=gw) pid = uuid.uuid4() async with client: resp = await client.post( f"/projects/{pid}/chapters/1/review", json={"draft": "本章草稿正文。"} ) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/event-stream") text = resp.text assert "event: section" in text assert '"name": "continuity"' in text assert '"status": "done"' in text assert "event: done" in text # collect 落了一行留痕;端点流耗尽后 commit。 assert len(review_repo.rows) == 1 assert session.commits == 1 @pytest.mark.asyncio async def test_review_stream_emits_conflict_events() -> None: review = ContinuityReview( conflicts=[ Conflict(type="性格漂移", where="第3段", refs=["第2章"], suggestion="改回冷静"), ] ) gw = FakeReviewGateway(parsed=review) client, _, review_repo, _, _ = _make_client(review_gateway=gw) pid = uuid.uuid4() async with client: resp = await client.post(f"/projects/{pid}/chapters/1/review", json={"draft": "草稿"}) text = resp.text assert "event: conflict" in text assert '"type": "性格漂移"' in text # 冲突落进 chapter_reviews 留痕。 assert review_repo.rows[0].conflicts[0]["type"] == "性格漂移" @pytest.mark.asyncio async def test_review_without_draft_falls_back_to_saved() -> None: chapter_repo = FakeChapterRepo() pid = uuid.uuid4() await chapter_repo.save_draft(pid, 1, text="已存草稿") client, _, _, _, _ = _make_client(chapter_repo=chapter_repo) async with client: resp = await client.post(f"/projects/{pid}/chapters/1/review", json={}) assert resp.status_code == 200 assert "event: done" in resp.text @pytest.mark.asyncio async def test_review_without_any_draft_404() -> None: client, _, _, _, _ = _make_client() pid = uuid.uuid4() async with client: resp = await client.post(f"/projects/{pid}/chapters/9/review", json={}) assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND # ---- T2.5 reviews 历史 ---- @pytest.mark.asyncio async def test_list_reviews_returns_history_newest_first() -> None: review_repo = FakeReviewRepo() pid = uuid.uuid4() await review_repo.record(pid, 1, conflicts=[{"type": "设定违例"}]) await review_repo.record(pid, 1, conflicts=[]) client, _, _, _, _ = _make_client(review_repo=review_repo) async with client: resp = await client.get(f"/projects/{pid}/chapters/1/reviews") assert resp.status_code == 200 rows = resp.json()["reviews"] assert len(rows) == 2 # 新→旧:最近 record 的(空冲突)在前。 assert rows[0]["conflicts"] == [] # ---- T2.4 accept happy path ---- async def _seed_review( review_repo: FakeReviewRepo, pid: uuid.UUID, conflicts: list[dict[str, object]] ) -> None: await review_repo.record(pid, 1, conflicts=conflicts) @pytest.mark.asyncio async def test_accept_promotes_appends_digest_records_decisions() -> None: review_repo = FakeReviewRepo() pid = uuid.uuid4() await _seed_review( review_repo, pid, [{"type": "性格漂移", "where": "x", "refs": [], "suggestion": "y"}] ) from ww_api.services.digest_extraction import ChapterDigestFacts digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="本章主线", events=["开战"])) client, chapter_repo, _, digest_repo, session = _make_client( review_repo=review_repo, digest_gateway=digest_gw ) async with client: resp = await client.post( f"/projects/{pid}/chapters/1/accept", json={ "final_text": "作者改稿后的终稿正文。", "decisions": [{"conflict_index": 0, "verdict": "accept"}], }, ) assert resp.status_code == 200 body = resp.json() assert body["accepted_version"] == 1 assert body["digest_added"] is True assert body["decisions_recorded"] == 1 # 晋升落 accepted v1,终稿正文。 accepted = await chapter_repo.latest_accepted(pid, 1) assert accepted is not None assert accepted.content == "作者改稿后的终稿正文。" # digest 从终稿提炼(不变量 #4)。 assert digest_repo.rows[0][2]["summary"] == "本章主线" # 裁决写回留痕。 assert review_repo.rows[0].decisions is not None assert review_repo.rows[0].decisions["items"][0]["verdict"] == "accept" # 单事务一次 commit。 assert session.commits == 1 @pytest.mark.asyncio async def test_characterization_does_not_block_accept() -> None: """灵感③ advisory:人物塑造问题(零 continuity 冲突)不阻断验收(D2)。 留痕带 characterization issues 但 conflicts=[] → 验收 gate 只读 continuity conflicts, 直通晋升;characterization 从不进 conflicts、从不产 decisions。 """ from ww_api.services.digest_extraction import ChapterDigestFacts review_repo = FakeReviewRepo() pid = uuid.uuid4() # 零 continuity 冲突,但有 advisory 人物塑造问题。 await review_repo.record( pid, 1, conflicts=[], characterization={ "issues": [ { "character": "沈砚", "aspect": "动机一致性", "where": "第3段", "quote": "他忽然转身就走。", "diagnosis": "与既定护妹动机断裂", "suggestion": "退缩扣回护妹动机", "confidence": 0.82, } ] }, ) digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s")) client, chapter_repo, _, digest_repo, session = _make_client( review_repo=review_repo, digest_gateway=digest_gw ) async with client: resp = await client.post( f"/projects/{pid}/chapters/1/accept", # 无需对人物塑造做任何裁决(advisory 无 conflict 通道)。 json={"final_text": "作者终稿正文。", "decisions": []}, ) assert resp.status_code == 200 assert resp.json()["accepted_version"] == 1 assert session.commits == 1 # characterization 留痕不产 decisions(advisory);晋升照常。 accepted = await chapter_repo.latest_accepted(pid, 1) assert accepted is not None @pytest.mark.asyncio async def test_characterization_surfaces_in_review_history() -> None: """列 characterization 经 GET .../reviews 透传到历史项(前端面板 seed 用)。""" review_repo = FakeReviewRepo() pid = uuid.uuid4() await review_repo.record( pid, 1, conflicts=[], characterization={"issues": [{"character": "沈砚"}]} ) client, _, _, _, _ = _make_client(review_repo=review_repo) async with client: resp = await client.get(f"/projects/{pid}/chapters/1/reviews") assert resp.status_code == 200 row = resp.json()["reviews"][0] assert row["characterization"]["issues"][0]["character"] == "沈砚" @pytest.mark.asyncio async def test_accept_with_no_review_succeeds() -> None: """无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。""" from ww_api.services.digest_extraction import ChapterDigestFacts digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s")) client, chapter_repo, _, digest_repo, session = _make_client(digest_gateway=digest_gw) pid = uuid.uuid4() async with client: resp = await client.post( f"/projects/{pid}/chapters/1/accept", json={"final_text": "终稿", "decisions": []}, ) assert resp.status_code == 200 assert resp.json()["accepted_version"] == 1 assert session.commits == 1 assert len(digest_repo.rows) == 1 # ---- T2.4 冲突 gate 拦截 ---- @pytest.mark.asyncio async def test_accept_blocks_when_conflict_unresolved() -> None: review_repo = FakeReviewRepo() pid = uuid.uuid4() await _seed_review( review_repo, pid, [ {"type": "性格漂移", "where": "a", "refs": [], "suggestion": "b"}, {"type": "设定违例", "where": "c", "refs": [], "suggestion": "d"}, ], ) digest_gw = FakeReviewGateway(parsed=ContinuityReview()) client, chapter_repo, _, digest_repo, session = _make_client( review_repo=review_repo, digest_gateway=digest_gw ) async with client: resp = await client.post( f"/projects/{pid}/chapters/1/accept", json={ "final_text": "终稿", # 只裁决了 0 号冲突,1 号缺判 → 拦截。 "decisions": [{"conflict_index": 0, "verdict": "ignore"}], }, ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == ErrorCode.CONFLICT_UNRESOLVED assert resp.json()["error"]["details"]["missing_conflict_indices"] == [1] # 不写库:未晋升、未提炼 digest、未提交、未调 digest 网关。 assert await chapter_repo.latest_accepted(pid, 1) is None assert len(digest_repo.rows) == 0 assert session.commits == 0 assert len(digest_gw.requests) == 0 # ---- T2.4 事务回滚 ---- @pytest.mark.asyncio async def test_accept_rolls_back_when_a_step_fails() -> None: """裁决留痕步骤抛错 → 整体回滚(不 commit)。""" review_repo = FakeReviewRepo(fail_set_decisions=True) pid = uuid.uuid4() await _seed_review(review_repo, pid, []) from ww_api.services.digest_extraction import ChapterDigestFacts digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s")) client, _, _, _, session = _make_client(review_repo=review_repo, digest_gateway=digest_gw) # set_decisions 在事务中途抛错;ASGITransport 默认上抛未处理异常。 with pytest.raises(RuntimeError, match="rollback test"): async with client: await client.post( f"/projects/{pid}/chapters/1/accept", json={"final_text": "终稿", "decisions": []}, ) # 关键:事务未 commit(半态不落库,整体回滚)。 assert session.commits == 0