结算流程用真实下单证据修正:新卡代填 iframe vault、订单号正则、登录态判据
- 新卡代填改用真实 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>
This commit is contained in:
+106
-21
@@ -15,13 +15,14 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.shared.errors import CartOperationError, InvalidRequestError
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -249,32 +250,116 @@ def test_site_interactor_construction_does_not_require_playwright():
|
||||
assert site._per_task_state == {}
|
||||
|
||||
|
||||
# ---- 未实现的方法仍然抛 NotImplementedError ----
|
||||
# ---- monitor 仍未实现,抛 NotImplementedError ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args",
|
||||
[
|
||||
("enter_checkout", (_make_task(),)),
|
||||
("submit_order", (_make_task(),)),
|
||||
("pay", (_make_task(), "ord-1")),
|
||||
("monitor", (_make_task(), "ord-1")),
|
||||
],
|
||||
)
|
||||
async def test_unimplemented_methods_raise(method: str, args: tuple):
|
||||
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
||||
fn = getattr(site, method)
|
||||
with pytest.raises(NotImplementedError):
|
||||
await fn(*args)
|
||||
|
||||
|
||||
async def test_parse_checkout_unimplemented():
|
||||
async def test_monitor_unimplemented():
|
||||
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
||||
with pytest.raises(NotImplementedError):
|
||||
await site.parse_checkout("<html></html>")
|
||||
await site.monitor(_make_task(), "ord-1")
|
||||
|
||||
|
||||
# ---- add_to_cart / verify_cart 在没启动 Playwright 时应失败 ----
|
||||
# ---- 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():
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.shared.errors import OrderGuardError
|
||||
from app.shared.errors import CheckoutBlockedError, OrderGuardError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker import verify
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
@@ -231,18 +231,27 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
|
||||
async def test_unimplemented_site_interaction_becomes_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB
|
||||
):
|
||||
"""SiteInteractor 默认 enter_checkout 抛 NotImplementedError → runner 转 needs_human
|
||||
"""站点交互抛 NotImplementedError → runner 转 needs_human
|
||||
|
||||
add_to_cart 与 verify_cart 已实现(但站点未启动时会从 Playwright 调用失败,
|
||||
测试里先替换为 noop 让流程跑到 enter_checkout)。
|
||||
add_to_cart / verify_cart / enter_checkout / parse_checkout / submit_order /
|
||||
pay 现在均已实现(monitor 仍未实现),这里用桩显式模拟「某一步没实现」,
|
||||
覆盖 runner._execute_with_renewal 里 NotImplementedError → needs_human 的分支,
|
||||
与具体哪个方法真的未实现解耦。
|
||||
"""
|
||||
|
||||
# 让已实现的两步 noop,触发未实现的 enter_checkout
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
|
||||
async def _unimplemented(html): # noqa: ANN001
|
||||
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
|
||||
|
||||
runner._site.add_to_cart = _noop # type: ignore[assignment]
|
||||
runner._site.verify_cart = _noop # type: ignore[assignment]
|
||||
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
|
||||
runner._site.parse_checkout = _unimplemented # type: ignore[assignment]
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
@@ -252,6 +261,36 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
|
||||
assert "未实现" in terminal["detail"]
|
||||
|
||||
|
||||
# ---- 结算被站点风控拦截 → needs_human(规格 §10.1)----
|
||||
|
||||
|
||||
async def test_checkout_blocked_becomes_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB
|
||||
):
|
||||
"""enter_checkout 抛 CheckoutBlockedError(session upgrade 被拦截)→ 转 needs_human
|
||||
|
||||
与普通 AppError(转 FAILED)区分开:这类阻断是站点主动要求人工验证,
|
||||
不是本服务的失败,需要人工用有头浏览器接管当前登录态完成验证。
|
||||
"""
|
||||
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _blocked(task): # noqa: ANN001
|
||||
raise CheckoutBlockedError("session upgrade 提交密码后 30s 内 URL 未变化,判定被站点风控拦截")
|
||||
|
||||
runner._site.add_to_cart = _noop # type: ignore[assignment]
|
||||
runner._site.verify_cart = _noop # type: ignore[assignment]
|
||||
runner._site.enter_checkout = _blocked # type: ignore[assignment]
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
assert "风控" in terminal["detail"]
|
||||
|
||||
|
||||
# ---- ラクマ 不在交易范围 → needs_human ----
|
||||
|
||||
|
||||
@@ -372,13 +411,21 @@ async def test_evidence_files_exist_before_each_report(
|
||||
|
||||
runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined]
|
||||
|
||||
# 让 add_to_cart / verify_cart 正常,enter_checkout 抛 NotImplemented,
|
||||
# 触发前两步写证据 + report,到第三步转 needs_human
|
||||
# 让 add_to_cart / verify_cart / enter_checkout 正常,parse_checkout 抛
|
||||
# NotImplemented(模拟某一步没实现),触发前几步写证据 + report,最终转 needs_human
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
|
||||
async def _unimplemented(html): # noqa: ANN001
|
||||
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
|
||||
|
||||
runner._site.add_to_cart = _noop # type: ignore[assignment]
|
||||
runner._site.verify_cart = _noop # type: ignore[assignment]
|
||||
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
|
||||
runner._site.parse_checkout = _unimplemented # type: ignore[assignment]
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user