CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
- POST /uploads/direct:原始字节 body + Content-Type 申报 + scene query(默认 report_photo),场景/类型/大小三重校验复用 storage 既有规则,落盘 var/uploads/<scene>/<user_id>/<uuid>.<ext>(config 新增 upload_dir),key 布局与 COS 同构;不加 python-multipart 依赖 - storage/local.py:save_direct_upload + require_local_upload(resolve 归并 + is_relative_to 归属收敛 + 存在性校验,越界/模拟/不存在一律 404) - report/service.create_report:资格校验后、配额预扣前插入照片存在性校验(模拟 key 被拦且不消耗额度;regenerate 复用已验 photo_meta 不复核) - 测试:test_upload_direct 14 单测 + test_upload_direct_flow 4 集成(真 key 全链路/假 key 404 零流水/401/40010) 偏差说明:test_report_flow._create_report 改走 /uploads/direct 拿真 key(存在性校验必然封死假 key),40920 与幂等重放两条断言语义未动。
441 lines
15 KiB
Python
441 lines
15 KiB
Python
"""报告域端到端(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 ---------------------------------------------------
|
||
|
||
|
||
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}
|