""" 用户名唯一性 Alembic 迁移测试。 """ import importlib import pytest import sqlalchemy as sa from alembic.migration import MigrationContext from alembic.operations import Operations from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.schema import CreateTable from app.db.engine import _register_sqlite_foreign_keys from app.db.models.passkey import PassKey from app.db.models.user import User from app.db.models.userconfig import UserConfig MIGRATION_MODULE = "database.versions.a9d4f2c7e6b1_3_0_18" def _bind_migration(monkeypatch, connection): """ 把用户名迁移绑定到隔离数据库连接。 """ migration = importlib.import_module(MIGRATION_MODULE) monkeypatch.setattr( migration, "op", Operations(MigrationContext.configure(connection)), ) return migration def _legacy_metadata() -> tuple[sa.MetaData, sa.Table, sa.Table]: """ 构造允许重名且带用户外键的旧版最小表结构。 """ metadata = sa.MetaData() users = sa.Table( "user", metadata, sa.Column("id", sa.Integer(), primary_key=True), sa.Column("name", sa.String(), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=True), sa.Index("ix_user_name", "name"), ) passkeys = sa.Table( "passkey", metadata, sa.Column("id", sa.Integer(), primary_key=True), sa.Column( "user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, ), ) return metadata, users, passkeys def _user_rows(connection, users: sa.Table) -> list[dict]: """ 按稳定身份返回用户快照。 """ return list(connection.execute(sa.select(users).order_by(users.c.id)).mappings()) def _indexes(connection) -> dict[str, dict]: """ 返回用户表的命名索引。 """ return {index["name"]: index for index in sa.inspect(connection).get_indexes("user")} def test_user_model_declares_exact_unique_name_index() -> None: """ 当前用户聚合模型必须声明唯一身份和精确级联约束。 """ indexes = {index.name: index for index in User.__table__.indexes} assert "ix_user_name" not in indexes assert indexes["ux_user_name"].unique is True assert tuple(column.name for column in indexes["ux_user_name"].columns) == ("name",) user_config_fk = next(iter(UserConfig.__table__.foreign_keys)) assert user_config_fk.target_fullname == "user.name" assert user_config_fk.onupdate == "CASCADE" assert user_config_fk.ondelete == "CASCADE" assert UserConfig.__table__.c.username.nullable is False assert UserConfig.__table__.c.key.nullable is False constraints = {constraint.name: constraint for constraint in UserConfig.__table__.constraints} assert {column.name for column in constraints["uq_userconfig_username_key"].columns} == {"username", "key"} passkey_fk = next(iter(PassKey.__table__.foreign_keys)) assert passkey_fk.target_fullname == "user.id" assert passkey_fk.ondelete == "CASCADE" def test_sqlite_connection_registration_enables_foreign_keys() -> None: """ 每条宿主 SQLite 连接都必须实际启用外键检查。 """ engine = sa.create_engine("sqlite://") _register_sqlite_foreign_keys(engine) with engine.connect() as connection: assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 1 engine.dispose() def test_user_child_constraints_compile_for_postgresql() -> None: """ 用户从属约束必须生成 PostgreSQL 可执行的级联 DDL。 """ user_config_ddl = str( CreateTable(UserConfig.__table__).compile( dialect=postgresql.dialect(), ) ) passkey_ddl = str( CreateTable(PassKey.__table__).compile( dialect=postgresql.dialect(), ) ) assert ('FOREIGN KEY(username) REFERENCES "user" (name) ON DELETE CASCADE ON UPDATE CASCADE') in user_config_ddl assert ('FOREIGN KEY(user_id) REFERENCES "user" (id) ON DELETE CASCADE') in passkey_ddl def test_user_child_migration_repairs_data_and_cascades(monkeypatch) -> None: """ 迁移清债后配置和 PassKey 不得脱离用户主体,并保持可逆。 """ metadata = sa.MetaData() users = sa.Table( "user", metadata, sa.Column("id", sa.Integer(), primary_key=True), sa.Column("name", sa.String(), nullable=False), sa.Column("is_active", sa.Boolean()), sa.Index("ix_user_name", "name"), ) configs = sa.Table( "userconfig", metadata, sa.Column("id", sa.Integer(), primary_key=True), sa.Column("username", sa.String(), nullable=True), sa.Column("key", sa.String(), nullable=True), sa.Column("value", sa.JSON()), ) passkeys = sa.Table( "passkey", metadata, sa.Column("id", sa.Integer(), primary_key=True), sa.Column( "user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, ), ) engine = sa.create_engine("sqlite://") with engine.connect() as connection: metadata.create_all(connection) connection.execute( users.insert().values( id=1, name="old", is_active=True, ) ) connection.execute( configs.insert(), [ {"id": 1, "username": "old", "key": "theme", "value": "first"}, {"id": 2, "username": "old", "key": "theme", "value": "duplicate"}, {"id": 3, "username": "ghost", "key": "theme", "value": "orphan"}, {"id": 4, "username": None, "key": "theme", "value": "null-user"}, {"id": 5, "username": "old", "key": None, "value": "null-key"}, ], ) connection.execute( passkeys.insert(), [ {"id": 11, "user_id": 1}, {"id": 12, "user_id": 999}, ], ) connection.commit() migration = _bind_migration(monkeypatch, connection) migration.upgrade() connection.commit() migration.upgrade() connection.commit() assert connection.execute(sa.select(configs.c.id, configs.c.username, configs.c.key)).all() == [ (1, "old", "theme") ] assert connection.execute(sa.select(passkeys.c.id)).scalars().all() == [11] inspector = sa.inspect(connection) config_columns = {column["name"]: column for column in inspector.get_columns("userconfig")} assert config_columns["username"]["nullable"] is False assert config_columns["key"]["nullable"] is False config_fk = inspector.get_foreign_keys("userconfig")[0] assert config_fk["options"] == { "ondelete": "CASCADE", "onupdate": "CASCADE", } passkey_fk = inspector.get_foreign_keys("passkey")[0] assert passkey_fk["options"] == {"ondelete": "CASCADE"} unique = {item["name"]: item for item in inspector.get_unique_constraints("userconfig")} assert unique["uq_userconfig_username_key"]["column_names"] == [ "username", "key", ] connection.exec_driver_sql("PRAGMA foreign_keys=ON") connection.execute(users.update().values(name="new")) assert connection.execute(sa.select(configs.c.username)).scalar_one() == "new" with pytest.raises(IntegrityError): with connection.begin_nested(): connection.execute( configs.insert().values( username="new", key="theme", value="duplicate", ) ) with pytest.raises(IntegrityError): with connection.begin_nested(): connection.execute( configs.insert().values( username="new", key=None, value="empty-key", ) ) connection.execute(users.delete()) assert connection.execute(sa.select(configs.c.id)).all() == [] assert connection.execute(sa.select(passkeys.c.id)).all() == [] connection.commit() migration.downgrade() connection.commit() downgraded = sa.inspect(connection) columns = {column["name"]: column for column in downgraded.get_columns("userconfig")} assert columns["username"]["nullable"] is True assert columns["key"]["nullable"] is True assert downgraded.get_foreign_keys("userconfig") == [] assert downgraded.get_foreign_keys("passkey")[0]["options"] == {} engine.dispose() def test_migration_preserves_rows_and_foreign_keys_across_replay( monkeypatch, ) -> None: """ 升级应确定性修复重名并在降级、再升级时保留用户身份。 """ engine = sa.create_engine("sqlite://") metadata, users, passkeys = _legacy_metadata() with engine.begin() as connection: connection.exec_driver_sql("PRAGMA foreign_keys = ON") metadata.create_all(connection) connection.execute( users.insert(), [ {"id": 1, "name": "alice", "is_active": True}, {"id": 2, "name": "alice", "is_active": True}, {"id": 3, "name": "alice", "is_active": None}, { "id": 4, "name": "alice__duplicate_2", "is_active": True, }, {"id": 5, "name": "bob", "is_active": True}, ], ) connection.execute( passkeys.insert(), [ {"id": 11, "user_id": 2}, {"id": 12, "user_id": 3}, ], ) migration = _bind_migration(monkeypatch, connection) migration.upgrade() upgraded_rows = _user_rows(connection, users) migration.upgrade() assert _user_rows(connection, users) == upgraded_rows assert upgraded_rows == [ {"id": 1, "name": "alice", "is_active": True}, { "id": 2, "name": "alice__duplicate_2_1", "is_active": False, }, { "id": 3, "name": "alice__duplicate_3", "is_active": False, }, { "id": 4, "name": "alice__duplicate_2", "is_active": True, }, {"id": 5, "name": "bob", "is_active": True}, ] assert connection.execute(sa.select(passkeys.c.id, passkeys.c.user_id).order_by(passkeys.c.id)).all() == [ (11, 2), (12, 3), ] index = _indexes(connection)["ux_user_name"] assert tuple(index["column_names"]) == ("name",) assert index["unique"] == 1 assert "ix_user_name" not in _indexes(connection) with pytest.raises(IntegrityError): with connection.begin_nested(): connection.execute( users.insert().values( id=6, name="alice", is_active=True, ) ) migration.downgrade() downgraded_rows = _user_rows(connection, users) assert downgraded_rows == upgraded_rows assert "ux_user_name" not in _indexes(connection) assert tuple(_indexes(connection)["ix_user_name"]["column_names"]) == ("name",) connection.execute(users.insert().values(id=6, name="alice", is_active=True)) migration.upgrade() assert _user_rows(connection, users)[-1] == { "id": 6, "name": "alice__duplicate_6", "is_active": False, } assert connection.execute(sa.select(passkeys.c.user_id).order_by(passkeys.c.id)).scalars().all() == [2, 3] assert _indexes(connection)["ux_user_name"]["unique"] == 1 def test_migration_repairs_malformed_canonical_index(monkeypatch) -> None: """ 升级必须替换同名但列或唯一语义错误的部分迁移索引。 """ engine = sa.create_engine("sqlite://") metadata, users, _passkeys = _legacy_metadata() with engine.begin() as connection: metadata.create_all(connection) connection.execute( users.insert(), [{"id": 1, "name": "alice", "is_active": True}], ) connection.exec_driver_sql('CREATE INDEX ux_user_name ON "user" (is_active)') migration = _bind_migration(monkeypatch, connection) migration.upgrade() index = _indexes(connection)["ux_user_name"] assert tuple(index["column_names"]) == ("name",) assert index["unique"] == 1 def test_migration_accepts_fresh_current_schema(monkeypatch) -> None: """ 当前模型已建表时,重复升级不得创建冲突索引。 """ engine = sa.create_engine("sqlite://") with engine.begin() as connection: User.__table__.create(connection) migration = _bind_migration(monkeypatch, connection) migration.upgrade() migration.upgrade() index = _indexes(connection)["ux_user_name"] assert tuple(index["column_names"]) == ("name",) assert index["unique"] == 1