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