Files
TieMeasureFlow/src/backend/tests/test_migrations.py
T
Adriano b9f43a0ede Stazioni: camera_url e camera_source, GET /api/stations/by-code/{code} (#72)
Migrazione 020 con le due colonne su stations, campi in modello, schemi e
servizio (validatore http(s) come vision_worker_url), nuova route by-code
dichiarata prima di /{station_id}. Test di migrazione su e giu e di API.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:50:14 +02:00

344 lines
14 KiB
Python

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