Files
meida_backend/tests/integration/test_upload_direct_flow.py
T
xsl a1d0171fc5
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(uploads): 本地磁盘直传(COS 降级路线)+ 报告照片存在性校验
- 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 与幂等重放两条断言语义未动。
2026-09-12 00:24:01 +08:00

169 lines
5.2 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.
"""本地直传 → 建报告全链路(无 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