"""Behavior tests for multi-packet fping execution and persistence.""" import asyncio from datetime import datetime, timedelta import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from app.config import Settings from app.models.device import Base, Device, DeviceTypeEnum from app.models.ping_record import PingRecord from app.services.fping_runner import ( ProbeResult, parse_fping_count_output, run_fping_count, ) from app.services.pinger import Pinger @pytest.fixture async def db_session() -> AsyncSession: """Provide an isolated database for pinger transaction tests.""" 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() def test_parses_loss_and_average_rtt(): """Numeric replies count as received packets and dashes count as loss.""" output = "10.0.0.8 : 12.4 - 12.6\n10.0.0.9 : - - -" result = parse_fping_count_output( output, {"10.0.0.8", "10.0.0.9"}, 3, ) assert result["10.0.0.8"].received_count == 2 assert result["10.0.0.8"].average_rtt_ms == 12.5 assert result["10.0.0.8"].packet_loss_percent == pytest.approx( 33.3333333333 ) assert result["10.0.0.9"].packet_loss_percent == 100.0 def test_marks_missing_and_malformed_expected_output_invalid(): """Incomplete fping output cannot be interpreted as device packet loss.""" output = "10.0.0.8 : 10.2 bad-token -" result = parse_fping_count_output( output, {"10.0.0.8", "10.0.0.9"}, 3, ) assert result["10.0.0.8"].is_valid is False assert result["10.0.0.8"].failure_reason == "malformed fping replies" assert result["10.0.0.9"].is_valid is False assert result["10.0.0.9"].failure_reason == "missing fping output" def test_parses_ipv6_without_confusing_address_colons_for_delimiter(): """An IPv6 address remains the result key when parsing the output delimiter.""" result = parse_fping_count_output( "2001:db8::8 : 1.25 - 1.75", {"2001:db8::8"}, 3, ) assert result["2001:db8::8"].is_valid is True assert result["2001:db8::8"].received_count == 2 assert result["2001:db8::8"].average_rtt_ms == 1.5 async def test_runner_uses_argument_vector_and_parses_quiet_stderr(monkeypatch): """The runner passes IPs as argv entries and reads fping -C quiet output.""" observed = {} class CompletedProcess: returncode = 1 async def communicate(self): return b"", b"10.0.0.8 : 4.2 - 4.4\n" async def fake_create_subprocess_exec(*args, **kwargs): observed["args"] = args observed["kwargs"] = kwargs return CompletedProcess() monkeypatch.setattr( asyncio, "create_subprocess_exec", fake_create_subprocess_exec, ) result = await run_fping_count( ["10.0.0.8"], packets_per_round=3, timeout_ms=800, executable="/test/fping", ) assert observed["args"] == ( "/test/fping", "-C", "3", "-q", "-t", "800", "10.0.0.8", ) assert observed["kwargs"] == { "stdout": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.PIPE, } assert result["10.0.0.8"].received_count == 2 async def test_runner_failure_returns_invalid_result_instead_of_packet_loss( monkeypatch, ): """A process communication failure remains an invalid probe aggregate.""" class BrokenProcess: async def communicate(self): raise RuntimeError("simulated process pipe failure") async def fake_create_subprocess_exec(*args, **kwargs): return BrokenProcess() monkeypatch.setattr( asyncio, "create_subprocess_exec", fake_create_subprocess_exec, ) result = await run_fping_count( ["10.0.0.8"], packets_per_round=3, timeout_ms=800, executable="/test/fping", ) assert result["10.0.0.8"].is_valid is False assert result["10.0.0.8"].failure_reason == ( "fping execution failed: RuntimeError" ) async def test_pinger_persists_one_summary_for_each_enabled_device( db_session, monkeypatch, ): """One monitoring round writes exactly one aggregate per enabled device.""" first = Device( name="edge-a", ip="10.0.0.8", device_type=DeviceTypeEnum.switch, current_status="online", is_enabled=True, ) second = Device( name="edge-b", ip="10.0.0.9", device_type=DeviceTypeEnum.switch, current_status="online", is_enabled=True, ) disabled = Device( name="edge-disabled", ip="10.0.0.10", device_type=DeviceTypeEnum.switch, current_status="online", is_enabled=False, ) db_session.add_all([first, second, disabled]) await db_session.commit() async def fake_run_fping_count( ips, packets_per_round, timeout_ms, executable, ): assert set(ips) == {"10.0.0.8", "10.0.0.9"} return { "10.0.0.8": ProbeResult("10.0.0.8", 3, 2, 8.5, True), "10.0.0.9": ProbeResult("10.0.0.9", 3, 3, 9.5, True), } monkeypatch.setattr( "app.services.pinger.run_fping_count", fake_run_fping_count, ) changes = await Pinger(Settings()).run_one_round(db_session) records = list( ( await db_session.execute( select(PingRecord).order_by(PingRecord.device_id) ) ) .scalars() .all() ) assert changes == [] assert len(records) == 2 assert [record.device_id for record in records] == [first.id, second.id] assert records[0].sent_count == 3 assert records[0].received_count == 2 assert records[0].packet_loss_percent == pytest.approx(33.3333333333) assert records[0].average_rtt_ms == 8.5 assert records[0].is_valid is True async def test_pinger_persists_invalid_probe_without_changing_device_state( db_session, monkeypatch, ): """A runner failure remains observable but cannot mark a device offline.""" device = Device( name="edge-a", ip="10.0.0.8", device_type=DeviceTypeEnum.switch, current_status="online", is_enabled=True, ) db_session.add(device) await db_session.commit() async def fake_run_fping_count(*args, **kwargs): return { device.ip: ProbeResult( device.ip, 3, 0, None, False, "fping executable not found", ) } monkeypatch.setattr( "app.services.pinger.run_fping_count", fake_run_fping_count, ) changes = await Pinger(Settings()).run_one_round(db_session) record = ( await db_session.execute(select(PingRecord).where(PingRecord.device_id == device.id)) ).scalar_one() assert changes == [] assert device.current_status == "online" assert record.is_valid is False assert record.packet_loss_percent is None assert record.failure_reason == "fping executable not found" async def test_pinger_evaluates_recent_valid_summaries_and_returns_change( db_session, monkeypatch, ): """A newly persisted full-loss summary completes the offline streak.""" device = Device( name="edge-a", ip="10.0.0.8", device_type=DeviceTypeEnum.switch, current_status="online", is_enabled=True, offline_consecutive_rounds=2, ) db_session.add(device) await db_session.flush() db_session.add( PingRecord( device_id=device.id, is_alive=False, response_time_ms=None, round_num=1, sent_count=3, received_count=0, packet_loss_percent=100.0, average_rtt_ms=None, is_valid=True, created_at=datetime.now() - timedelta(seconds=30), ) ) await db_session.commit() async def fake_run_fping_count(*args, **kwargs): return { device.ip: ProbeResult( device.ip, 3, 0, None, True, ) } monkeypatch.setattr( "app.services.pinger.run_fping_count", fake_run_fping_count, ) changes = await Pinger(Settings()).run_one_round(db_session) assert len(changes) == 1 assert changes[0].device is device assert changes[0].old_status == "online" assert changes[0].new_status == "offline" assert changes[0].event_type == "offline" assert device.current_status == "offline" assert device.last_offline_time is not None