"""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