b9f43a0ede
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>
344 lines
14 KiB
Python
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")
|