"""T3.2 伏笔登记/状态变更端点 + 验收后到期扫描(内存替身,无 DB/无网络)。 覆盖: - 登记 201 + 重复 code → 422 VALIDATION 信封; - 状态机转移 200 / 非法转移 → 422 VALIDATION / 不存在 → 404; - 进展 append;空更新 → 422; - 到期扫描纯函数 `run_overdue_scan` 直接 await:埋 expected_close_to < 验收章号的伏笔 → 置 OVERDUE、自建 session 提交(**绝不真起后台线程**)。 """ from __future__ import annotations import uuid import httpx import pytest from fakes_projects import ( FakeForeshadowRepo, FakeSession, FakeSessionFactory, ) from ww_api.services.foreshadow_scan import run_overdue_scan from ww_core.domain.foreshadow_state import ForeshadowStatus from ww_shared import ErrorCode def _make_client( *, repo: FakeForeshadowRepo | None = None, session: FakeSession | None = None ) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]: 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_foreshadow_repo from ww_db import get_session repo = repo or FakeForeshadowRepo() session = session or FakeSession() app = create_app() app.dependency_overrides[get_foreshadow_repo] = lambda: repo app.dependency_overrides[get_session] = lambda: session transport = httpx.ASGITransport(app=app) client = httpx.AsyncClient(transport=transport, base_url="http://test") return client, repo, session # ---- 登记 ---- @pytest.mark.asyncio async def test_register_foreshadow_returns_201_open() -> None: client, repo, session = _make_client() pid = uuid.uuid4() async with client: resp = await client.post( f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "神秘信物", "planted_at": 3, "expected_close_to": 10}, ) assert resp.status_code == 201 body = resp.json() assert body["code"] == "F1" assert body["status"] == ForeshadowStatus.OPEN.value assert body["expected_close_to"] == 10 assert session.commits == 1 assert (pid, "F1") in repo.rows @pytest.mark.asyncio async def test_register_duplicate_code_maps_to_422_validation() -> None: repo = FakeForeshadowRepo(raise_integrity_on={"F1"}) client, _, session = _make_client(repo=repo) pid = uuid.uuid4() async with client: resp = await client.post( f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "重复"} ) assert resp.status_code == 422 err = resp.json()["error"] assert err["code"] == ErrorCode.VALIDATION assert err["details"]["reason"] == "duplicate" # 冲突后回滚、不提交。 assert session.commits == 0 assert session.rollbacks == 1 # ---- 状态变更 ---- @pytest.mark.asyncio async def test_transition_open_to_partial_200() -> None: repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="t") client, _, session = _make_client(repo=repo) async with client: resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"}) assert resp.status_code == 200 assert resp.json()["status"] == "PARTIAL" assert session.commits == 1 @pytest.mark.asyncio async def test_invalid_transition_maps_to_422_validation() -> None: repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="t") await repo.transition(pid, "F1", to_status="CLOSED") # 先到终态 client, _, session = _make_client(repo=repo) async with client: resp = await client.patch( f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"}, # CLOSED→PARTIAL 非法 ) assert resp.status_code == 422 err = resp.json()["error"] assert err["code"] == ErrorCode.VALIDATION assert err["details"]["reason"] == "invalid_transition" assert session.rollbacks == 1 @pytest.mark.asyncio async def test_transition_unknown_code_404() -> None: client, _, _ = _make_client() pid = uuid.uuid4() async with client: resp = await client.patch(f"/projects/{pid}/foreshadow/NOPE", json={"to_status": "PARTIAL"}) assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_record_progress_appends() -> None: repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="t") client, _, session = _make_client(repo=repo) async with client: resp = await client.patch( f"/projects/{pid}/foreshadow/F1", json={"progress_entry": {"chapter_no": 5, "note": "首次照应"}}, ) assert resp.status_code == 200 assert resp.json()["progress"][0]["note"] == "首次照应" assert session.commits == 1 @pytest.mark.asyncio async def test_empty_update_maps_to_422() -> None: repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="t") client, _, _ = _make_client(repo=repo) async with client: resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={}) assert resp.status_code == 422 assert resp.json()["error"]["details"]["reason"] == "empty_update" # ---- 验收后到期扫描(纯函数直接 await,不起后台线程)---- @pytest.mark.asyncio async def test_run_overdue_scan_marks_overdue_and_commits() -> None: # Arrange:一条 expected_close_to=5 的伏笔,验收章号 7 > 5 → 应置 OVERDUE。 repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="逾期", expected_close_to=5) await repo.register(pid, code="F2", title="窗口内", expected_close_to=9) factory = FakeSessionFactory() # Act:经 repo_factory 缝注入 fake 账本 repo(不联网/不真起后台线程)。 count = await run_overdue_scan( factory, project_id=pid, chapter_no=7, request_id="req-1", repo_factory=lambda _session: repo, ) # Assert assert count == 1 assert repo.rows[(pid, "F1")].status == ForeshadowStatus.OVERDUE.value assert repo.rows[(pid, "F2")].status == ForeshadowStatus.OPEN.value # 自建 session 且有变更 → 提交一次。 assert len(factory.sessions) == 1 assert factory.sessions[0].commits == 1 @pytest.mark.asyncio async def test_run_overdue_scan_no_change_skips_commit() -> None: repo = FakeForeshadowRepo() pid = uuid.uuid4() await repo.register(pid, code="F1", title="窗口内", expected_close_to=9) factory = FakeSessionFactory() count = await run_overdue_scan( factory, project_id=pid, chapter_no=3, repo_factory=lambda _session: repo ) assert count == 0 # 空扫描不提交。 assert factory.sessions[0].commits == 0