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