Files
live-hub-py/tests/test_supply_chain.py
T
yml2213 603fc6eadc feat(douyu-pool): 完善微信扫码支付与任务闭环及常用快捷选择
- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注
- 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换
- 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验
- 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能
- 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
2026-10-07 16:59:43 +08:00

280 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。
链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门
(`_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"