"""C2 多章链端点 + 服务层单测(chain-workflow §6/§10)——内存替身,无 DB/无网络/无真 LLM。 覆盖: - 端点契约(HTTP 层,`run_chain_job` 被 no-op 替换以隔离调度逻辑): run → 202 `{job_id,...}`;resume → 202;未知 chain_key → 404;count 越界(>50 / 0)→ 422; resume 非 awaiting 态 → 409;项目不存在 → 404;resume job 不存在 → 404。 - 服务层 `run_chain_job`(mock 网关 + MemorySaver + fake session_factory + fake accept_op): 无冲突全跑 → job done + result.written 满;有冲突 → interrupt → job awaiting_input; resume 续跑 → done;token 绝不入 job result。 - `build_accept_op`:冲突缺判 → CONFLICT_UNRESOLVED;无冲突章正常落库(经 fake op 桩验证)。 """ from __future__ import annotations import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any import httpx import pytest from fakes_projects import FakeJobRepo, FakeProjectRepo from fastapi import FastAPI from langgraph.checkpoint.memory import MemorySaver from pydantic import BaseModel from ww_agents import Conflict, ContinuityReview from ww_api.schemas.projects import ConflictDecision from ww_api.services.chain_runner import build_accept_op, run_chain_job from ww_core.domain.job_repo import STATUS_AWAITING, STATUS_DONE from ww_core.domain.project_repo import ProjectCreate from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage PROJECT_OWNER = uuid.UUID(int=1) USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa") # ---- 共享替身 ---- def _usage() -> Usage: return Usage( provider="fake", model="fake", input_tokens=1, output_tokens=1, cost_minor=0, currency="USD" ) class _FakeChainGateway: """链网关 mock:write(无 output_schema)回固定正文;审稿按 schema 回 parsed。""" def __init__(self, *, conflicts: list[Conflict]) -> None: self._continuity = ContinuityReview(conflicts=conflicts) from ww_agents import foreshadow_spec, pace_spec, style_drift_spec self._by_schema: dict[type, BaseModel] = {} for spec in (foreshadow_spec, pace_spec, style_drift_spec): schema = spec.output_schema assert schema is not None self._by_schema[schema] = schema() async def run(self, req: LlmRequest) -> LlmResponse: schema = req.output_schema if schema is None: return LlmResponse( text="第 N 章正文。", parsed=None, usage=_usage(), served_by=ServedBy(provider="fake", model="fake"), ) parsed = self._continuity if schema is ContinuityReview else self._by_schema[schema] return LlmResponse( text=parsed.model_dump_json(), parsed=parsed, usage=_usage(), served_by=ServedBy(provider="fake", model="fake"), ) class _FakeDraftView: def __init__(self, content: str) -> None: self.content = content class _FakeChapterRepo: def __init__(self, store: dict[int, str]) -> None: self._store = store async def save_draft( self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 ) -> Any: self._store[chapter_no] = text return _FakeDraftView(text) async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> Any: content = self._store.get(chapter_no) return _FakeDraftView(content) if content is not None else None class _FakeReviewRecord: def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None: self.id = uuid.uuid4() self.chapter_no = chapter_no self.conflicts = conflicts class _FakeReviewRepo: def __init__(self, records: dict[int, list[_FakeReviewRecord]]) -> None: self._records = records async def record( self, project_id: uuid.UUID, chapter_no: int, *, chapter_version: int | None = None, conflicts: list[dict[str, Any]], foreshadow_sug: list[dict[str, Any]] | None = None, style: dict[str, Any] | None = None, pace: dict[str, Any] | None = None, health_score: int | None = None, ) -> Any: rec = _FakeReviewRecord(chapter_no, list(conflicts)) self._records.setdefault(chapter_no, []).insert(0, rec) return rec async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]: return list(self._records.get(chapter_no, [])) class _FakeSession: def __init__(self) -> None: self.commits = 0 async def commit(self) -> None: self.commits += 1 @asynccontextmanager async def _session_cm() -> AsyncIterator[Any]: yield _FakeSession() def _session_factory() -> Any: """fake 独立 session 工厂(满足 `SessionFactory` 形:`()` → async-CM)。 返回 Any 避开 AsyncSession 不变型校验(同 test_job_runner 的 `__call__ -> Any` 纪律)。 """ return _session_cm() @asynccontextmanager async def _memsaver_ctx(saver: MemorySaver) -> AsyncIterator[Any]: yield saver def _async_returning(gateway: Any) -> Any: """构造「按 session 建网关」的 builder 替身:忽略 session,恒返给定 mock 网关(绝不联网)。 链节点经 `gateway_builder(session)` 现建网关(不变量 #1);测试里 ledger 不落库(fake session),故 builder 只需把 mock 网关交回即可。 """ async def _build(_session: Any) -> Any: return gateway return _build class _FakeContext: stable_core = "## 世界观硬规则\n灵气可凝丹" volatile = "## 写作指令\n写第 N 章" # ---- 端点契约(HTTP 层,run_chain_job no-op)---- def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI: import os from cryptography.fernet import Fernet os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from ww_api.main import create_app from ww_api.services.chain_deps import get_checkpointer_factory from ww_api.services.project_deps import ( get_chain_gateway, get_chain_gateway_builder, get_digest_gateway_builder, get_job_repo, get_project_repo, get_session_factory, ) from ww_db import get_session app = create_app() app.dependency_overrides[get_project_repo] = lambda: project_repo app.dependency_overrides[get_job_repo] = lambda: job_repo app.dependency_overrides[get_session] = lambda: _FakeSession() app.dependency_overrides[get_session_factory] = lambda: _session_factory app.dependency_overrides[get_chain_gateway] = lambda: _FakeChainGateway(conflicts=[]) app.dependency_overrides[get_chain_gateway_builder] = lambda: _async_returning( _FakeChainGateway(conflicts=[]) ) saver = MemorySaver() app.dependency_overrides[get_checkpointer_factory] = lambda: lambda: _memsaver_ctx(saver) app.dependency_overrides[get_digest_gateway_builder] = lambda: lambda _s: None return app def _client(app: FastAPI) -> httpx.AsyncClient: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) return httpx.AsyncClient(transport=transport, base_url="http://test") async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID: view = await repo.create(PROJECT_OWNER, ProjectCreate(title="测试作品", genre="玄幻")) return uuid.UUID(str(view.id)) @pytest.fixture def _noop_run_chain_job(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: """把端点登记的 `run_chain_job` 换成记录调用的 no-op(隔离调度逻辑,不真跑图)。""" calls: list[dict[str, Any]] = [] async def _fake(*args: Any, **kwargs: Any) -> None: calls.append({"args": args, "kwargs": kwargs}) monkeypatch.setattr("ww_api.routers.chain.run_chain_job", _fake) return calls async def test_run_chain_returns_202_and_schedules_job( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/draft_volume/run", json={"start_chapter_no": 1, "count": 3}, ) assert resp.status_code == 202 body = resp.json() assert body["chain_key"] == "draft_volume" assert body["start_chapter_no"] == 1 assert body["count"] == 3 assert uuid.UUID(body["job_id"]) # job 行已落(queued)+ 调度了一次后台任务 assert len(job_repo.rows) == 1 assert len(_noop_run_chain_job) == 1 async def test_run_chain_unknown_key_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/unknown_chain/run", json={"start_chapter_no": 1, "count": 2}, ) assert resp.status_code == 404 assert resp.json()["error"]["code"] == "NOT_FOUND" assert len(_noop_run_chain_job) == 0 # 未调度 async def test_run_chain_count_over_50_returns_422( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/draft_volume/run", json={"start_chapter_no": 1, "count": 51}, ) assert resp.status_code == 422 async def test_run_chain_count_zero_returns_422( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/draft_volume/run", json={"start_chapter_no": 1, "count": 0}, ) assert resp.status_code == 422 async def test_run_chain_project_not_found_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{uuid.uuid4()}/chains/draft_volume/run", json={"start_chapter_no": 1, "count": 2}, ) assert resp.status_code == 404 async def test_resume_chain_awaiting_returns_202( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) # 预置一个 awaiting_input 的链 job job = await job_repo.create(pid, "chain") await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/runs/{job.id}/resume", json={"decisions": [{"conflict_index": 0, "verdict": "ignore"}]}, ) assert resp.status_code == 202 assert resp.json()["job_id"] == str(job.id) assert len(_noop_run_chain_job) == 1 async def test_resume_chain_non_awaiting_returns_409( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) job = await job_repo.create(pid, "chain") # queued(非 awaiting) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/runs/{job.id}/resume", json={"decisions": []}, ) assert resp.status_code == 409 assert resp.json()["error"]["code"] == "CONFLICT" assert len(_noop_run_chain_job) == 0 async def test_resume_chain_job_not_found_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/runs/{uuid.uuid4()}/resume", json={"decisions": []}, ) assert resp.status_code == 404 async def test_resume_chain_wrong_project_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: """所有权校验(审评 #4):job 属项目 A,从项目 B 路径 resume → 404(同案,不枚举)。""" project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid_a = await _seed_project(project_repo) pid_b = uuid.UUID( str((await project_repo.create(PROJECT_OWNER, ProjectCreate(title="作品B"))).id) ) job = await job_repo.create(pid_a, "chain") # job 属 A await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid_b}/chains/runs/{job.id}/resume", # 从 B 续 A 的 job json={"decisions": []}, ) assert resp.status_code == 404 assert len(_noop_run_chain_job) == 0 # 未调度 async def test_resume_chain_concurrent_claim_returns_409( _noop_run_chain_job: list[dict[str, Any]], ) -> None: """原子抢占(审评 #3):第一个 resume 已把 awaiting→running,第二个抢不到 → 409。""" project_repo = FakeProjectRepo() job_repo = FakeJobRepo() pid = await _seed_project(project_repo) job = await job_repo.create(pid, "chain") await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1}) # 模拟并发:第一个 resume 已抢占 awaiting→running。 claimed = await job_repo.claim_awaiting_to_running(job.id) assert claimed is not None app = _app(project_repo, job_repo) async with _client(app) as client: resp = await client.post( f"/projects/{pid}/chains/runs/{job.id}/resume", json={"decisions": []}, ) # job 现态 running(非 awaiting)→ 守卫先在 status 检查处 409(CONFLICT)。 assert resp.status_code == 409 assert resp.json()["error"]["code"] == "CONFLICT" assert len(_noop_run_chain_job) == 0 # ---- 服务层 run_chain_job(直接 await,注入全 fake;job 状态由 fake SqlJobRepo 替身记录)---- class _RecordingJobRepo: """run_chain_job 内部 `SqlJobRepo(session)` 的替身(monkeypatch):记录状态流转。""" state: dict[uuid.UUID, dict[str, Any]] = {} def __init__(self, session: Any) -> None: pass async def set_running(self, job_id: uuid.UUID) -> Any: _RecordingJobRepo.state.setdefault(job_id, {})["status"] = "running" return None async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any: _RecordingJobRepo.state[job_id] = {"status": STATUS_DONE, "result": result} return None async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> Any: _RecordingJobRepo.state[job_id] = {"status": STATUS_AWAITING, "result": result} return None async def fail(self, job_id: uuid.UUID, error: str) -> Any: _RecordingJobRepo.state[job_id] = {"status": "failed", "error": error} return None def _chain_runner_fakes() -> dict[str, Any]: draft_store: dict[int, str] = {} review_store: dict[int, list[_FakeReviewRecord]] = {} accepted: list[dict[str, Any]] = [] async def accept_op( *, project_id: uuid.UUID, chapter_no: int, user_id: uuid.UUID, decisions: list[Any] ) -> None: accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)}) return { "draft_store": draft_store, "review_store": review_store, "accepted": accepted, "accept_op": accept_op, "chapter_repo_factory": lambda _s: _FakeChapterRepo(draft_store), "review_repo_factory": lambda _s: _FakeReviewRepo(review_store), } @pytest.fixture def _patched_runner(monkeypatch: pytest.MonkeyPatch) -> None: """把 run_chain_job 里的 SqlJobRepo + assemble + sql_memory_repos 换成 fake(不触 DB)。""" _RecordingJobRepo.state = {} monkeypatch.setattr("ww_api.services.chain_runner.SqlJobRepo", _RecordingJobRepo) async def _fake_assemble( repos: Any, project_id: uuid.UUID, chapter_no: int, *a: Any, **k: Any ) -> _FakeContext: return _FakeContext() monkeypatch.setattr("ww_api.services.chain_runner.assemble", _fake_assemble) monkeypatch.setattr("ww_api.services.chain_runner.sql_memory_repos", lambda _s: object()) async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -> None: fakes = _chain_runner_fakes() gateway = _FakeChainGateway(conflicts=[]) job_id = uuid.uuid4() saver = MemorySaver() await run_chain_job( _session_factory, job_id, project_id=uuid.uuid4(), user_id=USER, chain_key="draft_volume", start_chapter_no=1, count=2, chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], review_repo_factory=fakes["review_repo_factory"], ) rec = _RecordingJobRepo.state[job_id] assert rec["status"] == STATUS_DONE assert rec["result"]["written"] == [1, 2] assert rec["result"]["completed"] is True assert rec["result"]["awaiting_chapter"] is None # token 不入 result:只含章号/标志,无正文/prompt assert "第 N 章正文" not in str(rec["result"]) assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2] async def test_run_chain_job_conflict_sets_awaiting_then_resume_done( _patched_runner: None, ) -> None: fakes = _chain_runner_fakes() conflict = Conflict(type="性格漂移", where="第3段", suggestion="统一主角姓名") gateway = _FakeChainGateway(conflicts=[conflict]) job_id = uuid.uuid4() saver = MemorySaver() # 同 saver 跨 run/resume 两次调用 project_id = uuid.uuid4() # 初始 run:第 1 章四审报冲突 → interrupt → job awaiting_input。 await run_chain_job( _session_factory, job_id, project_id=project_id, user_id=USER, chain_key="draft_volume", start_chapter_no=1, count=1, chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], review_repo_factory=fakes["review_repo_factory"], ) rec = _RecordingJobRepo.state[job_id] assert rec["status"] == STATUS_AWAITING assert rec["result"]["awaiting_chapter"] == 1 assert fakes["accepted"] == [] # 尚未验收 # resume:作者裁决 → 续跑 accept → done。 decisions = [ConflictDecision(conflict_index=0, verdict="ignore", note="笔误")] await run_chain_job( _session_factory, job_id, project_id=project_id, user_id=USER, chain_key="draft_volume", start_chapter_no=1, count=1, chain_gateway_builder=_async_returning(gateway), checkpointer_ctx=lambda: _memsaver_ctx(saver), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], review_repo_factory=fakes["review_repo_factory"], resume_decisions=decisions, ) rec = _RecordingJobRepo.state[job_id] assert rec["status"] == STATUS_DONE assert rec["result"]["written"] == [1] assert [a["chapter_no"] for a in fakes["accepted"]] == [1] assert fakes["accepted"][0]["decisions"] == [ {"conflict_index": 0, "verdict": "ignore", "note": "笔误"} ] async def test_run_chain_job_error_marks_failed_safely(_patched_runner: None) -> None: """work 抛 → job failed + 脱敏文案(不泄 str(exc)),异常不冒泡。""" fakes = _chain_runner_fakes() job_id = uuid.uuid4() class _BoomGateway: async def run(self, req: LlmRequest) -> LlmResponse: raise RuntimeError("boom secret=/internal/path") await run_chain_job( _session_factory, job_id, project_id=uuid.uuid4(), user_id=USER, chain_key="draft_volume", start_chapter_no=1, count=1, chain_gateway_builder=_async_returning(_BoomGateway()), checkpointer_ctx=lambda: _memsaver_ctx(MemorySaver()), accept_op=fakes["accept_op"], chapter_repo_factory=fakes["chapter_repo_factory"], review_repo_factory=fakes["review_repo_factory"], ) rec = _RecordingJobRepo.state[job_id] assert rec["status"] == "failed" assert rec["error"] == "任务执行失败" assert "secret" not in rec["error"] # ---- build_accept_op:冲突缺判 gate ---- async def test_build_accept_op_missing_decision_raises_conflict_unresolved( monkeypatch: pytest.MonkeyPatch, ) -> None: from ww_shared import AppError, ErrorCode review_store: dict[int, list[_FakeReviewRecord]] = { 1: [_FakeReviewRecord(1, [{"type": "矛盾", "where": "第1段"}])] } class _Session: async def commit(self) -> None: ... @asynccontextmanager async def _sf_cm() -> AsyncIterator[Any]: yield _Session() def _sf() -> Any: return _sf_cm() # 桩出 accept_op 依赖:链 accept 节点重读审稿、跑 gate(缺判应在 gate 拦下,不触 digest/事务)。 monkeypatch.setattr( "ww_api.services.chain_runner.SqlChapterRepo", lambda _s: _FakeChapterRepo({1: "正文"}) ) monkeypatch.setattr( "ww_api.services.chain_runner.SqlReviewRepo", lambda _s: _FakeReviewRepo(review_store) ) monkeypatch.setattr("ww_api.services.chain_runner.SqlDigestAppendRepo", lambda _s: object()) async def _digest_builder(_s: Any) -> Any: return object() accept_op = build_accept_op(session_factory=_sf, digest_gateway_builder=_digest_builder) with pytest.raises(AppError) as exc: await accept_op(project_id=uuid.uuid4(), chapter_no=1, user_id=USER, decisions=[]) assert exc.value.code == ErrorCode.CONFLICT_UNRESOLVED