Files
meida_backend/tests/integration/test_report_flow.py
T

462 lines
16 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.
"""报告域端到端(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 _upload_photo(client: Any, headers: dict[str, str]) -> str:
"""直传一张假 JPEG 拿真实 object_key(建报告对 photo_object_key 做存在性校验)。
字节内容不需要是真图片:上传链路只校验类型/大小,不嗅探内容。
"""
resp = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={**headers, "Content-Type": "image/jpeg"},
content=b"\xff\xd8\xff\xe0" + b"\x00" * 64,
)
assert resp.status_code == 200
return str(resp.json()["data"]["object_key"])
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": await _upload_photo(client, headers),
"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)
# photo_object_key 已由直传接口真实落盘(布局 report_photo/{user_id}/{uuid}.jpg)
assert str(rp_input.photo_meta["object_key"]).startswith(f"report_photo/{user_id}/")
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,由 report_basic 终身配额兜底 ---------------------
async def test_second_report_create_is_blocked_by_lifetime_quota(
fake_stages: None, client: Any
) -> None:
"""产品决策变更:已生成过报告仍可再次提交,不再抛 40920(终身资格硬校验已删)。
重复生成由 report_basic 终身配额兜底(迁移 0003 SSOT:lifetime base_limit=1)。
本地开发库的规则行被人为放宽过(base_limit=10),这里把本用例用户的
bl_quota 快照 limit_value 收敛回 SSOT 值 1,确定性断言额度耗尽:
新 request_key 的第二次创建应返回 403 / QUOTA_EXHAUSTED 40301,
而不是旧的 40920 REPORT_ALREADY_EXISTS。
"""
from sqlalchemy import update
from apps.worker.report_tasks import _generate_report
from packages.shared.db import session_scope
from packages.shared.models import Quota
headers, user_id = await _login(client)
data = await _create_report(client, headers)
await _generate_report(int(data["task_id"])) # 首单成功,终身额度已用 1 次
# 收敛该用户的终身额度快照到 SSOT base_limit=1(限本用例用户,不动全局规则行)。
async with session_scope() as session:
await session.execute(
update(Quota)
.where(Quota.user_id == user_id, Quota.quota_key == "report_basic")
.values(limit_value=1)
)
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {},
"photo_object_key": await _upload_photo(client, headers), # 真实 key,先过存在性校验
"request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放
},
)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == 40301 # QUOTA_EXHAUSTED,而非 40920
# --- 场景 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}