"""Tests for the Alembic migration chain, and specifically for 000_base_schema.py (issue #6): a genuinely empty database has no starting point before it, since 001-013 all assume tables 001 was written against already exist. These run the real `alembic upgrade head` (via `alembic.command`), not the async app engine: `migrations/env.py` builds its own engine from `settings.database_url`, so `Settings.database_url` is patched at the class level (a plain instance monkeypatch cannot override a `@property`) to point at a throwaway SQLite file for the duration of each test. """ from pathlib import Path import pytest from alembic import command from alembic.operations import Operations from alembic.runtime.migration import MigrationContext from alembic.config import Config import sqlalchemy as sa from sqlalchemy import create_engine, inspect as sa_inspect, text from src.backend.config import Settings from src.backend.database import Base _ALEMBIC_INI = ( Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini" ) @pytest.fixture def sqlite_database_url(tmp_path, monkeypatch): """Point settings.database_url (and therefore migrations/env.py) at a throwaway SQLite file, and return its async URL.""" db_path = tmp_path / "migration_test.db" async_url = f"sqlite+aiosqlite:///{db_path}" monkeypatch.setattr(Settings, "database_url", property(lambda self: async_url)) return db_path, async_url def _alembic_config() -> Config: return Config(str(_ALEMBIC_INI)) def _inspect(db_path: Path): engine = create_engine(f"sqlite:///{db_path}") try: return sa_inspect(engine) finally: engine.dispose() class TestUpgradeHeadFromEmpty: """Full chain on a genuinely empty SQLite database (#6).""" def test_upgrade_head_succeeds_on_empty_database(self, sqlite_database_url): db_path, _ = sqlite_database_url command.upgrade(_alembic_config(), "head") # must not raise insp = _inspect(db_path) assert set(Base.metadata.tables) <= set(insp.get_table_names()) def test_tables_and_columns_match_base_metadata(self, sqlite_database_url): """Every table and column ORM models declare must exist after `upgrade head` - the exact assertion the analysis asks for.""" db_path, _ = sqlite_database_url command.upgrade(_alembic_config(), "head") insp = _inspect(db_path) db_tables = set(insp.get_table_names()) for table_name, table in Base.metadata.tables.items(): assert table_name in db_tables, f"missing table {table_name}" db_columns = {c["name"] for c in insp.get_columns(table_name)} model_columns = {c.name for c in table.columns} missing = model_columns - db_columns assert not missing, f"{table_name} missing columns: {missing}" class TestBaseMigrationNoopsOnExistingSchema: """000_base_schema.py does nothing when `recipes` already exists (#6).""" def test_upgrade_head_noops_000_when_pre_001_schema_already_there( self, sqlite_database_url ): db_path, _ = sqlite_database_url cfg = _alembic_config() # Build the pre-001 schema and record it as migrated. command.upgrade(cfg, "000_base") insp = _inspect(db_path) assert "recipes" in insp.get_table_names() assert "image_path" not in { c["name"] for c in insp.get_columns("recipes") } # Erase the tracking: alembic now believes nothing has run, exactly # like a database that was born from Base.metadata.create_all() and # never stamped. Running 000's upgrade() again on a database that # already has `recipes` must be a no-op, not a "table already # exists" failure. sync_engine = create_engine(f"sqlite:///{db_path}") with sync_engine.begin() as conn: conn.execute(text("DELETE FROM alembic_version")) sync_engine.dispose() command.upgrade(cfg, "head") # must not raise (no duplicate table) insp = _inspect(db_path) assert set(Base.metadata.tables) <= set(insp.get_table_names()) assert "image_path" in { c["name"] for c in insp.get_columns("recipes") } class TestApiKeyHashMigration: """015_api_key_hash: users.api_key -> api_keys table, and back (#41).""" def test_015_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() command.upgrade(cfg, "head") insp = _inspect(db_path) assert "api_keys" in insp.get_table_names() assert "api_key" not in {c["name"] for c in insp.get_columns("users")} assert {c["name"] for c in insp.get_columns("api_keys")} >= { "user_id", "key_hash", "created_at", "expires_at", "last_used_at", } command.downgrade(cfg, "014_vision_fk_ondelete") insp = _inspect(db_path) assert "api_keys" not in insp.get_table_names() assert "api_key" in {c["name"] for c in insp.get_columns("users")} command.upgrade(cfg, "head") # and up again assert "api_keys" in _inspect(db_path).get_table_names() def test_015_on_create_all_schema(self, sqlite_database_url): """DB nato da create_all: users.api_key ha solo l'indice unique ix_users_api_key, nessun vincolo uq_users_api_key (#71).""" db_path, _ = sqlite_database_url cfg = _alembic_config() command.upgrade(cfg, "014_vision_fk_ondelete") engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: ctx = MigrationContext.configure(conn) ops = Operations(ctx) with ops.batch_alter_table("users") as batch: batch.drop_index("ix_users_api_key") batch.drop_constraint("uq_users_api_key", type_="unique") ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True) engine.dispose() insp = _inspect(db_path) assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in { u["name"] for u in insp.get_unique_constraints("users") } command.upgrade(cfg, "head") # non deve sollevare insp = _inspect(db_path) assert "api_keys" in insp.get_table_names() assert "api_key" not in {c["name"] for c in insp.get_columns("users")} def test_015_resumes_after_partial_run(self, sqlite_database_url): """015 a meta' (MySQL, DDL non transazionale): api_keys creata e ix_users_api_key tolto, alembic_version ancora 014 (#71).""" db_path, _ = sqlite_database_url cfg = _alembic_config() command.upgrade(cfg, "014_vision_fk_ondelete") engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: ops = Operations(MigrationContext.configure(conn)) ops.create_table( "api_keys", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("key_hash", sa.String(64), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("expires_at", sa.DateTime(), nullable=False), sa.Column("last_used_at", sa.DateTime(), nullable=True), sa.ForeignKeyConstraint( ["user_id"], ["users.id"], name="fk_api_keys_user_id", ondelete="CASCADE", ), ) ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True) ops.drop_index("ix_users_api_key", table_name="users") engine.dispose() command.upgrade(cfg, "head") # non deve sollevare insp = _inspect(db_path) assert "api_keys" in insp.get_table_names() assert {i["name"] for i in insp.get_indexes("api_keys")} >= { "ix_api_keys_key_hash", "ix_api_keys_user_id", } assert "api_key" not in {c["name"] for c in insp.get_columns("users")} class TestVisionRecipeDirMigration: """016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46).""" def test_016_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() def cols(): return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")} command.upgrade(cfg, "head") assert "vision_recipe_dir" in cols() command.downgrade(cfg, "015_api_key_hash") assert "vision_recipe_dir" not in cols() command.upgrade(cfg, "head") # e di nuovo su assert "vision_recipe_dir" in cols() class TestUserPinMigration: """017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59).""" def test_017_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() new = {"pin_hash", "pin_failed_count", "pin_locked_until"} def cols(): return {c["name"] for c in _inspect(db_path).get_columns("users")} command.upgrade(cfg, "head") assert new <= cols() command.downgrade(cfg, "016_vision_recipe_dir") assert not (new & cols()) command.upgrade(cfg, "head") # e di nuovo su assert new <= cols() _SEED_USER_RECIPE_VERSION = [ "INSERT INTO users (username, password_hash, display_name, roles, is_admin, active," " language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')", "INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)", "INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)" " VALUES (1, 1, 1, 1)", ] class TestTaskLoopResultsMigration: """018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55).""" def test_018_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() def cols(): return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")} command.upgrade(cfg, "head") assert {"in_loop", "at_close", "fields"} <= cols() assert "task_results" in _inspect(db_path).get_table_names() command.downgrade(cfg, "017_user_pin") assert not ({"in_loop", "at_close", "fields"} & cols()) assert "task_results" not in _inspect(db_path).get_table_names() command.upgrade(cfg, "head") # e di nuovo su def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() command.upgrade(cfg, "017_user_pin") engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: for stmt in _SEED_USER_RECIPE_VERSION: conn.execute(text(stmt)) for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")): conn.execute(text( "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" f" VALUES (1, {i}, '{t}', '{t}')")) engine.dispose() command.upgrade(cfg, "head") engine = create_engine(f"sqlite:///{db_path}") with engine.connect() as conn: rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all()) engine.dispose() assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0} def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() command.upgrade(cfg, "head") engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: for stmt in _SEED_USER_RECIPE_VERSION: conn.execute(text(stmt)) conn.execute(text( "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" " VALUES (1, 0, 'check', 'c')")) engine.dispose() with pytest.raises(RuntimeError, match="check"): command.downgrade(cfg, "017_user_pin") class TestStationVisionWorkerMigration: """019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45).""" def test_019_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() def cols(table): return {c["name"] for c in _inspect(db_path).get_columns(table)} command.upgrade(cfg, "head") assert "vision_worker_url" in cols("stations") assert "worker_url" in cols("vision_results") command.downgrade(cfg, "018_task_loop_results") assert "vision_worker_url" not in cols("stations") assert "worker_url" not in cols("vision_results") command.upgrade(cfg, "head") # e di nuovo su assert "vision_worker_url" in cols("stations") assert "worker_url" in cols("vision_results") class TestStationCameraMigration: """020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72).""" def test_020_upgrade_and_downgrade(self, sqlite_database_url): db_path, _ = sqlite_database_url cfg = _alembic_config() def cols(table): return {c["name"] for c in _inspect(db_path).get_columns(table)} command.upgrade(cfg, "head") assert {"camera_url", "camera_source"} <= cols("stations") command.downgrade(cfg, "019_station_vision_worker") assert "camera_url" not in cols("stations") assert "camera_source" not in cols("stations") assert "vision_worker_url" in cols("stations") # la 019 resta command.upgrade(cfg, "head") # e di nuovo su assert {"camera_url", "camera_source"} <= cols("stations")