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 与幂等重放两条断言语义未动。
169 lines
5.2 KiB
Python
169 lines
5.2 KiB
Python
"""本地直传 → 建报告全链路(无 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
|