新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表, 按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。 NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到 needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。
189 lines
6.7 KiB
Python
189 lines
6.7 KiB
Python
"""verify.verify_on_site 单元测试
|
|
|
|
用桩 gateway(只需要 get_task)和桩 site(只需要 list_recent_orders)跑通全部
|
|
分支——不涉及 Playwright/httpx,纯逻辑测试。核对链路本身(intent.item_url →
|
|
查任务创建时间 → 拉订单列表 → 按商品 URL 比对)2026-08-13 用真实账号验证过
|
|
数据结构(见 test_site_interact.py 里 _parse_order_list 那组测试),但当时账号
|
|
只有 1 笔订单,这里 NOT_ORDERED / 多笔命中 / 查询失败这几个分支只覆盖逻辑本身,
|
|
没有被真实多单数据跑过。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.shared.errors import AppError
|
|
from app.trading.worker import verify
|
|
from app.trading.worker.models import LeaseTask
|
|
from app.trading.worker.site_interact import OrderListEntry, OrderListItem, OrderListWindow
|
|
|
|
|
|
def _make_task(*, item_url: str | None = "https://item.rakuten.co.jp/shop/x/") -> LeaseTask:
|
|
intent: dict[str, Any] = {}
|
|
if item_url is not None:
|
|
intent["item_url"] = item_url
|
|
return LeaseTask(task_id="t1", site="rakuten", intent=intent, lease_count=2)
|
|
|
|
|
|
@dataclass
|
|
class FakeGateway:
|
|
created_at: str | None = "2026-08-01T00:00:00Z"
|
|
fail_with: Exception | None = None
|
|
calls: list[str] = field(default_factory=list)
|
|
|
|
async def get_task(self, task_id: str) -> dict[str, Any]:
|
|
self.calls.append(task_id)
|
|
if self.fail_with is not None:
|
|
raise self.fail_with
|
|
return {"task_id": task_id, "created_at": self.created_at}
|
|
|
|
|
|
@dataclass
|
|
class FakeSite:
|
|
window: OrderListWindow | None = None
|
|
fail_with: Exception | None = None
|
|
received_since: datetime | None = None
|
|
|
|
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
|
|
self.received_since = since
|
|
if self.fail_with is not None:
|
|
raise self.fail_with
|
|
assert self.window is not None
|
|
return self.window
|
|
|
|
|
|
def _entry(order_number: str, item_url: str) -> OrderListEntry:
|
|
return OrderListEntry(
|
|
order_number=order_number,
|
|
order_date="2026-08-10T00:00:00Z",
|
|
items=[OrderListItem(item_url=item_url)],
|
|
)
|
|
|
|
|
|
# ---- intent 缺 item_url:不查任何东西,直接 unknown ----
|
|
|
|
|
|
async def test_missing_item_url_returns_unknown_without_calling_gateway():
|
|
gateway = FakeGateway()
|
|
site = FakeSite()
|
|
result = await verify.verify_on_site(_make_task(item_url=None), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
assert gateway.calls == []
|
|
|
|
|
|
# ---- 查任务创建时间失败/不可解析 ----
|
|
|
|
|
|
async def test_get_task_failure_returns_unknown():
|
|
gateway = FakeGateway(fail_with=AppError(message="网关挂了", code="X", err_code=9999))
|
|
site = FakeSite()
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
assert "网关挂了" in result.detail
|
|
|
|
|
|
async def test_unparseable_created_at_returns_unknown():
|
|
gateway = FakeGateway(created_at="不是日期")
|
|
site = FakeSite()
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
|
|
|
|
# ---- 站点订单列表查询失败 ----
|
|
|
|
|
|
async def test_site_list_failure_returns_unknown():
|
|
gateway = FakeGateway()
|
|
site = FakeSite(fail_with=RuntimeError("Playwright 挂了"))
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
assert "Playwright 挂了" in result.detail
|
|
|
|
|
|
# ---- 命中 1 笔:已下单(2026-08-13 真实账号验证过的分支)----
|
|
|
|
|
|
async def test_exact_one_match_returns_already_ordered():
|
|
gateway = FakeGateway()
|
|
site = FakeSite(
|
|
window=OrderListWindow(
|
|
entries=[_entry("306087-20260813-0863947697", "https://item.rakuten.co.jp/shop/x/")],
|
|
window_fully_covered=True,
|
|
)
|
|
)
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
|
|
assert result.site_order_id == "306087-20260813-0863947697"
|
|
|
|
|
|
async def test_match_ignores_query_string_difference():
|
|
"""订单列表的 itemUrl 带 ?variantId=...,intent.item_url 通常不带——只比路径"""
|
|
gateway = FakeGateway()
|
|
site = FakeSite(
|
|
window=OrderListWindow(
|
|
entries=[_entry("o1", "https://item.rakuten.co.jp/shop/x/?variantId=abc-1")],
|
|
window_fully_covered=True,
|
|
)
|
|
)
|
|
result = await verify.verify_on_site(
|
|
_make_task(item_url="https://item.rakuten.co.jp/shop/x/"), gateway=gateway, site=site
|
|
)
|
|
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
|
|
|
|
|
|
# ---- 命中 0 笔且窗口确认覆盖完:未下单 ----
|
|
|
|
|
|
async def test_no_match_and_fully_covered_returns_not_ordered():
|
|
gateway = FakeGateway()
|
|
site = FakeSite(
|
|
window=OrderListWindow(
|
|
entries=[_entry("o1", "https://item.rakuten.co.jp/other-shop/y/")],
|
|
window_fully_covered=True,
|
|
)
|
|
)
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.NOT_ORDERED
|
|
|
|
|
|
# ---- 命中 0 笔但窗口没确认覆盖完:不能猜没下单 ----
|
|
|
|
|
|
async def test_no_match_and_not_fully_covered_returns_unknown():
|
|
gateway = FakeGateway()
|
|
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=False))
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
|
|
|
|
# ---- 命中多笔:无法唯一确定 ----
|
|
|
|
|
|
async def test_multiple_matches_returns_unknown():
|
|
gateway = FakeGateway()
|
|
site = FakeSite(
|
|
window=OrderListWindow(
|
|
entries=[
|
|
_entry("o1", "https://item.rakuten.co.jp/shop/x/"),
|
|
_entry("o2", "https://item.rakuten.co.jp/shop/x/"),
|
|
],
|
|
window_fully_covered=True,
|
|
)
|
|
)
|
|
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
|
assert "o1" in result.detail and "o2" in result.detail
|
|
|
|
|
|
# ---- since 正确传递给 site.list_recent_orders(用任务创建时间,不是别的)----
|
|
|
|
|
|
async def test_since_passed_to_site_matches_task_created_at():
|
|
gateway = FakeGateway(created_at="2026-08-05T12:00:00Z")
|
|
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=True))
|
|
await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
|
assert site.received_since == datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)
|