"""Persistence contracts for reliability monitoring data.""" import importlib from pathlib import Path import pytest from sqlalchemy import Column, Index, Integer, Table, inspect, text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine import app.models as models from app.config import Settings from app.models.device import Base @pytest.fixture async def db_session() -> AsyncSession: """Provide an isolated database containing the current model metadata.""" engine = create_async_engine("sqlite+aiosqlite:///:memory:") try: async with engine.begin() as connection: await connection.run_sync(Base.metadata.create_all) async with AsyncSession(engine, expire_on_commit=False) as session: yield session finally: await engine.dispose() async def test_probe_record_stores_packet_summary(db_session: AsyncSession): """A probe aggregate retains loss and latency needed by the state machine.""" record = models.PingRecord( device_id=1, is_alive=True, response_time_ms=12.5, round_num=1, sent_count=3, received_count=2, packet_loss_percent=33.33, average_rtt_ms=12.5, is_valid=True, ) db_session.add(record) await db_session.commit() assert record.packet_loss_percent == 33.33 assert record.received_count == 2 async def test_outbox_defaults_to_pending(db_session: AsyncSession): """New notifications stay eligible for delivery until the dispatcher claims them.""" assert hasattr(models, "NotificationOutbox") assert hasattr(models, "NotificationStatus") item = models.NotificationOutbox( alert_event_id=1, message_content="masked summary", ) db_session.add(item) await db_session.commit() assert item.status == models.NotificationStatus.pending def test_device_policy_prefers_explicit_device_overrides(): """A device-specific threshold supersedes only the configured global default.""" device = models.Device( probe_packets_per_round=5, degraded_loss_percent=None, ) policy = models.DeviceMonitoringPolicy.from_device(device, Settings()) assert policy.probe_packets_per_round == 5 assert policy.degraded_loss_percent == 20.0 def test_device_policy_uses_global_default_for_invalid_persisted_override(): """An invalid stored override cannot weaken a device's safe probe policy.""" device = models.Device( probe_packets_per_round=0, degraded_loss_percent=101.0, ) policy = models.DeviceMonitoringPolicy.from_device(device, Settings()) assert policy.probe_packets_per_round == 3 assert policy.degraded_loss_percent == 20.0 async def test_migration_is_idempotent_and_records_revision(tmp_path: Path): """Rerunning the migration preserves the schema and writes one revision marker.""" migration = importlib.import_module( "migrations.versions.20260803_reliability_monitoring" ) assert hasattr(migration, "run_reliability_migration") database_url = f"sqlite+aiosqlite:///{tmp_path / 'monitoring.db'}" engine = create_async_engine(database_url) try: async with engine.begin() as connection: await connection.run_sync(migration.run_reliability_migration) await connection.run_sync(Base.metadata.create_all) async with engine.begin() as connection: await connection.run_sync(migration.run_reliability_migration) revision_count = await connection.scalar( text( "SELECT count(*) FROM schema_migrations " "WHERE revision = '20260803_reliability_monitoring'" ) ) assert revision_count == 1 finally: await engine.dispose() async def test_migration_preserves_legacy_rows_and_owns_only_its_schema( tmp_path: Path, ): """The revision upgrades legacy data twice without creating unrelated models.""" migration = importlib.import_module( "migrations.versions.20260803_reliability_monitoring" ) database_url = f"sqlite+aiosqlite:///{tmp_path / 'legacy-monitoring.db'}" engine = create_async_engine(database_url) future_table = Table( "future_unrelated_model", Base.metadata, Column("id", Integer, primary_key=True), ) try: async with engine.begin() as connection: await connection.execute( text( "CREATE TABLE devices (" "id INTEGER PRIMARY KEY, name VARCHAR(128) NOT NULL, " "ip VARCHAR(45) NOT NULL, device_type VARCHAR(16) NOT NULL, " "location VARCHAR(256), project_name VARCHAR(256), " "tags VARCHAR(512), ping_interval INTEGER, " "alert_threshold INTEGER, is_enabled BOOLEAN, " "current_status VARCHAR(16), consecutive_failures INTEGER, " "last_ping_time DATETIME, last_online_time DATETIME, " "last_offline_time DATETIME, created_at DATETIME, " "updated_at DATETIME)" ) ) await connection.execute( text( "CREATE TABLE ping_records (" "id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, " "is_alive BOOLEAN NOT NULL, response_time_ms FLOAT, " "round_num INTEGER NOT NULL, created_at DATETIME)" ) ) await connection.execute( text( "CREATE TABLE alert_events (" "id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, " "alert_type VARCHAR(16) NOT NULL, message VARCHAR(1024), " "start_at DATETIME NOT NULL, end_at DATETIME, " "duration_minutes INTEGER, is_resolved BOOLEAN, " "notification_sent BOOLEAN, acknowledged_at DATETIME, " "created_at DATETIME)" ) ) await connection.execute( text( "INSERT INTO devices (id, name, ip, device_type, current_status) " "VALUES (1, 'legacy-router', '192.0.2.10', 'router', 'online')" ) ) await connection.execute( text( "INSERT INTO ping_records " "(id, device_id, is_alive, response_time_ms, round_num) " "VALUES (2, 1, 1, 8.5, 7)" ) ) await connection.execute( text( "INSERT INTO alert_events " "(id, device_id, alert_type, message, start_at, notification_sent) " "VALUES (3, 1, 'offline', 'legacy event', CURRENT_TIMESTAMP, 1)" ) ) await connection.run_sync(migration.run_reliability_migration) table_names = await connection.run_sync( lambda sync_connection: set( inspect(sync_connection).get_table_names() ) ) assert "future_unrelated_model" not in table_names assert "notification_outbox" in table_names Base.metadata.remove(future_table) await connection.run_sync(Base.metadata.create_all) async with engine.begin() as connection: await connection.run_sync(migration.run_reliability_migration) legacy_device = await connection.execute( text("SELECT name, ip, current_status FROM devices WHERE id = 1") ) legacy_probe = await connection.execute( text( "SELECT device_id, is_alive, response_time_ms, round_num " "FROM ping_records WHERE id = 2" ) ) legacy_alert = await connection.execute( text( "SELECT device_id, alert_type, message, notification_sent " "FROM alert_events WHERE id = 3" ) ) probe_columns, alert_columns, probe_indexes = await connection.run_sync( lambda sync_connection: ( { column["name"]: column for column in inspect(sync_connection).get_columns( "ping_records" ) }, { column["name"] for column in inspect(sync_connection).get_columns( "alert_events" ) }, { index["name"] for index in inspect(sync_connection).get_indexes( "ping_records" ) }, ) ) assert legacy_device.one() == ("legacy-router", "192.0.2.10", "online") assert legacy_probe.one() == (1, 1, 8.5, 7) assert legacy_alert.one() == (1, "offline", "legacy event", 1) assert probe_columns["is_valid"]["nullable"] is False assert { "sent_count", "received_count", "packet_loss_percent", "average_rtt_ms", "failure_reason", }.issubset(probe_columns) assert "related_event_id" in alert_columns assert "idx_ping_records_device_created_at" in probe_indexes finally: if future_table in Base.metadata.tables.values(): Base.metadata.remove(future_table) await engine.dispose() async def test_migration_does_not_create_a_future_ping_record_index( tmp_path: Path, ): """Historical migration DDL stays limited to its named composite index.""" migration = importlib.import_module( "migrations.versions.20260803_reliability_monitoring" ) future_index = Index( "idx_future_ping_record_round_num", models.PingRecord.__table__.c.round_num, ) engine = create_async_engine( f"sqlite+aiosqlite:///{tmp_path / 'future-index.db'}" ) try: async with engine.begin() as connection: await connection.execute( text( "CREATE TABLE ping_records (" "id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, " "is_alive BOOLEAN NOT NULL, response_time_ms FLOAT, " "round_num INTEGER NOT NULL, created_at DATETIME)" ) ) await connection.run_sync(migration.run_reliability_migration) index_names = await connection.run_sync( lambda sync_connection: { index["name"] for index in inspect(sync_connection).get_indexes( "ping_records" ) } ) assert "idx_ping_records_device_created_at" in index_names assert "idx_future_ping_record_round_num" not in index_names finally: models.PingRecord.__table__.indexes.remove(future_index) await engine.dispose()