Files
live-hub-py/tests/test_migration_high_value_roundtrip.py
T
yml2213 578e259eea fix(douyu): 加固高价值三态迁移与状态口径
修复迁移中断重跑、旧快照回填覆盖、降级将 unknown 误写成可兑换,以及迁移前 CK 已失效账号未作废旧结论的问题。统一 CK error 的展示与筛选口径,补齐旧快照迁移和兑换兜底反向用例,并同步高价值结论时间语义。同时纳入 .gitignore 更新。
2026-11-06 13:25:45 +08:00

212 lines
8.7 KiB
Python

"""0065 高价值三态迁移的 upgrade/downgrade 往返验证(真实 MySQL)。"""
import json
import secrets
from datetime import UTC, datetime
from urllib.parse import quote_plus
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
import web.backend.database as database_module
from tests.support.db import get_mysql_credentials
def test_0065_upgrade_downgrade_roundtrip():
creds = get_mysql_credentials()
mig_db = f"douyu_login_mig_rt_{secrets.token_hex(4)}"
mig_url = (
f"mysql+pymysql://{quote_plus(creds['user'])}:{quote_plus(creds['password'])}"
f"@{creds['host']}:{creds['port']}/{mig_db}?charset=utf8mb4"
)
admin_engine = create_engine(
f"mysql+pymysql://{quote_plus(creds['user'])}:{quote_plus(creds['password'])}"
f"@{creds['host']}:{creds['port']}/?charset=utf8mb4"
)
with admin_engine.begin() as conn:
conn.execute(text(f"CREATE DATABASE `{mig_db}` CHARACTER SET utf8mb4"))
original = database_module.DATABASE_URL
try:
database_module.DATABASE_URL = mig_url
cfg = Config("alembic.ini")
cfg.set_main_option("script_location", "web/backend/migrations")
cfg.set_main_option("sqlalchemy.url", mig_url)
# 先在旧版本落真实数据,验证快照回填,而不是只在空库上验 DDL。
command.upgrade(cfg, "20261021_0064")
engine = create_engine(mig_url)
queried_at = datetime(2026, 11, 6, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None)
snapshots = {
"snapshot-status": (
{
"queried_at": "2026-11-06T12:00:00+00:00",
"high_value_goods": [
{"name": "套装-兔兔白日梦", "status": 1},
{"name": "套装-狂战萌兔", "status": 0},
],
},
"valid",
),
"snapshot-no-status": (
{
"queried_at": "2026-11-05T10:00:00+00:00",
"high_value_goods": [
{"name": "套装-兔兔白日梦", "owned": True},
],
},
"valid",
),
"snapshot-invalid": (
{
"queried_at": "2026-11-04T09:00:00+00:00",
"high_value_goods": [
{"name": "套装-兔兔白日梦", "status": 1},
{"name": "套装-狂战萌兔", "status": 0},
],
},
"invalid",
),
"snapshot-missing": (None, None),
}
with engine.begin() as conn:
for username, (snapshot, ck_status) in snapshots.items():
conn.execute(
text(
"INSERT INTO accounts (username, password, email, email_password,"
" cookie, ck_check_status, overview_snapshot, overview_queried_at,"
" high_value_owned)"
" VALUES (:u, 'p', 'e@x.com', 'p', '', :ck, :snapshot, :queried,"
" :owned)"
),
{
"u": username,
"ck": ck_status or "",
"snapshot": json.dumps(snapshot)
if snapshot is not None
else None,
"queried": queried_at if snapshot is not None else None,
"owned": json.dumps([]) if snapshot is not None else None,
},
)
command.upgrade(cfg, "head")
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("accounts")}
assert "high_value_bairimeng_status" in cols
assert "high_value_mengtu_status" in cols
assert "high_value_checked_at" in cols
assert "high_value_owned" not in cols
assert "ix_accounts_high_value_checked_at" in {
i["name"] for i in insp.get_indexes("accounts")
}
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT username, high_value_bairimeng_status,"
" high_value_mengtu_status, high_value_checked_at"
" FROM accounts ORDER BY username"
)
).fetchall()
by_name = {row[0]: row for row in rows}
assert by_name["snapshot-status"][1:3] == ("owned", "available")
assert by_name["snapshot-status"][3] == queried_at
assert by_name["snapshot-no-status"][1:3] == ("unknown", "unknown")
assert by_name["snapshot-no-status"][3] == datetime(
2026, 11, 5, 10, 0, 0, tzinfo=UTC
).replace(tzinfo=None)
assert by_name["snapshot-invalid"][1:3] == ("unknown", "unknown")
assert by_name["snapshot-invalid"][3] == datetime(
2026, 11, 4, 9, 0, 0, tzinfo=UTC
).replace(tzinfo=None)
assert by_name["snapshot-missing"][1:3] == (None, None)
assert by_name["snapshot-missing"][3] is None
# 模拟“回填和索引已完成,但删旧列前进程中断”:版本仍停在 0064,
# 旧列还在。重跑必须能成功,且不能覆盖已经形成的新结论。
with engine.begin() as conn:
conn.execute(
text("ALTER TABLE accounts ADD COLUMN high_value_owned JSON NULL")
)
conn.execute(
text(
"UPDATE accounts SET high_value_bairimeng_status='available',"
" high_value_mengtu_status='unknown', high_value_checked_at=:now"
" WHERE username='snapshot-status'"
),
{
"now": datetime(2026, 11, 6, 13, 0, 0, tzinfo=UTC).replace(
tzinfo=None
)
},
)
conn.execute(text("UPDATE alembic_version SET version_num='20261021_0064'"))
command.upgrade(cfg, "head")
with engine.connect() as conn:
resumed = conn.execute(
text(
"SELECT high_value_bairimeng_status, high_value_mengtu_status"
" FROM accounts WHERE username='snapshot-status'"
)
).one()
assert resumed == ("available", "unknown")
# 降级无法表达 unknown;必须留 NULL,不能生成 [] 让旧版本误判为可兑换。
with engine.begin() as conn:
for index, (b, m) in enumerate(
[
("available", "available"),
("owned", "available"),
("owned", "owned"),
]
):
conn.execute(
text(
"INSERT INTO accounts (username, password, email, email_password,"
" cookie, overview_queried_at, high_value_bairimeng_status,"
" high_value_mengtu_status)"
" VALUES (:u, 'p', 'e@x.com', 'p', '', :q, :b, :m)"
),
{"u": f"downgrade-{index}", "q": queried_at, "b": b, "m": m},
)
command.downgrade(cfg, "20261021_0064")
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("accounts")}
assert "high_value_owned" in cols
assert "high_value_bairimeng_status" not in cols
assert "ix_accounts_high_value_checked_at" not in {
i["name"] for i in insp.get_indexes("accounts")
}
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT username, high_value_owned FROM accounts ORDER BY username"
)
).fetchall()
old_values = {
name: json.loads(value) if isinstance(value, str) else value
for name, value in rows
}
assert old_values["snapshot-status"] is None
assert old_values["snapshot-no-status"] is None
assert old_values["snapshot-invalid"] is None
assert old_values["snapshot-missing"] is None
assert old_values["downgrade-0"] == []
assert old_values["downgrade-1"] == ["套装-兔兔白日梦"]
assert old_values["downgrade-2"] == ["套装-兔兔白日梦", "套装-狂战萌兔"]
command.upgrade(cfg, "head")
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("accounts")}
assert "high_value_owned" not in cols
assert "high_value_checked_at" in cols
finally:
database_module.DATABASE_URL = original
with admin_engine.begin() as conn:
conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`"))