199 lines
6.4 KiB
Python
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
|