"""报告域端到端(T-P3-07/09):触发→配额→流水线→失败退款→重生成。 Fake 阶段定义在本文件里,通过 ``REPORT_PIPELINE_STAGES_JSON`` 让 assemble_stages 按 dotted path 动态装配——与 T16/T17 的真实阶段走完全相同 的装配路径。不启真 celery:worker 内芯函数直调;API 投递钩子替换为 no-op, 避免测试消息进真 Redis 队列被开发环境的 worker 消费掉。 """ from __future__ import annotations import json import os import uuid from collections.abc import AsyncIterator, Iterator from contextlib import contextmanager from typing import Any import pytest def _infra_available() -> bool: return bool(os.getenv("MEIDA_RUN_INTEGRATION")) pytestmark = [ pytest.mark.integration, pytest.mark.skipif( not _infra_available(), reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running", ), ] # Fake 阶段走 importlib 动态装配,dotted path 必须可导入(本文件自身)。 _MODULE = "tests.integration.test_report_flow" _SUCCESS_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeDiagnosisStage"]) _FAILING_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeBoomStage"]) class FakeVisionStage: """视觉阶段替身:计算次数写进 derived_features,用例据此断言「已存在即跳过」。""" name = "vision" async def run(self, ctx: Any) -> None: features = ctx.report_input.derived_features if not features: # 与 T16 真实视觉阶段同一契约(T-P3-09):中间态非空则不重算。 features["vision_runs"] = 1 ctx.report.result["vision"] = {"features": sorted(features)} class FakeDiagnosisStage: name = "diagnosis" async def run(self, ctx: Any) -> None: # 顺序契约:诊断能读到视觉阶段的产物。 assert "vision" in ctx.report.result ctx.report.result["diagnosis"] = {"summary": "肩宽腰细", "source": "vision"} class FakeBoomStage: name = "boom" async def run(self, ctx: Any) -> None: raise RuntimeError("爆了:模拟阶段失败") # --- fixtures --------------------------------------------------------------- @pytest.fixture def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None: """不向真 Redis 投递:worker 由用例直调(celery 注册/消费不在本用例范围)。""" monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None) @contextmanager def _stage_config(monkeypatch: pytest.MonkeyPatch, payload: str) -> Iterator[None]: """把阶段配置换成 Fake 阶段(setenv + cache_clear,结束时恢复)。 API 进程与 worker 内芯读的是同一个 get_settings 单例,cache_clear 之后 两边都会按 env 重建。 """ from packages.shared.config import get_settings monkeypatch.setenv("REPORT_PIPELINE_STAGES_JSON", payload) get_settings.cache_clear() try: yield finally: monkeypatch.undo() get_settings.cache_clear() @pytest.fixture def fake_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: with _stage_config(monkeypatch, _SUCCESS_STAGES): yield @pytest.fixture def failing_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: with _stage_config(monkeypatch, _FAILING_STAGES): yield @pytest.fixture async def client(no_enqueue: None) -> AsyncIterator[Any]: import httpx from apps.api.main import create_app from packages.shared.config import get_settings app = create_app(get_settings()) transport = httpx.ASGITransport(app=app) async with ( httpx.AsyncClient(transport=transport, base_url="http://test") as http, app.router.lifespan_context(app), ): yield http # --- helpers ---------------------------------------------------------------- async def _login(client: Any) -> tuple[dict[str, str], int]: """dev-token:每个用例独立 device_key,拿到的就是全新用户。""" resp = await client.post( "/api/v1/auth/dev-token", json={"device_key": f"report-it-{uuid.uuid4().hex[:8]}"} ) assert resp.json()["code"] == 0 data = resp.json()["data"] return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"]) async def _create_report( client: Any, headers: dict[str, str], *, request_key: str | None = None ) -> dict[str, Any]: resp = await client.post( "/api/v1/reports", headers=headers, json={ "questionnaire": {"style": "通勤", "height_cm": 168}, "photo_object_key": "report_photo/u1.jpg", "request_key": request_key or f"rk-{uuid.uuid4().hex}", }, ) assert resp.status_code == 200 body = resp.json()["data"] assert body["status"] == "pending" return body async def _fetch_one(statement: Any) -> Any: from packages.shared.db import session_scope async with session_scope() as session: result = await session.execute(statement) return result.scalar_one_or_none() async def _fetch_report(report_id: int) -> Any: from sqlalchemy import select from packages.shared.models import RpReport return await _fetch_one(select(RpReport).where(RpReport.report_id == report_id)) async def _fetch_input(report_id: int) -> Any: from sqlalchemy import select from packages.shared.models import RpInput return await _fetch_one(select(RpInput).where(RpInput.report_id == report_id)) async def _fetch_task(task_id: int) -> Any: from packages.shared.db import session_scope from packages.task import repo as task_repo async with session_scope() as session: task = await task_repo.get(session, task_id) assert task is not None # 脱离会话前把要断言的 JSONB 读出来(expire_on_commit=False,够用)。 return task async def _ledger_rows(user_id: int, quota_key: str) -> list[Any]: from sqlalchemy import select from packages.shared.db import session_scope from packages.shared.models import Ledger async with session_scope() as session: result = await session.execute( select(Ledger) .where(Ledger.user_id == user_id, Ledger.quota_key == quota_key) .order_by(Ledger.created_at.asc(), Ledger.tx_id.asc()) ) return list(result.scalars().all()) # --- 场景 1:触发与预扣 ------------------------------------------------------- async def test_create_reserves_lifetime_quota_and_creates_pending_task( fake_stages: None, client: Any ) -> None: headers, user_id = await _login(client) data = await _create_report(client, headers) task_id, report_id = int(data["task_id"]), int(data["report_id"]) assert data["version"] == 1 task = await _fetch_task(task_id) assert task.status == "pending" assert task.task_type == "report" assert task.payload["report_id"] == str(report_id) assert task.quota_reserved == 1 assert task.reserve_tx_id is not None report = await _fetch_report(report_id) assert report.status == "pending" rp_input = await _fetch_input(report_id) assert rp_input.photo_meta == {"object_key": "report_photo/u1.jpg"} assert rp_input.derived_features == {} rows = await _ledger_rows(user_id, "report_basic") assert len(rows) == 1 assert rows[0].tx_type == "consume" assert rows[0].status == "reserved" assert rows[0].period_key == "life" # report_basic 是终身配额(迁移 0003) # --- 场景 2:worker 成功 ------------------------------------------------------ async def test_worker_success_commits_quota_and_writes_stage_keys( fake_stages: None, client: Any ) -> None: from apps.worker.report_tasks import _generate_report headers, user_id = await _login(client) data = await _create_report(client, headers) task_id, report_id = int(data["task_id"]), int(data["report_id"]) result = await _generate_report(task_id) assert result["status"] == "succeeded" report = await _fetch_report(report_id) assert report.status == "succeeded" assert report.completed_at is not None assert set(report.result) == {"vision", "diagnosis"} assert report.result["vision"] == {"features": ["vision_runs"]} assert "stages" in report.model_info task = await _fetch_task(task_id) assert task.status == "succeeded" assert task.progress == 100 rows = await _ledger_rows(user_id, "report_basic") assert [row.status for row in rows] == ["committed"] # 列表与详情视图透出同样的产物。 listing = (await client.get("/api/v1/reports", headers=headers)).json()["data"] assert [item["report_id"] for item in listing] == [str(report_id)] assert listing[0]["result"]["diagnosis"]["summary"] == "肩宽腰细" # --- 场景 3:幂等重提交 ------------------------------------------------------- async def test_duplicate_request_key_returns_same_task_without_second_reserve( fake_stages: None, client: Any ) -> None: from apps.worker.report_tasks import _generate_report headers, user_id = await _login(client) request_key = f"rk-{uuid.uuid4().hex}" first = await _create_report(client, headers, request_key=request_key) await _generate_report(int(first["task_id"])) # 先让首单成功 again = await client.post( "/api/v1/reports", headers=headers, json={ "questionnaire": {"style": "通勤"}, "photo_object_key": "report_photo/u1.jpg", "request_key": request_key, }, ) assert again.status_code == 200 replay = again.json()["data"] assert replay["task_id"] == first["task_id"] assert replay["report_id"] == first["report_id"] assert replay["status"] == "succeeded" # tk_task 幂等键 + reserve 幂等键双保险:只有一笔 reserve。 rows = await _ledger_rows(user_id, "report_basic") assert len(rows) == 1 assert rows[0].status == "committed" assert await _count_user_reports(user_id) == 1 # 重放不建新报告行 async def _count_user_reports(user_id: int) -> int: from sqlalchemy import func, select from packages.shared.db import session_scope from packages.shared.models import RpReport async with session_scope() as session: result = await session.execute( select(func.count()).select_from(RpReport).where(RpReport.user_id == user_id) ) return int(result.scalar_one()) # --- 场景 4:首次资格 40920 --------------------------------------------------- async def test_second_first_report_is_rejected_with_40920(fake_stages: None, client: Any) -> None: from apps.worker.report_tasks import _generate_report headers, _ = await _login(client) data = await _create_report(client, headers) await _generate_report(int(data["task_id"])) # 首单成功,终身资格已用掉 resp = await client.post( "/api/v1/reports", headers=headers, json={ "questionnaire": {}, "photo_object_key": "report_photo/u2.jpg", "request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放 }, ) assert resp.status_code == 409 body = resp.json() assert body["code"] == 40920 assert "30" in (body.get("hint") or "") # --- 场景 5:失败退款 + derived_features 保留 ---------------------------------- async def test_stage_failure_refunds_and_preserves_derived_features( failing_stages: None, client: Any ) -> None: from apps.worker.report_tasks import _generate_report from packages.report.pipeline import StageFailureError headers, user_id = await _login(client) data = await _create_report(client, headers) task_id, report_id = int(data["task_id"]), int(data["report_id"]) with pytest.raises(StageFailureError): await _generate_report(task_id) report = await _fetch_report(report_id) assert report.status == "failed" # 已完成阶段的产物仍在 + 错误摘要写进 result。 assert report.result["vision"] == {"features": ["vision_runs"]} assert report.result["error"]["stage"] == "boom" # T-P3-09 核心:视觉中间态保留——重试/重生成不需要重新上传照片。 rp_input = await _fetch_input(report_id) assert rp_input.derived_features == {"vision_runs": 1} task = await _fetch_task(task_id) assert task.status == "failed" # 退款:consume 流水转 refunded,反向 refund 流水 committed。 rows = await _ledger_rows(user_id, "report_basic") assert [(row.tx_type, row.status) for row in rows] == [ ("consume", "refunded"), ("refund", "committed"), ] # failed 报告的详情只透错误摘要,不透半成品。 detail = (await client.get(f"/api/v1/reports/{report_id}", headers=headers)).json()["data"] assert set(detail["result"]) == {"error"} assert detail["result"]["error"]["stage"] == "boom" # --- 场景 6:regenerate 走月配额并复用视觉中间态 ------------------------------- async def test_regenerate_uses_month_quota_and_reuses_derived_features( fake_stages: None, client: Any ) -> None: from apps.worker.report_tasks import _generate_report headers, user_id = await _login(client) first = await _create_report(client, headers) await _generate_report(int(first["task_id"])) regen = await client.post( f"/api/v1/reports/{first['report_id']}/regenerate", headers=headers, json={"request_key": f"rk-regen-{uuid.uuid4().hex}"}, ) assert regen.status_code == 200 data = regen.json()["data"] assert data["version"] == 2 new_report_id = int(data["report_id"]) assert new_report_id != int(first["report_id"]) # 重新生成走 report_regen 月配额(m:YYYY-MM),不复用终身额度。 rows = await _ledger_rows(user_id, "report_regen") assert len(rows) == 1 assert rows[0].status == "reserved" assert rows[0].period_key.startswith("m:") # 新输入行直接带着上一版的视觉中间态(无需重新上传照片)。 new_input = await _fetch_input(new_report_id) assert new_input.derived_features == {"vision_runs": 1} await _generate_report(int(data["task_id"])) report_v2 = await _fetch_report(new_report_id) assert report_v2.status == "succeeded" assert report_v2.version == 2 # 成功后视觉仍未重算:vision_runs 计数保持 1(Fake 阶段非空即跳过)。 input_after = await _fetch_input(new_report_id) assert input_after.derived_features == {"vision_runs": 1}