Files
PingWatch/backend/tests/test_fping_runner.py
T

323 lines
8.9 KiB
Python

"""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