"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。 链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门 (`_skip_when` / `_depends_on`)必须有逆向用例守着:闸门失效 = 白花钱。 """ from __future__ import annotations from datetime import UTC, datetime from tests.support.db import get_test_engine, make_test_session from web.backend.models import Account, DouyuTask, User from web.backend.services import account_pool as pool from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore NOW = datetime(2026, 10, 7, 16, 0, 0, tzinfo=UTC) class TestBuildSupplyChain: def _candidate(self, points: int, score: int = 1280): return pool.make_candidate( account_id=1, username="a", points=points, score=score ) def test_tier_a_has_only_exchange(self): chain = pool.build_supply_chain(self._candidate(5000), commodity_id="C1") assert [s.task_type for s in chain] == ["exchange_goods"] assert chain[0].payload == {"commodity_id": "C1", "num": 1} def test_tier_b_refreshes_before_spending(self): """链首必须先查开通状态与余额 —— 否则未开通会误充值,旧值会多充钱。""" chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") assert [s.task_type for s in chain][:2] == [ "query_elite_status", "query_gold_balance", ] assert chain[0].payload == {"require_opened": True} def test_tier_b_topup_amount_is_exact_gap(self): # 缺口 980 分 → 98 个令 → 98 元 chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") recharge = [s for s in chain if s.task_type == "create_gold_qr"] assert [s.payload["amount"] for s in recharge] == [98] donate = [s for s in chain if s.task_type == "donate_elite_gift"] assert donate[0].payload["gift_count"] == 98 def test_tier_b_splits_recharge_above_800(self): # 缺口 9000 分 → 900 个令 → 900 元 → 必须拆成 800 + 100 chain = pool.build_supply_chain( self._candidate(0, score=9000), commodity_id="C1" ) recharge = [s for s in chain if s.task_type == "create_gold_qr"] assert [s.payload["amount"] for s in recharge] == [800, 100] def test_recharge_step_skips_when_gold_already_enough(self): chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") recharge = next(s for s in chain if s.task_type == "create_gold_qr") assert recharge.payload["_skip_when"] == { "column": "gold_balance", "op": ">=", "value": 98, } def test_donate_step_skips_when_points_already_enough(self): chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") donate = next(s for s in chain if s.task_type == "donate_elite_gift") assert donate.payload["_skip_when"] == { "column": "points", "op": ">=", "value": 1280, } def test_exchange_is_last(self): chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") assert chain[-1].task_type == "exchange_goods" def test_esports_uses_its_own_task_type(self): chain = pool.build_supply_chain( self._candidate(5000), commodity_id="C1", exchange_task_type="exchange_esports_goods", ) assert chain[-1].task_type == "exchange_esports_goods" class _GateBase: def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="gate-kf", password_hash="h", role="support") self.session.add(self.user) self.session.commit() # `douyu_tasks.account_id` 有外键,任务必须挂在一个真实账号上 anchor = Account( username="gate-anchor", password="p", email="e@e.com", email_password="ep", ) self.session.add(anchor) self.session.commit() self.anchor_id = anchor.id # 只为调用 core 上的两个闸门方法:绕开 __init__(它要日志队列与事件循环) self.runner = DouyuBatchRunnerCore.__new__(DouyuBatchRunnerCore) self.runner.payload = {} def teardown_method(self): self.session.close() def _account(self, **fields) -> Account: account = Account( username=f"gate-{fields.get('points')}", password="p", email="e@e.com", email_password="ep", **fields, ) self.session.add(account) self.session.commit() return account def _task(self, payload: dict) -> DouyuTask: task = DouyuTask( batch_id="b", account_id=self.anchor_id, task_type="create_gold_qr", handbook_scope="elite", status="planned", result={"payload": payload}, created_by=self.user.id, ) self.session.add(task) self.session.commit() return task class TestSkipWhenGate(_GateBase): def test_skips_when_condition_met(self): account = self._account(points=5000, gold_balance=200) task = self._task( {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} ) assert "跳过" in self.runner._skip_reason(account, task) def test_runs_when_condition_not_met(self): account = self._account(points=5000, gold_balance=10) task = self._task( {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} ) assert self.runner._skip_reason(account, task) == "" def test_missing_value_does_not_skip(self): """不知道余额时**不能跳** —— 跳了就等于静默不充值,兑换必然失败。""" account = self._account(points=5000, gold_balance=None) task = self._task( {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} ) assert self.runner._skip_reason(account, task) == "" def test_unknown_column_does_not_skip(self): account = self._account(points=5000) task = self._task( {"_skip_when": {"column": "password", "op": ">=", "value": 1}} ) assert self.runner._skip_reason(account, task) == "" def test_unknown_operator_does_not_skip(self): account = self._account(points=5000) task = self._task({"_skip_when": {"column": "points", "op": "!!", "value": 1}}) assert self.runner._skip_reason(account, task) == "" def test_no_rule_does_not_skip(self): account = self._account(points=5000) assert self.runner._skip_reason(account, self._task({})) == "" class TestDependencyGate(_GateBase): def _previous(self, status: str) -> DouyuTask: task = DouyuTask( batch_id="b", account_id=self.anchor_id, task_type="create_gold_qr", handbook_scope="elite", status=status, created_by=self.user.id, ) self.session.add(task) self.session.commit() return task def test_blocks_when_previous_failed(self): previous = self._previous("failed") task = self._task({"_depends_on": previous.id}) assert "前置任务" in self.runner._dependency_reason(self.session, task) def test_passes_when_previous_succeeded(self): previous = self._previous("success") task = self._task({"_depends_on": previous.id}) assert self.runner._dependency_reason(self.session, task) == "" def test_passes_when_previous_was_skipped(self): """前置被跳过 = 那一步本来就不需要做 → 不该阻塞后面的兑换。""" previous = self._previous("skipped") task = self._task({"_depends_on": previous.id}) assert self.runner._dependency_reason(self.session, task) == "" def test_blocks_when_previous_in_manual_required(self): previous = self._previous("manual_required") task = self._task({"_depends_on": previous.id}) assert "前置任务" in self.runner._dependency_reason(self.session, task) def test_no_dependency_runs(self): assert self.runner._dependency_reason(self.session, self._task({})) == "" class TestManualRequiredKeepsLease(_GateBase): """转人工时**不能把号还回去** —— 客服还在扫码,账号已经挂在这单上了。 链式执行会把后半段标成 `skipped`,那些终态任务同样会触发释放钩子; 所以判据是「**批次里还有 manual_required**」而不是只看当前这条任务的状态。 """ def _lease(self, batch_id: str): account = self._account( points=5000, cookie="PHPSESSID=x", ck_check_status="valid", elite_opened=True, status="active", ) lease = pool.lease_accounts( self.session, holder_user_id=self.user.id, candidates=[ pool.make_candidate( account_id=account.id, username=account.username, points=5000, score=1280, ) ], commodity_id="C1", batch_id=batch_id, )[0] return lease def _task_of( self, batch_id: str, status: str, account_id: int | None = None ) -> DouyuTask: task = DouyuTask( batch_id=batch_id, account_id=account_id or self.anchor_id, task_type="exchange_goods", handbook_scope="elite", status=status, created_by=self.user.id, ) self.session.add(task) self.session.commit() return task def test_lease_kept_while_waiting_for_manual(self): lease = self._lease("batch-manual") self._task_of("batch-manual", "manual_required", lease.account_id) skipped = self._task_of("batch-manual", "skipped", lease.account_id) self.runner._release_pool_lease(self.session, skipped) self.session.rollback() assert self.session.get(type(lease), lease.id).status == "active" def test_lease_released_when_no_manual_waiting(self): lease = self._lease("batch-done") done = self._task_of("batch-done", "success", lease.account_id) self.runner._release_pool_lease(self.session, done) self.session.rollback() assert self.session.get(type(lease), lease.id).status == "released"