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