- 新卡代填改用真实 DOM 结构:卡号/有效期分别托管在 Rakuten PCI 代付 vault 的 跨域 iframe 里,此前按 autocomplete/name 猜的 selector 在主文档里根本找不到 元素;持卡人姓名字段改用「名義人」标签相对定位,不用 placeholder 示例文案 (TARO RAKUTEN 只是示例用户名,不是稳定标识) - 订单号正则修正 实体导致的分隔符匹配失败(2026-08-13 真实下单验证) - login_runner._is_logged_in 改用与 AuthSession 一致的 __INITIAL_STATE__ 判据, 修掉此前误判「已登录」导致保存无效 storage_state 的问题 - 新增 CheckoutBlockedError:站点风控拦截(session upgrade/3DS)转 needs_human, 不当普通失败重试 - account.yaml 支持 phone / payment.credit-card 字段 - data/evidence/(真实 PII)加入 .gitignore Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""site_interact 单元测试
|
|
|
|
重点测模块级纯函数(_parse_initial_state / _extract_purchase_fields /
|
|
_extract_error_message)——这三者覆盖了「从商品页 HTML 抽加购表单」的核心逻辑,
|
|
是 worker 真实跑起来时最关键的转换。
|
|
|
|
SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖
|
|
范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
|
|
from app.trading.worker.models import LeaseTask
|
|
from app.trading.worker.site_interact import (
|
|
CheckoutSummary,
|
|
SiteInteractor,
|
|
_extract_error_message,
|
|
_extract_purchase_fields,
|
|
_parse_checkout_summary,
|
|
_parse_initial_state,
|
|
)
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def _make_task(**kwargs: Any) -> LeaseTask:
|
|
defaults = {
|
|
"task_id": "t1",
|
|
"site": "rakuten",
|
|
"intent": {"item_url": "https://item.rakuten.co.jp/shop/x/"},
|
|
}
|
|
defaults.update(kwargs)
|
|
return LeaseTask(**defaults)
|
|
|
|
|
|
def _wrap_state(state: dict[str, Any]) -> str:
|
|
"""构造一段模拟的 item page HTML,把 state 嵌进 __INITIAL_STATE__"""
|
|
return (
|
|
"<html><script>window.__INITIAL_STATE__ = "
|
|
+ json.dumps(state, ensure_ascii=False)
|
|
+ ";window.__OTHER__ = 1;</script></html>"
|
|
)
|
|
|
|
|
|
# ---- _parse_initial_state ----
|
|
|
|
|
|
def test_parse_initial_state_extracts_state_dict():
|
|
state = {"item": {"itemId": 123}, "purchase": {}}
|
|
parsed = _parse_initial_state(_wrap_state(state))
|
|
assert parsed == state
|
|
|
|
|
|
def test_parse_initial_state_returns_none_when_no_marker():
|
|
assert _parse_initial_state("<html>no state here</html>") is None
|
|
|
|
|
|
def test_parse_initial_state_returns_none_on_invalid_json():
|
|
html = "<script>window.__INITIAL_STATE__ = {invalid};window.__END=1;</script>"
|
|
assert _parse_initial_state(html) is None
|
|
|
|
|
|
# ---- _extract_purchase_fields ----
|
|
|
|
|
|
def test_extract_fields_from_multi_inventory_fixture():
|
|
"""item_state.json 是多规格商品,应自动选第一个非售罄 variant"""
|
|
state = json.loads((FIXTURES / "item_state.json").read_text(encoding="utf-8"))
|
|
fields = _extract_purchase_fields(state, intent_override={})
|
|
assert fields["purchase_condition"] == "enabled"
|
|
assert fields["basket_domain"].startswith("https://")
|
|
assert fields["basket_domain"].endswith("/rms/mall/bss/cartadd/set")
|
|
# 多规格:form 应包含自动选的 variant_id
|
|
assert fields["inventory_flag"] == "2"
|
|
assert "variant_id" in fields["form_fields"]
|
|
assert fields["form_fields"]["__event"] == "ES01_003_001"
|
|
assert fields["form_fields"]["inventory_flag"] == "2"
|
|
# quantity_field / variant_field 是约定字段名
|
|
assert fields["quantity_field"] == "units"
|
|
assert fields["variant_field"] == "variant_id"
|
|
|
|
|
|
def test_extract_fields_single_inventory_uses_item_variant_id():
|
|
"""单规格商品应直接用 item.variantId,不需要选 sku.variants"""
|
|
state = {
|
|
"item": {"itemId": 999, "variantId": "v-1"},
|
|
"purchase": {
|
|
"sku": {"inventoryType": "single", "variants": []},
|
|
"sellType": {
|
|
"normalPurchase": {
|
|
"basketDomain": "https://example.co.jp/add",
|
|
"purchaseCondition": "enabled",
|
|
}
|
|
},
|
|
"information": {},
|
|
},
|
|
"shop": {"information": {"shopId": 42}},
|
|
}
|
|
fields = _extract_purchase_fields(state, intent_override={})
|
|
assert fields["inventory_flag"] == "1"
|
|
assert fields["form_fields"]["variant_id"] == "v-1"
|
|
|
|
|
|
def test_extract_fields_intent_overrides_variant_id():
|
|
"""调用方显式给 variant_id 时,跳过自动选"""
|
|
state = json.loads((FIXTURES / "item_state.json").read_text(encoding="utf-8"))
|
|
fields = _extract_purchase_fields(state, intent_override={"variant_id": "my-choice"})
|
|
assert fields["form_fields"]["variant_id"] == "my-choice"
|
|
|
|
|
|
def test_extract_fields_multi_inventory_no_variants_returns_no_variant():
|
|
"""多规格但 variants 空:不强行填 variant_id;调用方靠这个判断不可加购"""
|
|
state = {
|
|
"item": {"itemId": 1},
|
|
"purchase": {
|
|
"sku": {"inventoryType": "multiple", "variants": []},
|
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
|
"information": {},
|
|
},
|
|
"shop": {"information": {"shopId": 1}},
|
|
}
|
|
fields = _extract_purchase_fields(state, intent_override={})
|
|
assert "variant_id" not in fields["form_fields"]
|
|
|
|
|
|
def test_extract_fields_required_options_auto_picks_first_value():
|
|
"""有必填选项时,自动用第一个候选值填 choice"""
|
|
state = {
|
|
"item": {"itemId": 1, "variantId": "v"},
|
|
"purchase": {
|
|
"sku": {"inventoryType": "single"},
|
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
|
"information": {
|
|
"options": [
|
|
{
|
|
"id": 1,
|
|
"name": "サイズ",
|
|
"type": "select",
|
|
"isRequired": True,
|
|
"values": [
|
|
{"id": 10, "name": "S"},
|
|
{"id": 11, "name": "M"},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
},
|
|
"shop": {"information": {"shopId": 1}},
|
|
}
|
|
fields = _extract_purchase_fields(state, intent_override={})
|
|
assert fields["has_required_options"] is True
|
|
assert fields["form_fields"]["choice"] == "サイズ:S"
|
|
|
|
|
|
def test_extract_fields_intent_choice_override_accepts_list_and_str():
|
|
state = {
|
|
"item": {"itemId": 1, "variantId": "v"},
|
|
"purchase": {
|
|
"sku": {"inventoryType": "single"},
|
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
|
"information": {"options": [{"name": "x", "isRequired": True, "values": [{"name": "a"}]}]},
|
|
},
|
|
"shop": {"information": {"shopId": 1}},
|
|
}
|
|
# list 形态
|
|
fields = _extract_purchase_fields(state, intent_override={"choice": ["サイズ:M", "色:赤"]})
|
|
assert fields["form_fields"]["choice"] == "サイズ:M,色:赤"
|
|
# str 形态
|
|
fields = _extract_purchase_fields(state, intent_override={"choice": "custom:value"})
|
|
assert fields["form_fields"]["choice"] == "custom:value"
|
|
|
|
|
|
def test_extract_fields_basket_domain_unescapes_u002f():
|
|
"""JSON encoded 的 / 在 __INITIAL_STATE__ 里可能是 \\u002F,要还原"""
|
|
state = {
|
|
"item": {"itemId": 1, "variantId": "v"},
|
|
"purchase": {
|
|
"sku": {"inventoryType": "single"},
|
|
"sellType": {
|
|
"normalPurchase": {
|
|
"basketDomain": "https:\\u002F\\u002Fx.example\\u002Fadd",
|
|
"purchaseCondition": "enabled",
|
|
}
|
|
},
|
|
"information": {},
|
|
},
|
|
"shop": {"information": {"shopId": 1}},
|
|
}
|
|
fields = _extract_purchase_fields(state, intent_override={})
|
|
assert fields["basket_domain"] == "https://x.example/add"
|
|
|
|
|
|
# ---- _extract_error_message ----
|
|
|
|
|
|
def test_extract_error_message_picks_visible_text():
|
|
body = """
|
|
<html><body>
|
|
<div>「商品 X」は商品情報が変更されました。</div>
|
|
<div>最新の商品ページから再度かごに追加してください。</div>
|
|
<div>ご注文手続き中の通信は、SSLによりすべて暗号化されます</div>
|
|
<div>© Rakuten Group, Inc.</div>
|
|
</body></html>
|
|
"""
|
|
msg = _extract_error_message(body)
|
|
# 应当包含前两条核心提示;SSL 与版权被过滤
|
|
assert "商品情報が変更されました" in msg
|
|
assert "SSL" not in msg
|
|
assert "Rakuten Group" not in msg
|
|
|
|
|
|
def test_extract_error_message_filters_punctuation_heavy_strings():
|
|
body = "<div>未選択の項目からどれか1つ選んでください。</div><div>{json: 'x'}</div>"
|
|
msg = _extract_error_message(body)
|
|
assert "未選択" in msg
|
|
# 含 { 与 ' 的 JSON 片段应被过滤
|
|
assert "json" not in msg
|
|
|
|
|
|
def test_extract_error_message_returns_empty_when_no_meaningful_text():
|
|
body = "<html></html>"
|
|
assert _extract_error_message(body) == ""
|
|
|
|
|
|
# ---- CheckoutSummary 仍是 dataclass ----
|
|
|
|
|
|
def test_checkout_summary_defaults():
|
|
s = CheckoutSummary(payable_yen=1000)
|
|
assert s.payable_yen == 1000
|
|
assert s.site_order_id is None
|
|
assert s.pay_deadline is None
|
|
|
|
|
|
# ---- SiteInteractor 构造(不调 start,无 Playwright 依赖)----
|
|
|
|
|
|
def test_site_interactor_construction_does_not_require_playwright():
|
|
"""构造 SiteInteractor 不应触发 Playwright import;start() 才会"""
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
assert site._playwright is None
|
|
assert site._browser is None
|
|
assert site._context is None
|
|
assert site._per_task_state == {}
|
|
|
|
|
|
# ---- monitor 仍未实现,抛 NotImplementedError ----
|
|
|
|
|
|
async def test_monitor_unimplemented():
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
with pytest.raises(NotImplementedError):
|
|
await site.monitor(_make_task(), "ord-1")
|
|
|
|
|
|
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
|
|
|
|
|
|
async def test_submit_order_without_checkout_page_raises():
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
with pytest.raises(OrderOperationError):
|
|
await site.submit_order(_make_task())
|
|
|
|
|
|
async def test_pay_without_checkout_page_raises():
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
with pytest.raises(OrderOperationError):
|
|
await site.pay(_make_task(), "ord-1")
|
|
|
|
|
|
# ---- parse_checkout / _parse_checkout_summary:金额解析已用真实确认页 HTML 验证
|
|
# (2026-08-13,data/evidence/checkout-research-20260811/i-final-state.html),
|
|
# 订单号/付款期限仍未验证。下面简单 fixture 测的是防御性行为(找不到/矛盾就报错)。
|
|
|
|
|
|
async def test_parse_checkout_delegates_to_pure_function():
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
html = "<div>お支払い金額 1,980円</div>"
|
|
summary = await site.parse_checkout(html)
|
|
assert summary.payable_yen == 1980
|
|
|
|
|
|
def test_parse_checkout_summary_extracts_amount_order_id_deadline():
|
|
"""订单号 fixture 用真实格式(三段数字用「-」连接,见 _ORDER_ID_PATTERN 注释),
|
|
2026-08-13 之前这里用的是纯猜测的 "AB-123456" 字母前缀格式,拿到真实订单号后
|
|
已确认不是这个形状。"""
|
|
html = (
|
|
"<div>お支払い合計 12,345円</div>"
|
|
"<div>ご注文番号 306087-20260813-0863947697</div>"
|
|
"<div>お支払い期限 2026/08/20</div>"
|
|
)
|
|
summary = _parse_checkout_summary(html)
|
|
assert summary.payable_yen == 12345
|
|
assert summary.site_order_id == "306087-20260813-0863947697"
|
|
assert summary.pay_deadline == "2026/08/20"
|
|
|
|
|
|
def test_parse_checkout_summary_missing_amount_raises():
|
|
"""找不到「标签+金额」的明确匹配就报错,不落回猜数字——这个值直接喂给金额守卫"""
|
|
with pytest.raises(OrderOperationError):
|
|
_parse_checkout_summary("<div>合計 1,980円</div>") # 标签不在候选列表里
|
|
|
|
|
|
def test_parse_checkout_summary_conflicting_amounts_raises():
|
|
html = "<div>お支払い金額 1,000円</div><div>お支払い合計 2,000円</div>"
|
|
with pytest.raises(OrderOperationError):
|
|
_parse_checkout_summary(html)
|
|
|
|
|
|
def test_parse_checkout_summary_optional_fields_default_none():
|
|
summary = _parse_checkout_summary("<div>お支払い金額 500円</div>")
|
|
assert summary.payable_yen == 500
|
|
assert summary.site_order_id is None
|
|
assert summary.pay_deadline is None
|
|
|
|
|
|
def test_parse_checkout_summary_matches_real_page_dom_shape():
|
|
"""真实确认页金额结构:标签纯「支払い金額」(无「お」前缀),数字与「円」
|
|
分别在独立标签里、中间隔着大段 class 属性,不是「标签+数字+円」紧邻文案
|
|
(2026-08-13 从真实页面简化摘录的结构,见 _AMOUNT_TAG_BOUNDED_PATTERN 注释)"""
|
|
html = (
|
|
'<span class="label--x">支払い金額</span></div>'
|
|
'<div class="number-display--x">'
|
|
'<div class="value--x">297</div>'
|
|
'<span class="suffix--x"><div class="text-display--x">円</div></span>'
|
|
"</div>"
|
|
)
|
|
summary = _parse_checkout_summary(html)
|
|
assert summary.payable_yen == 297
|
|
|
|
|
|
def test_parse_checkout_summary_matches_real_order_id_dom_shape():
|
|
"""真实下单完成页订单号结构:标签和号码之间是 ` ` 实体而不是普通空白,
|
|
号码是三段数字用「-」连接(2026-08-13 真实点击「注文を確定する」后从
|
|
完成页摘录的结构,见 _ORDER_ID_PATTERN 注释)。旧版正则的分隔符只认
|
|
非单词字符,会卡在 ` ` 里的 n/b/s/p 上、完全连不到号码。"""
|
|
html = (
|
|
'<div>お支払い金額 297円</div>'
|
|
'<span class="label--x">注文番号 306087-20260813-0863947697</span>'
|
|
)
|
|
summary = _parse_checkout_summary(html)
|
|
assert summary.site_order_id == "306087-20260813-0863947697"
|
|
|
|
|
|
# ---- enter_checkout / add_to_cart / verify_cart 在没启动 Playwright 时应失败 ----
|
|
|
|
|
|
async def test_enter_checkout_without_start_raises():
|
|
"""没调 start() 就调 enter_checkout,auth_session=None,应在 require_logged_in 处抛错
|
|
|
|
与 add_to_cart 同理:测的是「未启动时不应该静默成功」,不是 CheckoutBlockedError
|
|
这条业务分支(那条分支需要真实 Playwright page,不在离线单测范围)。
|
|
"""
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
with pytest.raises((AttributeError, TypeError)):
|
|
await site.enter_checkout(_make_task())
|
|
|
|
|
|
async def test_add_to_cart_without_start_raises():
|
|
"""没调 start() 就调 add_to_cart,_context 是 None,应抛错"""
|
|
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
|
# require_logged_in 会因为 auth_session=None 抛 AttributeError,比 _context None 更早
|
|
# 这里测的是「未启动时不应该静默成功」
|
|
with pytest.raises((AttributeError, TypeError)):
|
|
await site.add_to_cart(_make_task())
|
|
|
|
|
|
# ---- helper ----
|