This commit is contained in:
2026-07-27 10:34:53 +08:00
commit 3527975794
74 changed files with 16405 additions and 0 deletions
+455
View File
@@ -0,0 +1,455 @@
"""API 层测试:鉴权、参数校验、错误映射与响应包装
抓取客户端被替换为桩实现,不触达真实站点。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.core.errors import ItemNotFoundError, OffIchibaRedirectError, UpstreamBlockedError
from app.main import create_app
from app.models.scrape import (
GenreData,
GenreNode,
ItemDetailData,
RakumaItemDetailData,
RakumaSearchItem,
RakumaSearchResultData,
RakumaShopDetailData,
RakumaShopItemsData,
SearchItem,
SearchResultData,
ShopDetailData,
)
TOKEN = get_settings().bearer_token
AUTH = {"Authorization": f"Bearer {TOKEN}"}
class StubClient:
"""记录入参并返回固定结果的抓取客户端桩"""
def __init__(self) -> None:
self.search_payload = None
self.detail_payload = None
self.genre_payload = None
self.shop_detail_payload = None
self.shop_items_payload = None
self.raise_on_search: Exception | None = None
self.raise_on_detail: Exception | None = None
async def search(self, payload) -> SearchResultData:
self.search_payload = payload
if self.raise_on_search:
raise self.raise_on_search
return SearchResultData(
keyword=payload.keyword,
page=payload.page,
page_size=45,
total_count=1234,
reachable_count=1234,
has_more=True,
ad_count=2,
request_url="https://search.rakuten.co.jp/search/mall/x/",
items=[SearchItem(item_id="1", item_code="c1", item_name="商品", price=100)],
)
async def item_detail(self, payload) -> ItemDetailData:
self.detail_payload = payload
if self.raise_on_detail:
raise self.raise_on_detail
return ItemDetailData(item_id="1", item_code="c1", item_name="商品", price=100)
async def genres(self, payload) -> GenreData:
self.genre_payload = payload
return GenreData(
genre_id=payload.genre_id or "",
name="テレビゲーム" if payload.genre_id else "",
children=[GenreNode(genre_id="565950", name="Nintendo Switch")],
)
async def shop_detail(self, payload) -> ShopDetailData:
self.shop_detail_payload = payload
return ShopDetailData(
shop_id=272415,
shop_code=payload.shop_code or "edion",
shop_name="エディオン 楽天市場店",
review_score=4.51,
review_count=128136,
)
async def shop_items(self, payload) -> SearchResultData:
self.shop_items_payload = payload
return await self.search(payload.to_search_request(payload.shop_id or 272415))
class StubRakumaClient:
"""ラクマ 抓取客户端桩"""
def __init__(self) -> None:
self.search_payload = None
self.detail_payload = None
self.shop_detail_payload = None
self.shop_items_payload = None
self.raise_on_detail: Exception | None = None
async def search(self, payload) -> RakumaSearchResultData:
self.search_payload = payload
return RakumaSearchResultData(
keyword=payload.keyword,
page=payload.page,
page_size=40,
total_count=1196774,
has_more=True,
request_url="https://fril.jp/s?query=x",
items=[RakumaSearchItem(item_id="abc", item_name="商品", price=6299)],
)
async def item_detail(self, payload) -> RakumaItemDetailData:
self.detail_payload = payload
if self.raise_on_detail:
raise self.raise_on_detail
return RakumaItemDetailData(item_id="abc", item_name="商品", price=6299)
async def shop_detail(self, payload) -> RakumaShopDetailData:
self.shop_detail_payload = payload
return RakumaShopDetailData(shop_id="s1", shop_name="出品者", review_count=118)
async def shop_items(self, payload) -> RakumaShopItemsData:
self.shop_items_payload = payload
return RakumaShopItemsData(
shop_id="s1",
shop_name="出品者",
page=payload.page,
total_count=21,
items=[RakumaSearchItem(item_id="abc", item_name="商品", price=6299)],
)
@pytest.fixture
def client_and_stub():
app = create_app()
with TestClient(app) as client:
stub = StubClient()
rakuma_stub = StubRakumaClient()
app.state.container.rakuten_client = stub
app.state.container.rakuma_client = rakuma_stub
yield client, stub, rakuma_stub
@pytest.fixture
def client(client_and_stub):
return client_and_stub[0]
@pytest.fixture
def stub(client_and_stub):
return client_and_stub[1]
@pytest.fixture
def rakuma_stub(client_and_stub):
return client_and_stub[2]
# ---- 鉴权 ----
@pytest.mark.parametrize("path", ["/api/search", "/api/item_detail", "/api/genres"])
def test_endpoints_reject_missing_token(client, path):
response = client.post(path, json={"keyword": "a", "item_url": "https://item.rakuten.co.jp/s/c/"})
assert response.status_code == 401
body = response.json()
assert body["success"] is False
assert body["code"] == 1001
def test_endpoints_reject_wrong_token(client):
response = client.post(
"/api/search", json={"keyword": "a"}, headers={"Authorization": "Bearer wrong-token"}
)
assert response.status_code == 401
def test_endpoints_reject_non_bearer_scheme(client):
response = client.post("/api/search", json={"keyword": "a"}, headers={"Authorization": TOKEN})
assert response.status_code == 401
def test_health_needs_no_token(client):
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["data"]["status"] == "ok"
assert set(body["data"]["sessions"]) == {"pc", "sp", "rakuma"}
# ---- 参数校验 ----
def test_search_requires_a_target(client):
response = client.post("/api/search", json={}, headers=AUTH)
assert response.status_code == 422
assert response.json()["code"] == 1002
def test_search_rejects_page_beyond_site_limit(client):
response = client.post("/api/search", json={"keyword": "a", "page": 151}, headers=AUTH)
assert response.status_code == 422
def test_search_rejects_unknown_sort(client):
response = client.post("/api/search", json={"keyword": "a", "sort": "cheapest"}, headers=AUTH)
assert response.status_code == 422
def test_item_detail_requires_url_or_code_pair(client):
response = client.post("/api/item_detail", json={"shop_code": "edion"}, headers=AUTH)
assert response.status_code == 422
# ---- 正常响应 ----
def test_search_passes_filters_through_to_client(client, stub):
response = client.post(
"/api/search",
json={
"keyword": "switch",
"page": 2,
"sort": "price_asc",
"min_price": 1000,
"condition": "used",
"free_shipping": True,
"exclude_ads": False,
},
headers=AUTH,
)
assert response.status_code == 200
payload = stub.search_payload
assert payload.keyword == "switch"
assert payload.page == 2
assert payload.sort.value == "price_asc"
assert payload.min_price == 1000
assert payload.condition.value == "used"
assert payload.free_shipping is True
assert payload.exclude_ads is False
body = response.json()
assert body["success"] is True
assert body["code"] == 0
assert body["data"]["total_count"] == 1234
assert body["data"]["items"][0]["item_code"] == "c1"
def test_genres_accepts_empty_body_for_top_level(client, stub):
"""顶层分类不需要任何入参"""
response = client.post("/api/genres", json={}, headers=AUTH)
assert response.status_code == 200
assert stub.genre_payload.genre_id is None
assert response.json()["data"]["children"][0]["genre_id"] == "565950"
def test_genres_passes_genre_id_through(client, stub):
response = client.post("/api/genres", json={"genre_id": "101205"}, headers=AUTH)
assert response.status_code == 200
assert stub.genre_payload.genre_id == "101205"
assert response.json()["data"]["name"] == "テレビゲーム"
def test_item_detail_defaults_to_including_sku_variants(client, stub):
response = client.post(
"/api/item_detail",
json={"shop_code": "edion", "item_code": "4902370549263"},
headers=AUTH,
)
assert response.status_code == 200
assert stub.detail_payload.include_sku_variants is True
assert response.json()["data"]["item_name"] == "商品"
# ---- 抓取异常映射 ----
def test_blocked_upstream_maps_to_structured_error(client, stub):
stub.raise_on_search = UpstreamBlockedError("blocked by akamai")
response = client.post("/api/search", json={"keyword": "a"}, headers=AUTH)
assert response.status_code == 400
body = response.json()
assert body["success"] is False
assert body["code"] == 3002
assert "blocked" in body["msg"]
def test_off_ichiba_redirect_gets_its_own_error_code(client, stub):
"""楽天ブックス等官方子站的商品不该被当成反爬拦截,上游要能据此改走别的通道"""
stub.raise_on_detail = OffIchibaRedirectError(
"https://item.rakuten.co.jp/book/16033028/",
"https://books.rakuten.co.jp/rb/16033028/",
)
response = client.post(
"/api/item_detail", json={"shop_code": "book", "item_code": "16033028"}, headers=AUTH
)
assert response.status_code == 400
body = response.json()
assert body["code"] == 4002
assert "books.rakuten.co.jp" in body["msg"]
def test_missing_item_maps_to_404(client, stub):
stub.raise_on_detail = ItemNotFoundError("no such item")
response = client.post(
"/api/item_detail", json={"shop_code": "s", "item_code": "c"}, headers=AUTH
)
assert response.status_code == 404
assert response.json()["code"] == 4004
# ---- 乐天商家 ----
def test_shop_detail_returns_store_profile(client, stub):
response = client.post("/api/shop_detail", json={"shop_code": "edion"}, headers=AUTH)
assert response.status_code == 200
assert stub.shop_detail_payload.shop_code == "edion"
data = response.json()["data"]
assert data["shop_id"] == 272415
assert data["review_count"] == 128136
def test_shop_detail_requires_an_identifier(client):
response = client.post("/api/shop_detail", json={}, headers=AUTH)
assert response.status_code == 422
def test_shop_items_scopes_the_search_to_the_shop(client, stub):
"""商家商品实际是一次 sid 限定的搜索,筛选字段要原样传下去"""
response = client.post(
"/api/shop_items",
json={"shop_id": 272415, "page": 2, "keyword": "テレビ", "sort": "price_asc"},
headers=AUTH,
)
assert response.status_code == 200
assert stub.shop_items_payload.shop_id == 272415
# 转换后的搜索请求必须带上店铺限定
assert stub.search_payload.shop_id == 272415
assert stub.search_payload.keyword == "テレビ"
assert stub.search_payload.page == 2
assert response.json()["data"]["total_count"] == 1234
def test_shop_items_requires_an_identifier(client):
response = client.post("/api/shop_items", json={"page": 2}, headers=AUTH)
assert response.status_code == 422
# ---- ラクマ ----
@pytest.mark.parametrize(
"path",
[
"/api/rakuma/search",
"/api/rakuma/item_detail",
"/api/rakuma/shop_detail",
"/api/rakuma/shop_items",
],
)
def test_rakuma_endpoints_reject_missing_token(client, path):
response = client.post(path, json={"keyword": "a", "item_id": "x", "shop_id": "s"})
assert response.status_code == 401
assert response.json()["code"] == 1001
def test_rakuma_search_passes_filters_through(client, rakuma_stub):
response = client.post(
"/api/rakuma/search",
json={
"keyword": "switch",
"page": 2,
"sort": "price_asc",
"conditions": ["new", "almost_new"],
"transaction": "on_sale",
"free_shipping": True,
"brand_id": "5296",
},
headers=AUTH,
)
assert response.status_code == 200
payload = rakuma_stub.search_payload
assert payload.keyword == "switch"
assert payload.page == 2
assert payload.sort.value == "price_asc"
assert [c.value for c in payload.conditions] == ["new", "almost_new"]
assert payload.transaction.value == "on_sale"
assert payload.free_shipping is True
assert payload.brand_id == "5296"
assert response.json()["data"]["total_count"] == 1196774
def test_rakuma_search_requires_a_target(client):
response = client.post("/api/rakuma/search", json={}, headers=AUTH)
assert response.status_code == 422
assert response.json()["code"] == 1002
def test_rakuma_search_rejects_page_beyond_site_limit(client):
"""站点 page>100 直接 404,不必打出去才知道"""
response = client.post(
"/api/rakuma/search", json={"keyword": "a", "page": 101}, headers=AUTH
)
assert response.status_code == 422
def test_rakuma_search_rejects_rakuten_only_sort(client):
"""两站排序枚举不同,乐天的 review_count 在 ラクマ 上不存在"""
response = client.post(
"/api/rakuma/search", json={"keyword": "a", "sort": "review_count"}, headers=AUTH
)
assert response.status_code == 422
def test_rakuma_item_detail_accepts_item_id(client, rakuma_stub):
response = client.post("/api/rakuma/item_detail", json={"item_id": "abc"}, headers=AUTH)
assert response.status_code == 200
assert rakuma_stub.detail_payload.item_id == "abc"
assert response.json()["data"]["price"] == 6299
def test_rakuma_item_detail_requires_an_identifier(client):
response = client.post("/api/rakuma/item_detail", json={}, headers=AUTH)
assert response.status_code == 422
def test_rakuma_missing_item_maps_to_404(client, rakuma_stub):
rakuma_stub.raise_on_detail = ItemNotFoundError("no such item")
response = client.post("/api/rakuma/item_detail", json={"item_id": "x"}, headers=AUTH)
assert response.status_code == 404
assert response.json()["code"] == 4004
def test_rakuma_shop_detail_skips_reviews_by_default(client, rakuma_stub):
"""评价明细要多打一次请求,默认不取"""
response = client.post("/api/rakuma/shop_detail", json={"shop_id": "s1"}, headers=AUTH)
assert response.status_code == 200
assert rakuma_stub.shop_detail_payload.include_reviews is False
assert response.json()["data"]["review_count"] == 118
def test_rakuma_shop_detail_can_request_reviews(client, rakuma_stub):
response = client.post(
"/api/rakuma/shop_detail", json={"shop_id": "s1", "include_reviews": True}, headers=AUTH
)
assert response.status_code == 200
assert rakuma_stub.shop_detail_payload.include_reviews is True
def test_rakuma_shop_items_passes_page_through(client, rakuma_stub):
response = client.post(
"/api/rakuma/shop_items", json={"shop_id": "s1", "page": 3}, headers=AUTH
)
assert response.status_code == 200
assert rakuma_stub.shop_items_payload.page == 3
assert response.json()["data"]["total_count"] == 21
@pytest.mark.parametrize("path", ["/api/rakuma/shop_detail", "/api/rakuma/shop_items"])
def test_rakuma_shop_endpoints_require_an_identifier(client, path):
response = client.post(path, json={}, headers=AUTH)
assert response.status_code == 422