"""worker 主循环测试 不启动真实的后台任务,直接构造 WorkerRunner 与桩 gateway client / local_db / evidence store / site,覆盖 §6 主循环的分支: - 已完成的任务再次被领:直接补报,不重新执行 - lease_count > 1(恢复领取):走核对分支,verify unknown → needs_human - 站点交互未实现(SiteInteractor 默认抛 NotImplementedError):转 needs_human - 金额守卫拦截:转 needs_human,站点侧无提交动作 每一步的证据文件在 report 之前就已落盘(§9 第 11 条)也在这里验证。 """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any import pytest from app.shared.errors import OrderGuardError from app.shared.task_state import OrderState, TaskStatus from app.trading.worker import verify from app.trading.worker.evidence import EvidenceStore from app.trading.worker.local_db import LocalDB from app.trading.worker.models import LeaseTask from app.trading.worker.runner import WorkerRunner from app.trading.worker.site_interact import CheckoutSummary, SiteInteractor # ---- 桩:网关客户端 ---- @dataclass class FakeGateway: """记录所有调用,可控返回值的网关桩""" reports: list[dict[str, Any]] = field(default_factory=list) renews: int = 0 fail_report_with: Exception | None = None async def lease( self, worker_id: str, *, wait: int = 30, site: str | None = None ) -> LeaseTask | None: return None # 主循环测试不通过 lease 喂数据,直接调 handle async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]: self.renews += 1 return {"task_id": task_id, "lease_expires_at": "2099-01-01T00:00:00Z"} async def report( self, task_id: str, worker_id: str, *, state: OrderState, payable_yen: int | None = None, pay_deadline: str | None = None, site_order_id: str | None = None, evidence_ref: str | None = None, detail: str = "", terminal: bool = False, terminal_status: TaskStatus | None = None, ) -> dict[str, Any]: if self.fail_report_with is not None: raise self.fail_report_with record = { "task_id": task_id, "worker_id": worker_id, "state": state, "payable_yen": payable_yen, "pay_deadline": pay_deadline, "site_order_id": site_order_id, "evidence_ref": evidence_ref, "detail": detail, "terminal": terminal, "terminal_status": terminal_status, } self.reports.append(record) return {"task_id": task_id, "status": "running", "recorded": True} def last_terminal_report(self) -> dict[str, Any]: for r in reversed(self.reports): if r["terminal"]: return r raise AssertionError("没有终态 report") # ---- fixtures ---- @pytest.fixture async def local_db(tmp_path: Path) -> LocalDB: db = LocalDB(tmp_path / "trading.db") await db.start() yield db await db.close() @pytest.fixture def evidence(tmp_path: Path) -> EvidenceStore: return EvidenceStore(tmp_path / "evidence") @pytest.fixture def site() -> SiteInteractor: """返回**未启动**的 SiteInteractor(不调 start,不依赖 Playwright) 默认 add_to_cart/verify_cart 已实现(但会抛 NotImplementedError 因为没启动), 具体测试可用 monkeypatch.setattr(site, "method", fn) 替换为桩。 """ # 用最小化的 auth_session / settings 占位;测试不会真的调它们 return SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type] @pytest.fixture def runner(tmp_path, local_db, evidence, site) -> WorkerRunner: """构造一个 worker_id=test、网关 URL 已配置的 runner 使用 fake gateway;settings 取真实 Settings 但 worker_id 注入为 test。 """ class _FakeSettings: worker_id = "test" order_max_total_yen = 30000 @property def worker_id_effective(self) -> str: return "test" gateway = FakeGateway() r = WorkerRunner( settings=_FakeSettings(), # type: ignore[arg-type] gateway_client=gateway, # type: ignore[arg-type] local_db=local_db, evidence=evidence, site=site, ) # 把 gateway 桩挂到 runner 上,测试通过它断言调用 r._gateway_for_test = gateway # type: ignore[attr-defined] return r def _make_task( *, task_id: str = "t1", site: str = "rakuten", lease_count: int = 1, known_state: str | None = None, intent: dict | None = None, ) -> LeaseTask: return LeaseTask( task_id=task_id, site=site, intent=intent or {}, lease_expires_at="2099-01-01T00:00:00Z", lease_count=lease_count, known_state=known_state, ) # ---- 已完成的任务:补报而不重新执行(§9 第 9 条)---- async def test_already_finished_task_is_reported_not_re_executed( runner: WorkerRunner, local_db: LocalDB ): """本地已完成 → 直接补报 final_state,不进入 execute""" await local_db.ensure_started("t1", "rakuten", {"k": "v"}) await local_db.mark_finished("t1", OrderState.PAID.value) 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["state"] == OrderState.PAID assert terminal["terminal"] is True # 没有进入 execute:site 不应被调用,但为保险起见,我们只看 # evidence 目录里没有任何步骤文件 evidence_files = list(local_db._db_path.parent.parent.glob("**/*.html")) # noqa # 上面 glob 会扫到 trading.db 之外的所有 .html;这里只关心 evidence 目录 # (runner 走 execute 才会写证据) # ---- lease_count > 1:核对分支(§9 第 8 条)---- async def test_recovery_task_with_unknown_verdict_reports_needs_human( runner: WorkerRunner, local_db: LocalDB, monkeypatch ): """lease_count > 1 + verify 桩返回 unknown → 转 needs_human,不执行""" # verify 桩默认返回 unknown,不用 monkeypatch await runner.handle(_make_task(task_id="t1", lease_count=2)) 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"] async def test_recovery_task_with_already_ordered_verdict_reports_succeeded( runner: WorkerRunner, local_db: LocalDB, monkeypatch ): """lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单""" async def _already_ordered(task): # noqa: ANN001 return verify.VerifyResult( verdict=verify.VerifyVerdict.ALREADY_ORDERED, site_order_id="ord-1", detail="订单列表命中", ) monkeypatch.setattr(verify, "verify_on_site", _already_ordered) await runner.handle(_make_task(task_id="t1", lease_count=2, known_state="ordered")) gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined] terminal = gateway.last_terminal_report() assert terminal["state"] == OrderState.ORDERED assert terminal["site_order_id"] == "ord-1" assert terminal["terminal_status"] == TaskStatus.SUCCEEDED # 本地 DB 也应当被标记为已完成 assert await local_db.has_finished("t1") is True # ---- 站点交互未实现 → needs_human(§10 接口缝)---- async def test_unimplemented_site_interaction_becomes_needs_human( runner: WorkerRunner, local_db: LocalDB ): """SiteInteractor 默认 enter_checkout 抛 NotImplementedError → runner 转 needs_human add_to_cart 与 verify_cart 已实现(但站点未启动时会从 Playwright 调用失败, 测试里先替换为 noop 让流程跑到 enter_checkout)。 """ # 让已实现的两步 noop,触发未实现的 enter_checkout async def _noop(task): # noqa: ANN001 return None runner._site.add_to_cart = _noop # type: ignore[assignment] runner._site.verify_cart = _noop # 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 ---- async def test_rakuma_site_reports_needs_human(runner: WorkerRunner): """交易服务只对乐天;其他站点(含 ラクマ)直接转人工""" await runner.handle(_make_task(task_id="t1", site="rakuma")) gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined] terminal = gateway.last_terminal_report() assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN # ---- 金额守卫:拦截而不提交(§9 第 10 条)---- async def test_amount_guard_blocks_when_payable_exceeds_limit( runner: WorkerRunner, local_db: LocalDB ): """实际应付超过上限 → OrderGuardError → 转 needs_human,站点侧无提交动作 模拟站点交互:前两步(add_to_cart / verify_cart)正常,enter_checkout 返回 html, parse_checkout 返回超额金额。submit_order 永远不应被调用。 """ call_log: list[str] = [] async def _ok(task): # noqa: ANN001 call_log.append(task.task_id + ":step") async def _checkout_html(task): # noqa: ANN001 return "checkout" async def _parse(html: str): return CheckoutSummary(payable_yen=50000) submit_called = False async def _submit(task): # noqa: ANN001 nonlocal submit_called submit_called = True return "ord-1" runner._site.add_to_cart = _ok # type: ignore[assignment] runner._site.verify_cart = _ok # type: ignore[assignment] runner._site.enter_checkout = _checkout_html # type: ignore[assignment] runner._site.parse_checkout = _parse # type: ignore[assignment] runner._site.submit_order = _submit # type: ignore[assignment] # intent.max_total_yen 缺省,回落到 settings.order_max_total_yen=30000, # 实际 50000 > 30000 → 拦截 await runner.handle(_make_task(task_id="t1")) assert submit_called is False # 关键:站点侧无提交动作 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"] async def test_amount_guard_honors_intent_max_total_yen( runner: WorkerRunner, local_db: LocalDB ): """intent 显式给 max_total_yen 时优先于全局上限""" async def _noop(task): # noqa: ANN001 return None async def _checkout_html(task): # noqa: ANN001 return "checkout" async def _parse(html: str): return CheckoutSummary(payable_yen=8000) submit_called = False async def _submit(task): # noqa: ANN001 nonlocal submit_called submit_called = True raise NotImplementedError("不应到这里") # parse 之后的步骤未实现,到这里即说明守卫过了 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 = _parse # type: ignore[assignment] runner._site.submit_order = _submit # type: ignore[assignment] # 全局上限 30000,但 intent 给 5000 → 实际 8000 > 5000 → 拦截 await runner.handle(_make_task(task_id="t1", intent={"max_total_yen": 5000})) assert submit_called is False gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined] terminal = gateway.last_terminal_report() assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN # ---- 证据在 report 之前落盘(§9 第 11 条)---- async def test_evidence_files_exist_before_each_report( runner: WorkerRunner, evidence: EvidenceStore ): """每个 _run_step 调用:动作 → 落证据 → 写本地 → 回报 gateway 用一个 hook 把 report 时机抓出来,断言此时该 step 的 meta.json 已在盘上。 """ seen_evidence_at_report: list[bool] = [] base = evidence.step_dir("t1") real_report = runner._gateway_for_test.report # type: ignore[attr-defined] async def _spy_report(*args, **kwargs): # noqa: ANN001, ANN002, ANN003 # 在 report 触发时检查 evidence_ref 指向的 meta 文件是否已落盘 ev_ref = kwargs.get("evidence_ref") if ev_ref: meta_path = base / (ev_ref.split("/", 1)[-1] + ".meta.json") seen_evidence_at_report.append(meta_path.exists()) return await real_report(*args, **kwargs) runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined] # 让 add_to_cart / verify_cart 正常,enter_checkout 抛 NotImplemented, # 触发前两步写证据 + report,到第三步转 needs_human async def _noop(task): # noqa: ANN001 return None runner._site.add_to_cart = _noop # type: ignore[assignment] runner._site.verify_cart = _noop # type: ignore[assignment] await runner.handle(_make_task(task_id="t1")) # 至少有一个 step 调了 report,且每次 report 之前证据都在 assert seen_evidence_at_report, "应当至少有一次带 evidence_ref 的 report" assert all(seen_evidence_at_report), "某次 report 之前证据文件未落盘"