147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
"""搜索/商品 URL 构建与解析的单元测试"""
|
|
from urllib.parse import parse_qsl, urlsplit
|
|
|
|
import pytest
|
|
|
|
from app.core.errors import InvalidRequestError
|
|
from app.models.scrape import ItemCondition, SearchRequest, SortOption
|
|
from app.utils.urls import (
|
|
build_item_url,
|
|
build_search_url,
|
|
item_url_parts,
|
|
normalize_search_url,
|
|
split_item_url,
|
|
)
|
|
|
|
|
|
def query_of(url: str) -> list[tuple[str, str]]:
|
|
return parse_qsl(urlsplit(url).query, keep_blank_values=True)
|
|
|
|
|
|
def test_keyword_only_uses_default_sort_without_params():
|
|
url = build_search_url(SearchRequest(keyword="nintendo switch"))
|
|
assert url == "https://search.rakuten.co.jp/search/mall/nintendo%20switch/"
|
|
|
|
|
|
def test_genre_without_keyword_uses_placeholder_segment():
|
|
url = build_search_url(SearchRequest(genre_id="565950"))
|
|
assert urlsplit(url).path == "/search/mall/-/565950/"
|
|
|
|
|
|
def test_keyword_and_genre_are_both_path_segments():
|
|
url = build_search_url(SearchRequest(keyword="switch", genre_id="565950"))
|
|
assert urlsplit(url).path == "/search/mall/switch/565950/"
|
|
|
|
|
|
def test_page_one_is_omitted_but_later_pages_are_explicit():
|
|
assert "p=" not in build_search_url(SearchRequest(keyword="a", page=1))
|
|
assert ("p", "3") in query_of(build_search_url(SearchRequest(keyword="a", page=3)))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("sort", "expected"),
|
|
[
|
|
(SortOption.STANDARD, None),
|
|
(SortOption.PRICE_ASC, "2"),
|
|
(SortOption.PRICE_DESC, "3"),
|
|
(SortOption.NEWEST, "4"),
|
|
(SortOption.REVIEW_COUNT, "5"),
|
|
(SortOption.REVIEW_SCORE, "6"),
|
|
(SortOption.PRICE_WITH_SHIPPING_ASC, "11"),
|
|
(SortOption.PRICE_WITH_SHIPPING_DESC, "12"),
|
|
],
|
|
)
|
|
def test_sort_maps_to_site_codes(sort, expected):
|
|
"""排序码取自站点前端 bundle 的枚举,改动会直接影响返回顺序"""
|
|
query = dict(query_of(build_search_url(SearchRequest(keyword="a", sort=sort))))
|
|
assert query.get("s") == expected
|
|
|
|
|
|
def test_condition_and_bool_filters_share_repeatable_f_param():
|
|
url = build_search_url(
|
|
SearchRequest(
|
|
keyword="a",
|
|
condition=ItemCondition.USED,
|
|
free_shipping=True,
|
|
include_sold_out=True,
|
|
has_review=True,
|
|
next_day_delivery=True,
|
|
super_deal=True,
|
|
)
|
|
)
|
|
f_values = sorted(value for key, value in query_of(url) if key == "f")
|
|
assert f_values == sorted(["100", "0", "2", "4", "12", "13"])
|
|
|
|
|
|
def test_advanced_filters_are_mapped_to_site_param_names():
|
|
url = build_search_url(
|
|
SearchRequest(
|
|
keyword="a",
|
|
min_price=1000,
|
|
max_price=5000,
|
|
shop_id=272415,
|
|
exclude_keyword="中古",
|
|
title_only=True,
|
|
or_query=True,
|
|
min_review_score=4,
|
|
tags=["1001", "1002"],
|
|
)
|
|
)
|
|
query = dict(query_of(url))
|
|
assert query["min"] == "1000"
|
|
assert query["max"] == "5000"
|
|
assert query["sid"] == "272415"
|
|
assert query["nitem"] == "中古"
|
|
assert query["sf"] == "1"
|
|
assert query["st"] == "O"
|
|
assert query["review"] == "4"
|
|
assert query["tg"] == "1001,1002"
|
|
|
|
|
|
def test_search_request_requires_a_target():
|
|
with pytest.raises(ValueError):
|
|
SearchRequest()
|
|
|
|
|
|
def test_search_request_rejects_inverted_price_range():
|
|
with pytest.raises(ValueError):
|
|
SearchRequest(keyword="a", min_price=5000, max_price=1000)
|
|
|
|
|
|
def test_normalize_search_url_keeps_url_untouched_on_first_page():
|
|
raw = "https://search.rakuten.co.jp/search/mall/switch/?s=2&f=2"
|
|
assert normalize_search_url(raw, 1) == raw
|
|
|
|
|
|
def test_normalize_search_url_overrides_existing_page():
|
|
raw = "https://search.rakuten.co.jp/search/mall/switch/?p=9&s=2"
|
|
query = dict(query_of(normalize_search_url(raw, 4)))
|
|
assert query["p"] == "4"
|
|
assert query["s"] == "2"
|
|
|
|
|
|
def test_normalize_search_url_rejects_foreign_host():
|
|
with pytest.raises(InvalidRequestError):
|
|
normalize_search_url("https://example.com/search/mall/switch/", 1)
|
|
|
|
|
|
def test_build_item_url_joins_shop_and_item_code():
|
|
assert build_item_url("edion", "4902370549263") == "https://item.rakuten.co.jp/edion/4902370549263/"
|
|
|
|
|
|
def test_split_item_url_extracts_shop_and_item_code():
|
|
assert split_item_url("https://item.rakuten.co.jp/edion/4902370549263/?variantId=x") == (
|
|
"edion",
|
|
"4902370549263",
|
|
)
|
|
|
|
|
|
def test_split_item_url_rejects_foreign_host():
|
|
with pytest.raises(InvalidRequestError):
|
|
split_item_url("https://www.rakuten.co.jp/edion/4902370549263/")
|
|
|
|
|
|
def test_item_url_parts_returns_empty_for_ad_redirect_links():
|
|
"""广告位链接指向跳转域,解析不出来应静默返回空串而不是抛异常"""
|
|
assert item_url_parts("https://grp07.ias.rakuten.co.jp/redirect_rpp/?s=abc") == ("", "")
|