"""本地直传 → 建报告全链路(无 COS 降级路线)。 dev-token 登录 → POST /uploads/direct 上传原始字节 → 拿真实 object_key → POST /reports 建报告走通;模拟 key 被存在性校验以 404 挡住,且不消耗配额。 不启真 celery:API 投递钩子替换为 no-op(与 test_report_flow 同口径)。 """ from __future__ import annotations import os import uuid from collections.abc import AsyncIterator 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", ), ] JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 64 @pytest.fixture def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None: """不向真 Redis 投递:本用例只验证「上传 → 建报告」入口,不跑 worker。""" monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None) @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 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"upload-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(client: Any, headers: dict[str, str]) -> str: resp = await client.post( "/api/v1/uploads/direct?scene=report_photo", headers={**headers, "Content-Type": "image/jpeg"}, content=JPEG_BYTES, ) assert resp.status_code == 200 return str(resp.json()["data"]["object_key"]) async def test_direct_upload_then_create_report_with_real_key(client: Any) -> None: headers, user_id = await _login(client) object_key = await _upload(client, headers) assert object_key.startswith(f"report_photo/{user_id}/") assert object_key.endswith(".jpg") resp = await client.post( "/api/v1/reports", headers=headers, json={ "questionnaire": {"style": "通勤"}, "photo_object_key": object_key, "request_key": f"rk-{uuid.uuid4().hex}", }, ) assert resp.status_code == 200 body = resp.json()["data"] assert body["status"] == "pending" # 落盘的输入行应记录真实 object_key。 from sqlalchemy import select from packages.shared.db import session_scope from packages.shared.models import RpInput async with session_scope() as session: result = await session.execute( select(RpInput).where(RpInput.report_id == int(body["report_id"])) ) rp_input = result.scalar_one() assert rp_input.photo_meta == {"object_key": object_key} async def test_create_report_with_mock_key_is_rejected_404_without_reserving_quota( client: Any, ) -> None: headers, user_id = await _login(client) resp = await client.post( "/api/v1/reports", headers=headers, json={ "questionnaire": {}, "photo_object_key": f"report_photo/{user_id}/mock123.jpg", # 布局合法的模拟键 "request_key": f"rk-{uuid.uuid4().hex}", }, ) assert resp.status_code == 404 assert resp.json()["code"] == 40400 # 拒绝发生在配额预扣之前:终身配额流水必须为空。 from sqlalchemy import func, 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(func.count()) .select_from(Ledger) .where(Ledger.user_id == user_id, Ledger.quota_key == "report_basic") ) assert int(result.scalar_one()) == 0 async def test_direct_upload_requires_auth(client: Any) -> None: resp = await client.post( "/api/v1/uploads/direct?scene=report_photo", headers={"Content-Type": "image/jpeg"}, content=JPEG_BYTES, ) assert resp.status_code == 401 assert resp.json()["code"] == 40100 async def test_direct_upload_rejects_disallowed_type_and_scene(client: Any) -> None: headers, _ = await _login(client) bad_type = await client.post( "/api/v1/uploads/direct?scene=report_photo", headers={**headers, "Content-Type": "application/pdf"}, content=JPEG_BYTES, ) assert bad_type.status_code == 400 assert bad_type.json()["code"] == 40010 bad_scene = await client.post( "/api/v1/uploads/direct?scene=evil", headers={**headers, "Content-Type": "image/jpeg"}, content=JPEG_BYTES, ) assert bad_scene.status_code == 400 assert bad_scene.json()["code"] == 40010