Files
live-hub-py/tests/test_account_binding.py
T
yml2213 4c1fe0c84f fix(account): 补上电竞绑定映射漏掉的 game_not_bound(连生产库才发现的)
把生产库克隆到本地做部署演练时,拉了三列绑定状态的**真实取值全集**,发现
`esports_bind_status` 里存在 `game_not_bound`(生产 26 行),而我的映射表里没有它
—— 会被归成 `unknown`,可它明明在说「没绑定」。

第一版映射只覆盖了文档里举的几个例子。**教训:映射表必须拿真实取值全集核对。**

修法:Python 映射与迁移 `0044` 的 CASE 同步补上 `game_not_bound → unbound`
(两者有逐字比对用例,不会分叉),并新增一条用**生产实测取值全集**驱动的用例
(17 + 11 + 9 个取值),以后往这三列加新取值时会被提醒确认语义,
而不是默默变成 `unknown`。

已在生产克隆库上验证:电竞列 unbound 从 0 变成 26(正是那批)。
2026-10-04 17:49:02 +08:00

459 lines
18 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.
"""S6:角色绑定状态三列拆解。
守住四件事:
1. **枚举收口**:三个绑定状态列只能有 `unknown/unbound/pending/bound` 四个值,
传旧自由文本直接报错(否则垃圾又会被写回去);
2. **不猜**:旧自由文本 → 新枚举只映射**明确陈述了绑定关系**的取值,
其余一律 `unknown`(`bind_status` 是覆盖式的,「最后一件事是查询」不代表没绑定);
3. **迁移与运行时同规则**:迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 一致;
4. **写入收口**:源码里不允许再出现 `account.bind_status = "..."` 这类自由文本写入
(这正是该列烂掉的方式)。
"""
from datetime import UTC, datetime
from typing import ClassVar
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account
from web.backend.services import account_binding as binding
NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC)
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
def teardown_method(self):
self.session.close()
def account(self, **kwargs) -> Account:
acc = Account(
username="bind-acc",
password="p",
email="b@e.com",
email_password="ep",
**kwargs,
)
self.session.add(acc)
self.session.commit()
return acc
class TestEnum(_Base):
def test_only_four_states(self):
assert set(binding.ALL_BIND_STATES) == {
"unknown",
"unbound",
"pending",
"bound",
}
def test_labels_cover_every_state(self):
assert set(binding.BIND_LABELS) == set(binding.ALL_BIND_STATES)
def test_set_bind_status_writes_and_reports_change(self):
acc = self.account()
assert (
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
is True
)
assert acc.role_bind_status == "bound"
# 幂等:同值重复设置不算变化
assert (
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
is False
)
assert (
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_UNBOUND)
is True
)
@pytest.mark.parametrize(
"legacy",
[
"bind_confirmed", # 旧自由文本
"game_queried",
"elite_opened",
"points_queried",
"",
"BOUND",
],
)
def test_set_bind_status_rejects_free_text(self, legacy):
"""传旧自由文本必须**报错**,不能静默写进去 —— 那是垃圾场重建的路径。"""
acc = self.account()
with pytest.raises(ValueError):
binding.set_bind_status(acc, binding.SCOPE_ELITE, legacy)
assert acc.role_bind_status == "unknown"
def test_each_scope_maps_to_its_own_column(self):
acc = self.account()
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
binding.set_bind_status(acc, binding.SCOPE_ESPORTS, binding.BIND_PENDING)
binding.set_bind_status(acc, binding.SCOPE_PEACE, binding.BIND_UNBOUND)
assert (
acc.role_bind_status,
acc.esports_bind_status,
acc.xpd_bind_status,
) == ("bound", "pending", "unbound")
# 三列互不覆盖(旧设计里它们会互相盖掉)
assert binding.get_bind_status(acc, binding.SCOPE_ELITE) == "bound"
def test_unknown_scope_is_rejected(self):
acc = self.account()
with pytest.raises(ValueError):
binding.set_bind_status(acc, "nope", binding.BIND_BOUND)
def test_bind_payload_carries_state_and_label(self):
acc = self.account()
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
payload = binding.bind_payload(acc)
assert payload["role_bind_status"] == "bound"
assert payload["role_bind_label"] == "已绑定"
assert payload["esports_bind_status"] == "unknown"
assert payload["xpd_bind_label"] == "未知"
assert set(payload) == {
f"{binding.SCOPE_PAYLOAD_PREFIX[scope]}_{suffix}"
for scope in binding.ALL_SCOPES
for suffix in ("bind_status", "bind_label")
}
class TestPayloadMatchesSchemas:
"""`bind_payload()` 的字段名必须与 API schema 声明的名字**逐字一致**。
这是被真实 bug 逼出来的:第一版按作用域名拼成了 `elite_bind_status` /
`peace_bind_status`,而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。
Pydantic 拿到不认识的键会**静默用默认值**(或者在有 `**kwargs` 展开时直接报错),
表现是「接口永远返回 unknown」却没有任何报错。
"""
def test_keys_are_declared_by_schemas(self):
from web.backend.schemas import DouyuTaskAccountOut
payload = binding.bind_payload(Account())
declared = set(DouyuTaskAccountOut.model_fields)
missing = set(payload) - declared
assert not missing, f"bind_payload 产出了 schema 不认识的字段:{missing}"
def test_key_prefix_matches_column_name(self):
for scope, column in binding.SCOPE_COLUMNS.items():
prefix = binding.SCOPE_PAYLOAD_PREFIX[scope]
assert column == f"{prefix}_bind_status", (
f"{scope}: 列名 {column} 与载荷前缀 {prefix} 不一致"
)
def test_list_schema_declares_every_payload_field(self):
"""列表接口(AccountOut)也必须有这些字段,否则关键列会是空的。"""
from web.backend.schemas import AccountOut
payload = binding.bind_payload(Account())
declared = set(AccountOut.model_fields)
missing = set(payload) - declared
assert not missing, f"AccountOut 缺少绑定字段:{missing}"
class TestLegacyMapping:
"""只映射明确陈述绑定关系的取值,其余 unknown。"""
@pytest.mark.parametrize(
("scope", "legacy", "expected"),
[
# 明确陈述
(binding.SCOPE_ELITE, "bind_confirmed", "bound"),
(binding.SCOPE_ELITE, "game_not_bound", "unbound"),
(binding.SCOPE_ELITE, "bind_qr_generated", "pending"),
(binding.SCOPE_ESPORTS, "esports_bound", "bound"),
(binding.SCOPE_ESPORTS, "esports_bind_ready", "pending"),
(binding.SCOPE_ESPORTS, "esports_bind_confirming", "pending"),
(binding.SCOPE_ESPORTS, "esports_qr_created", "pending"),
(binding.SCOPE_ESPORTS, "esports_role_switching", "pending"),
(binding.SCOPE_PEACE, "xpd_bound", "bound"),
(binding.SCOPE_PEACE, "xpd_not_bound", "unbound"),
(binding.SCOPE_PEACE, "xpd_bind_qr_ready", "pending"),
# 什么都没说 → unknown(不猜)
(binding.SCOPE_ELITE, "game_queried", "unknown"),
(binding.SCOPE_ELITE, "change_time_queried", "unknown"),
(binding.SCOPE_ELITE, "elite_opened", "unknown"),
(binding.SCOPE_ELITE, "gold_recharged", "unknown"),
(binding.SCOPE_ELITE, "goods_locked", "unknown"),
(binding.SCOPE_ELITE, "csrf_token_ready", "unknown"),
(binding.SCOPE_ESPORTS, "esports_opened", "unknown"),
(binding.SCOPE_ESPORTS, "esports_points_queried", "unknown"),
(binding.SCOPE_PEACE, "xpd_goods_refreshed", "unknown"),
(binding.SCOPE_PEACE, "xpd_balance_queried", "unknown"),
# 空值
(binding.SCOPE_ELITE, "", "unknown"),
(binding.SCOPE_ELITE, None, "unknown"),
],
)
def test_mapping(self, scope, legacy, expected):
assert binding.map_legacy_bind_status(scope, legacy) == expected
def test_every_mapped_target_is_a_valid_state(self):
for scope, table in binding.LEGACY_MAPS.items():
for legacy, state in table.items():
assert state in binding.ALL_BIND_STATES, f"{scope}:{legacy}"
class TestMigrationMatchesRuntime(_Base):
"""迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐字对应。
分叉了不会报错,只会表现为「迁移刚跑完是 A、下一次任务又改成 B」。
这里把迁移里那三段 UPDATE 真跑一遍,与 Python 映射逐条比对。
"""
def _load_migration(self):
import importlib.util
from pathlib import Path
path = (
Path(__file__).resolve().parents[1]
/ "web/backend/migrations/versions/20261004_0044_bind_status_split.py"
)
spec = importlib.util.spec_from_file_location("mig_0044_bind_split", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@staticmethod
def _equals_pairs(sql: str, column: str) -> dict[str, str]:
import re
return dict(re.findall(rf"WHEN {column} = '([a-z_]+)' THEN '([a-z_]+)'", sql))
@staticmethod
def _in_branch(sql: str, column: str) -> tuple[set[str], str]:
"""解析 `WHEN col IN ( ... ) THEN 'x'`(列表跨多行)。"""
import re
match = re.search(
rf"WHEN {column} IN \((.*?)\)\s*THEN '([a-z_]+)'", sql, re.DOTALL
)
if not match:
return set(), ""
values = set(re.findall(r"'([a-z_]+)'", match.group(1)))
return values, match.group(2)
def test_migration_sql_agrees_with_python_maps(self):
"""迁移里的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐条对应。
做法是**解析迁移自己的 SQL 源码**(不是抄一份,也不是造数据跑):
旧取值最长 19 字符(`esports_bind_ready`),而列已收窄成 `String(16)`,
已经塞不进测试库了 —— 恰好说明「收窄必须放在归一化之后」是对的。
"""
import inspect
migration = self._load_migration()
# 精英:全部是 WHEN col = 'x' THEN 'y'
role_sql = inspect.getsource(migration.backfill_role_bind_status)
assert (
self._equals_pairs(role_sql, "bind_status") == binding.LEGACY_ROLE_BIND_MAP
)
# 电竞:等值分支 + 一个 IN 分支(都归 pending)
esports_sql = inspect.getsource(migration.normalise_esports_bind_status)
esports_map = self._equals_pairs(esports_sql, "esports_bind_status")
in_values, in_target = self._in_branch(esports_sql, "esports_bind_status")
assert in_target in binding.ALL_BIND_STATES
for value in in_values:
esports_map[value] = in_target
assert esports_map == binding.LEGACY_ESPORTS_BIND_MAP
# 小店:全部是等值分支
xpd_sql = inspect.getsource(migration.normalise_xpd_bind_status)
assert (
self._equals_pairs(xpd_sql, "xpd_bind_status")
== binding.LEGACY_XPD_BIND_MAP
)
def test_every_sql_branch_target_is_a_valid_state(self):
"""迁移不可能回填出枚举以外的值(否则运行时 get_bind_status 会兜成 unknown)。"""
import inspect
import re
migration = self._load_migration()
for func in (
migration.backfill_role_bind_status,
migration.normalise_esports_bind_status,
migration.normalise_xpd_bind_status,
):
targets = set(re.findall(r"THEN '([a-z_]+)'", inspect.getsource(func)))
assert targets, f"{func.__name__} 里没解析到 THEN 分支"
assert targets <= set(binding.ALL_BIND_STATES), targets
def test_migration_saves_esports_opened_before_normalising(self):
"""`upgrade()` 里必须先救 `esports_opened`、再归一化。
「电竞手册是否已开通」以前只寄生在 `esports_bind_status` 的 `esports_opened`
取值里,归一化会把它抹成 `unknown` —— **顺序反了就静默丢状态**。
"""
import inspect
migration = self._load_migration()
source = inspect.getsource(migration.upgrade)
save_pos = source.index("save_esports_opened")
normalise_pos = source.index("normalise_esports_bind_status")
assert save_pos < normalise_pos, (
"必须先救 esports_opened,再归一化 esports_bind_status"
)
class TestNoFreeTextWriters:
"""源码扫描:不允许再有人往绑定状态列写自由文本。
`bind_status` 就是这么烂掉的 —— 6 个文件、29 处写入、20 多种取值,
状态/动作/查询三种语义混在一个覆盖式列里。
"""
ALLOWED: ClassVar[frozenset[str]] = frozenset(
{
"web/backend/models.py",
"web/backend/services/account_binding.py",
}
)
def test_no_direct_bind_status_assignment(self):
import re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
backend = root / "web" / "backend"
# ⚠️ 两个模式都**要求前面有个点**(属性赋值)。不带点会把关键字参数
# (`bind_status=bind_status,`)也算进来 —— 那是传参,不是写列。
patterns = (
re.compile(
r"\.(role_bind_status|esports_bind_status|xpd_bind_status)\s*=(?!=)"
),
# 旧的垃圾场列:任何人都不能再写
re.compile(r"\.bind_status\s*=(?!=)"),
)
offenders: list[str] = []
for path in backend.rglob("*.py"):
rel = path.relative_to(root).as_posix()
if rel in self.ALLOWED or "/migrations/" in rel or "huya" in rel:
continue
for lineno, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# 过滤掉字典键("bind_status": ...)与比较
if any(p.search(line) for p in patterns):
offenders.append(f"{rel}:{lineno}: {stripped}")
assert not offenders, (
"绑定状态只能由 account_binding.set_bind_status 写;"
"旧 bind_status 列不允许任何写入方:\n" + "\n".join(offenders)
)
class TestRealProductionValuesCovered:
"""用**生产库真实取值全集**核对映射表。
这是被真实数据打脸换来的用例:第一版映射只覆盖了文档里举的例子,
而生产的电竞列里还有 `game_not_bound`(26 行)—— 漏了它会被归成
`unknown`,可它明明在说「没绑定」。
⚠️ 下面的取值列表是从生产库 `GROUP BY` 拉出来的**全集**。以后往这三列加
新取值时,这里应该同步补上;新增取值如果不在映射里,用例会提醒你
确认它到底该归成哪种状态,而不是默默变成 `unknown`。
"""
#: 生产库实测(2026-10-04,15,182 个账号)
PRODUCTION_VALUES: ClassVar[dict[str, list[str | None]]] = {
"elite": [
"goods_exchanged",
"limited_goods_queried",
"gift_donated",
"",
"change_time_queried",
"game_queried",
"bind_confirmed",
"gold_recharged",
"gold_balance_queried",
"points_queried",
"elite_status_queried",
"bind_qr_generated",
"game_not_bound",
"elite_qr_created",
"elite_opened",
"gold_api_order_created",
"gold_qr_created",
],
"esports": [
"",
"esports_gift_donated",
"esports_bound",
"esports_goods_exchanged",
"esports_points_queried",
"esports_bind_ready",
"game_not_bound",
"esports_goods_refreshed",
"esports_qr_created",
"esports_opened",
"esports_bind_confirming",
],
"peace": [
"",
None,
"xpd_purchase_records_queried",
"xpd_bound",
"xpd_balance_queried",
"xpd_goods_exchanged",
"xpd_bind_qr_ready",
"xpd_fragments_queried",
"xpd_goods_refreshed",
],
}
#: 明确陈述了绑定关系、必须映射成非 unknown 的取值
MUST_BE_MEANINGFUL: ClassVar[dict[tuple[str, str], str]] = {
("elite", "bind_confirmed"): "bound",
("elite", "bind_qr_generated"): "pending",
("elite", "game_not_bound"): "unbound",
("esports", "esports_bound"): "bound",
("esports", "esports_bind_ready"): "pending",
("esports", "esports_bind_confirming"): "pending",
("esports", "esports_qr_created"): "pending",
# ← 生产实测发现的那一条
("esports", "game_not_bound"): "unbound",
("peace", "xpd_bound"): "bound",
("peace", "xpd_bind_qr_ready"): "pending",
}
@pytest.mark.parametrize(
("scope", "legacy", "expected"),
[(*key, value) for key, value in MUST_BE_MEANINGFUL.items()],
)
def test_meaningful_values_map_exactly(self, scope, legacy, expected):
assert binding.map_legacy_bind_status(scope, legacy) == expected
def test_every_production_value_maps_to_a_valid_state(self):
for scope, values in self.PRODUCTION_VALUES.items():
for value in values:
state = binding.map_legacy_bind_status(scope, value)
assert state in binding.ALL_BIND_STATES, f"{scope}/{value!r}"
def test_no_production_value_is_silently_unknown_when_it_should_not_be(self):
"""反向检查:该有含义的值不能全落成 unknown。"""
for scope, values in self.PRODUCTION_VALUES.items():
meaningful = {
v for (s, v), _ in self.MUST_BE_MEANINGFUL.items() if s == scope
}
mapped = {
v
for v in values
if binding.map_legacy_bind_status(scope, v) != "unknown"
}
missing = meaningful - mapped
assert not missing, f"{scope} 里这些值被错归成 unknown:{missing}"