Files
PingWatch/backend/tests/test_models_and_migration.py
T

252 lines
9.6 KiB
Python

"""Persistence contracts for reliability monitoring data."""
import importlib
from pathlib import Path
import pytest
from sqlalchemy import Column, 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()