252 lines
9.6 KiB
Python
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()
|