Files
2026-05-29 13:27:41 +08:00

199 lines
6.4 KiB
Python

"""新接口的端到端测试。会真实跑到 mock_extractor,因此每个 case 会 sleep 1.5-2.5s。
我们 monkey-patch 把 sleep 拿掉,加快测试。"""
from __future__ import annotations
import io
import json
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@pytest.fixture(autouse=True)
def _no_sleep():
"""禁用 mock_extractor 的真实异步延迟,测试运行时间从 ~10s 降到 < 1s。"""
async def _instant(_seconds):
return None
with patch("app.mock_extractor.asyncio.sleep", _instant):
yield
def _fake_image(payload: bytes = b"fake jpeg bytes 0123456789") -> tuple:
return ("image", ("test.jpg", io.BytesIO(payload), "image/jpeg"))
def test_parse_image_basic() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": "[]"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["title"]
assert body["price"] > 0
assert body["typical_price"] > 0
assert body["source_app"] in {"淘宝", "京东", "拼多多", "抖音", "美团", "饿了么"}
assert body["cluster_id"] is None # 空 clusters 必然新建
def test_parse_image_same_bytes_same_product() -> None:
"""同样字节 → 同样商品(便于真机重放)。允许价格因抖动小幅不同。"""
r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"})
r2 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["title"] == r2.json()["title"]
assert r1.json()["source_app"] == r2.json()["source_app"]
def test_parse_image_cluster_match() -> None:
"""如果 clusters 里有 title 与 mock 选中商品共享前 4 字符,应命中该 cluster。"""
# 先发一次拿到 picked title
r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"BBBB")], data={"clusters": "[]"})
picked_title = r1.json()["title"]
cluster_id = "uuid-fake-1"
clusters = [{"id": cluster_id, "title": picked_title}]
r2 = client.post(
"/api/v1/parse-image",
files=[_fake_image(b"BBBB")],
data={"clusters": json.dumps(clusters)},
)
assert r2.status_code == 200
assert r2.json()["cluster_id"] == cluster_id
def test_parse_image_no_image() -> None:
r = client.post(
"/api/v1/parse-image",
files=[("image", ("empty.jpg", io.BytesIO(b""), "image/jpeg"))],
data={"clusters": "[]"},
)
assert r.status_code == 422
assert r.json()["detail"] == "no_image"
def test_parse_image_missing_image_field() -> None:
r = client.post("/api/v1/parse-image", data={"clusters": "[]"})
assert r.status_code == 422 # FastAPI 字段校验失败也返回 422
def test_parse_image_invalid_clusters_json() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": "not a json"},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_parse_image_clusters_not_array() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": '{"id":"x","title":"y"}'},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_parse_image_too_large() -> None:
big = b"x" * (5 * 1024 * 1024 + 1)
r = client.post(
"/api/v1/parse-image",
files=[("image", ("big.jpg", io.BytesIO(big), "image/jpeg"))],
data={"clusters": "[]"},
)
assert r.status_code == 422
assert r.json()["detail"] == "image_too_large"
def test_parse_text_basic() -> None:
r = client.post(
"/api/v1/parse-text",
json={
"title": "iPhone 15 Pro Max 256GB",
"price": 9999.0,
"source_app": "淘宝",
"clusters": [],
},
)
assert r.status_code == 200
body = r.json()
assert body["title"] == "iPhone 15 Pro Max 256GB"
assert body["price"] == 9999.0
assert body["source_app"] == "淘宝"
assert body["typical_price"] > 0
assert body["cluster_id"] is None
def test_parse_text_cluster_match() -> None:
"""同样 title 前 4 字符的 cluster 应命中。"""
cluster_id = "uuid-text-1"
r = client.post(
"/api/v1/parse-text",
json={
"title": "iPhone 15 Pro Max 1TB 暮光紫",
"price": 11999.0,
"source_app": "京东",
"clusters": [{"id": cluster_id, "title": "iPhone 15 Pro 256GB"}],
},
)
assert r.status_code == 200
assert r.json()["cluster_id"] == cluster_id
def test_parse_text_empty_title() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": " ", "price": 9.9, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
assert r.json()["detail"] == "empty_title"
def test_parse_text_invalid_price() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": "x", "price": 0, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_price"
def test_parse_text_negative_price() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": "x", "price": -1, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
def test_parse_image_invalid_cluster_schema() -> None:
"""clusters 数组里某个对象缺 id/title 字段 → Pydantic ValidationError 必须返回 422 而非 500"""
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": '[{"id":"only-id-no-title"}]'},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_concurrent_parse_image_does_not_block_event_loop(_no_sleep) -> None:
"""禁用 sleep 后并发应该极快;若 event loop 被阻塞会显著退化。
主要保护 mock_extract_image 永远是 async + await。"""
import time as _t
start = _t.time()
for _ in range(10):
r = client.post("/api/v1/parse-image", files=[_fake_image()], data={"clusters": "[]"})
assert r.status_code == 200
# 10 次串行(TestClient 是同步客户端)且 sleep 被 mock 掉,应远 < 1s
assert _t.time() - start < 2.0