456 lines
15 KiB
Python
456 lines
15 KiB
Python
"""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
|