Files
writer-work-flow/apps/api/tests/test_review_accept.py
Yaojia Wang 345cc73965 fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化
P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit
  (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交);
  补多凭据一请求中途失败整体回滚集成测试。
P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope;
  credential_enc_key 改 SecretStr。
P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。
P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。
P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。
P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。
P1-10 移除 OAuth user_code 日志。
P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol;
  CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验;
  httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/
  ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
2026-06-21 19:32:24 +02:00

291 lines
11 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.

"""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_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