diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index c07a2063..d8299d67 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -273,7 +273,7 @@ def ensure_views(sql) -> bool: IFNULL(devScan, '') AS devScan, IFNULL(devLogEvents, '') AS devLogEvents, IFNULL(devAlertEvents, '') AS devAlertEvents, - IFNULL(devAlertDown, '') AS devAlertDown, + IFNULL(devAlertDown, 0) AS devAlertDown, IFNULL(devCanSleep, 0) AS devCanSleep, IFNULL(devSkipRepeated, '') AS devSkipRepeated, IFNULL(devLastNotification, '') AS devLastNotification, diff --git a/test/db/test_devices_view.py b/test/db/test_devices_view.py new file mode 100644 index 00000000..60792de7 --- /dev/null +++ b/test/db/test_devices_view.py @@ -0,0 +1,206 @@ +""" +Unit tests for the DevicesView SQL view built by ensure_views(). + +Regression coverage: +- NULL devAlertDown must NOT be treated as != 0 (IFNULL bug: '' vs 0). +- devCanSleep / devIsSleeping suppression within the sleep window. +- Only devices with devAlertDown = 1 AND devPresentLastScan = 0 appear in + the "Device Down" event query. + +Each test uses an isolated in-memory SQLite database so it has no +dependency on the running application or config. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import ( # noqa: E402 + make_db as _make_db, + minutes_ago as _minutes_ago, + insert_device as _insert_device, +) + + +# --------------------------------------------------------------------------- +# Tests: devAlertDown NULL coercion +# --------------------------------------------------------------------------- + +class TestAlertDownNullCoercion: + """ + Guard against the IFNULL(devAlertDown, '') bug. + + When devAlertDown IS NULL and the view uses IFNULL(..., ''), the text value + '' satisfies `!= 0` in SQLite (text > integer), causing spurious down events. + The fix is IFNULL(devAlertDown, 0) so NULL → 0, and 0 != 0 is FALSE. + """ + + def test_null_alert_down_not_in_down_event_query(self): + """A device with NULL devAlertDown must NOT appear in the down-event query.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:01", alert_down=None, present_last_scan=0) + conn.commit() + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devPresentLastScan = 0 + """) + rows = cur.fetchall() + macs = [r["devMac"] for r in rows] + assert "AA:BB:CC:DD:EE:01" not in macs, ( + "Device with NULL devAlertDown must not fire a down event " + "(IFNULL coercion regression)" + ) + + def test_zero_alert_down_not_in_down_event_query(self): + """A device with explicit devAlertDown=0 must NOT appear.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:02", alert_down=0, present_last_scan=0) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:02" not in macs + + def test_one_alert_down_in_down_event_query(self): + """A device with devAlertDown=1 and absent MUST appear in the down-event query.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:03", alert_down=1, present_last_scan=0) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:03" in macs + + def test_online_device_not_in_down_event_query(self): + """An online device (devPresentLastScan=1) should never fire a down event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:04", alert_down=1, present_last_scan=1) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:04" not in macs + + +# --------------------------------------------------------------------------- +# Tests: devIsSleeping suppression +# --------------------------------------------------------------------------- + +class TestIsSleepingSuppression: + """ + When devCanSleep=1 and the device has been absent for less than + NTFPRCS_sleep_time minutes, devIsSleeping must be 1 and the device + must NOT appear in the down-event query. + """ + + def test_sleeping_device_is_marked_sleeping(self): + """devCanSleep=1, absent, last seen 5 min ago → devIsSleeping=1.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:01", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:01'") + row = cur.fetchone() + assert row["devIsSleeping"] == 1 + + def test_sleeping_device_not_in_down_event_query(self): + """A sleeping device must be excluded from the down-event query.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:02", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devIsSleeping = 0 + AND devPresentLastScan = 0 + """) + macs = [r["devMac"] for r in cur.fetchall()] + assert "BB:BB:BB:BB:BB:02" not in macs + + def test_expired_sleep_window_fires_down(self): + """After the sleep window expires, the device must appear as Down.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:03", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45), # > 30 min + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:03'") + assert cur.fetchone()["devIsSleeping"] == 0 + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devIsSleeping = 0 + AND devPresentLastScan = 0 + """) + macs = [r["devMac"] for r in cur.fetchall()] + assert "BB:BB:BB:BB:BB:03" in macs + + def test_can_sleep_zero_device_is_not_sleeping(self): + """devCanSleep=0 device recently offline → devIsSleeping must be 0.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:04", + alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:04'") + assert cur.fetchone()["devIsSleeping"] == 0 + + def test_devstatus_sleeping(self): + """DevicesView devStatus must be 'Sleeping' for a sleeping device.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:05", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:05'") + assert cur.fetchone()["devStatus"] == "Sleeping" + + def test_devstatus_down_after_window_expires(self): + """DevicesView devStatus must be 'Down' once the sleep window expires.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:06", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45), + ) + conn.commit() + + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:06'") + assert cur.fetchone()["devStatus"] == "Down" diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py new file mode 100644 index 00000000..d7880d2c --- /dev/null +++ b/test/db_test_helpers.py @@ -0,0 +1,230 @@ +""" +Shared in-memory database factories and helpers for NetAlertX unit tests. + +Import from any test subdirectory with: + + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + from db_test_helpers import make_db, insert_device, minutes_ago, DummyDB, down_event_macs +""" + +import sqlite3 +import sys +import os +from datetime import datetime, timezone, timedelta + +# Make the 'server' package importable when this module is loaded directly. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "server")) +from db.db_upgrade import ensure_views # noqa: E402 + + +# --------------------------------------------------------------------------- +# DDL +# --------------------------------------------------------------------------- + +CREATE_DEVICES = """ + CREATE TABLE IF NOT EXISTS Devices ( + devMac TEXT PRIMARY KEY, + devName TEXT, + devOwner TEXT, + devType TEXT, + devVendor TEXT, + devFavorite INTEGER DEFAULT 0, + devGroup TEXT, + devComments TEXT, + devFirstConnection TEXT, + devLastConnection TEXT, + devLastIP TEXT, + devPrimaryIPv4 TEXT, + devPrimaryIPv6 TEXT, + devVlan TEXT, + devForceStatus TEXT, + devStaticIP TEXT, + devScan INTEGER DEFAULT 1, + devLogEvents INTEGER DEFAULT 1, + devAlertEvents INTEGER DEFAULT 1, + devAlertDown INTEGER, -- intentionally nullable + devCanSleep INTEGER DEFAULT 0, + devSkipRepeated INTEGER DEFAULT 0, + devLastNotification TEXT, + devPresentLastScan INTEGER DEFAULT 0, + devIsNew INTEGER DEFAULT 0, + devLocation TEXT, + devIsArchived INTEGER DEFAULT 0, + devParentMAC TEXT, + devParentPort TEXT, + devIcon TEXT, + devGUID TEXT, + devSite TEXT, + devSSID TEXT, + devSyncHubNode TEXT, + devSourcePlugin TEXT, + devCustomProps TEXT, + devFQDN TEXT, + devParentRelType TEXT, + devReqNicsOnline INTEGER DEFAULT 0, + devMacSource TEXT, + devNameSource TEXT, + devFQDNSource TEXT, + devLastIPSource TEXT, + devVendorSource TEXT, + devSSIDSource TEXT, + devParentMACSource TEXT, + devParentPortSource TEXT, + devParentRelTypeSource TEXT, + devVlanSource TEXT + ) +""" + +# Includes eve_PairEventRowid — required by insert_events(). +CREATE_EVENTS = """ + CREATE TABLE IF NOT EXISTS Events ( + eve_MAC TEXT, + eve_IP TEXT, + eve_DateTime TEXT, + eve_EventType TEXT, + eve_AdditionalInfo TEXT, + eve_PendingAlertEmail INTEGER, + eve_PairEventRowid INTEGER + ) +""" + +CREATE_CURRENT_SCAN = """ + CREATE TABLE IF NOT EXISTS CurrentScan ( + scanMac TEXT, + scanLastIP TEXT, + scanVendor TEXT, + scanSourcePlugin TEXT, + scanName TEXT, + scanLastQuery TEXT, + scanLastConnection TEXT, + scanSyncHubNode TEXT, + scanSite TEXT, + scanSSID TEXT, + scanParentMAC TEXT, + scanParentPort TEXT, + scanType TEXT + ) +""" + +CREATE_SETTINGS = """ + CREATE TABLE IF NOT EXISTS Settings ( + setKey TEXT PRIMARY KEY, + setValue TEXT + ) +""" + + +# --------------------------------------------------------------------------- +# DB factory +# --------------------------------------------------------------------------- + +def make_db(sleep_minutes: int = 30) -> sqlite3.Connection: + """ + Return a fully seeded in-memory SQLite connection with DevicesView built. + + Builds all required tables (Devices, Events, CurrentScan, Settings) and + calls ensure_views() so DevicesView is immediately queryable. + """ + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(CREATE_DEVICES) + cur.execute(CREATE_EVENTS) + cur.execute(CREATE_CURRENT_SCAN) + cur.execute(CREATE_SETTINGS) + cur.execute( + "INSERT OR REPLACE INTO Settings (setKey, setValue) VALUES (?, ?)", + ("NTFPRCS_sleep_time", str(sleep_minutes)), + ) + conn.commit() + ensure_views(cur) + conn.commit() + return conn + + +# --------------------------------------------------------------------------- +# Time helpers +# --------------------------------------------------------------------------- + +def minutes_ago(n: int) -> str: + """Return a UTC timestamp string for *n* minutes ago.""" + dt = datetime.now(timezone.utc) - timedelta(minutes=n) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def now_utc() -> str: + """Return the current UTC timestamp as a string.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Device row factory +# --------------------------------------------------------------------------- + +def insert_device( + cur, + mac: str, + *, + alert_down, + present_last_scan: int = 0, + can_sleep: int = 0, + last_connection: str | None = None, + last_ip: str = "192.168.1.1", +) -> None: + """ + Insert a minimal Devices row. + + Parameters + ---------- + alert_down: + Value for devAlertDown. Pass ``None`` to store SQL NULL (tests the + IFNULL coercion regression), ``0`` for disabled, ``1`` for enabled. + present_last_scan: + ``1`` = device was seen last scan (about to go down transition). + ``0`` = device was already absent last scan. + can_sleep: + ``1`` enables the sleeping window for this device. + last_connection: + ISO-8601 UTC string; defaults to 60 minutes ago when omitted. + last_ip: + Value stored in devLastIP. + """ + cur.execute( + """ + INSERT INTO Devices + (devMac, devAlertDown, devPresentLastScan, devCanSleep, + devLastConnection, devLastIP, devIsArchived, devIsNew) + VALUES (?, ?, ?, ?, ?, ?, 0, 0) + """, + (mac, alert_down, present_last_scan, can_sleep, + last_connection or minutes_ago(60), last_ip), + ) + + +# --------------------------------------------------------------------------- +# Assertion helpers +# --------------------------------------------------------------------------- + +def down_event_macs(cur) -> set: + """Return the set of MACs that have a 'Device Down' event row.""" + cur.execute("SELECT eve_MAC FROM Events WHERE eve_EventType = 'Device Down'") + return {r["eve_MAC"] for r in cur.fetchall()} + + +# --------------------------------------------------------------------------- +# DummyDB — minimal wrapper used by scan.session_events helpers +# --------------------------------------------------------------------------- + +class DummyDB: + """ + Minimal DB wrapper that satisfies the interface expected by + ``session_events.insert_events()`` and related helpers. + """ + + def __init__(self, conn: sqlite3.Connection): + self.sql = conn.cursor() + self._conn = conn + + def commitDB(self) -> None: + self._conn.commit() diff --git a/test/scan/test_down_sleep_events.py b/test/scan/test_down_sleep_events.py new file mode 100644 index 00000000..cc9067a6 --- /dev/null +++ b/test/scan/test_down_sleep_events.py @@ -0,0 +1,296 @@ +""" +Integration tests for the 'Device Down' event insertion and sleeping suppression. + +Two complementary layers are tested: + +Layer 1 — insert_events() (session_events.py) + The "Device Down" event fires when: + devPresentLastScan = 1 (was online last scan) + AND device NOT in CurrentScan (absent this scan) + AND devAlertDown != 0 + + At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0, + but insert_events runs before update_presence_from_CurrentScan flips it). + Tests here verify NULL-devAlertDown regression and normal down/no-down branching. + +Layer 2 — DevicesView down-count query (as used by insertOnlineHistory / db_helper) + After presence is updated (devPresentLastScan → 0) the sleeping suppression + (devIsSleeping=1) kicks in for count/API queries. + Tests here verify that sleeping devices are excluded from down counts and that + expired-window devices are included. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import ( # noqa: E402 + make_db as _make_db, + minutes_ago as _minutes_ago, + insert_device as _insert_device, + down_event_macs as _down_event_macs, + DummyDB, +) + +# server/ is already on sys.path after db_test_helpers import +from scan.session_events import insert_events # noqa: E402 + + +# --------------------------------------------------------------------------- +# Layer 1: insert_events() — event creation on the down transition +# +# Condition: devPresentLastScan = 1 (was online) AND not in CurrentScan (now absent) +# At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0). +# --------------------------------------------------------------------------- + +class TestInsertEventsDownDetection: + """ + Tests for the 'Device Down' INSERT in insert_events(). + + The down transition is: devPresentLastScan=1 AND absent from CurrentScan. + CurrentScan is left empty in all tests (all devices absent this scan). + """ + + def test_null_alert_down_does_not_fire_down_event(self): + """ + Regression: NULL devAlertDown must NOT produce a 'Device Down' event. + + Root cause: IFNULL(devAlertDown, '') made '' != 0 evaluate TRUE in SQLite, + causing devices without devAlertDown set to fire constant down events. + Fix: IFNULL(devAlertDown, 0) → 0 != 0 is FALSE. + """ + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:01", alert_down=None, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:01" not in _down_event_macs(cur), ( + "NULL devAlertDown must never fire a 'Device Down' event " + "(IFNULL coercion regression)" + ) + + def test_zero_alert_down_does_not_fire_down_event(self): + """Explicit devAlertDown=0 must NOT fire a 'Device Down' event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:02", alert_down=0, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:02" not in _down_event_macs(cur) + + def test_alert_down_one_fires_down_event_when_absent(self): + """devAlertDown=1, was online last scan, absent now → 'Device Down' event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:03", alert_down=1, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:03" in _down_event_macs(cur) + + def test_device_in_current_scan_does_not_fire_down_event(self): + """A device present in CurrentScan (online now) must NOT get Down event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:04", alert_down=1, present_last_scan=1) + # Put it in CurrentScan → device is online this scan + cur.execute( + "INSERT INTO CurrentScan (scanMac, scanLastIP) VALUES (?, ?)", + ("AA:11:22:33:44:04", "192.168.1.1"), + ) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:04" not in _down_event_macs(cur) + + def test_already_absent_last_scan_does_not_re_fire(self): + """ + devPresentLastScan=0 means device was already absent last scan. + The down event was already created then; it must not be created again. + (The INSERT query requires devPresentLastScan=1 — the down-transition moment.) + """ + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:05", alert_down=1, present_last_scan=0) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:05" not in _down_event_macs(cur) + + def test_archived_device_does_not_fire_down_event(self): + """Archived devices should not produce Down events.""" + conn = _make_db() + cur = conn.cursor() + cur.execute( + """INSERT INTO Devices + (devMac, devAlertDown, devPresentLastScan, devCanSleep, + devLastConnection, devLastIP, devIsArchived, devIsNew) + VALUES (?, 1, 1, 0, ?, '192.168.1.1', 1, 0)""", + ("AA:11:22:33:44:06", _minutes_ago(60)), + ) + conn.commit() + + insert_events(DummyDB(conn)) + + # Archived devices have devIsArchived=1; insert_events doesn't filter + # by archived, but DevicesView applies devAlertDown — archived here is + # tested to confirm the count stays clean for future filter additions. + # The archived device DOES get a Down event today (no archive filter in + # insert_events). This test documents the current behaviour. + # If that changes, update this assertion accordingly. + assert "AA:11:22:33:44:06" in _down_event_macs(cur) + + def test_multiple_devices_mixed_alert_down(self): + """Only devices with devAlertDown=1 that are absent fire Down events.""" + conn = _make_db() + cur = conn.cursor() + cases = [ + ("CC:00:00:00:00:01", None, 1), # NULL → no event + ("CC:00:00:00:00:02", 0, 1), # 0 → no event + ("CC:00:00:00:00:03", 1, 1), # 1 → event + ("CC:00:00:00:00:04", 1, 0), # already absent → no event + ] + for mac, alert_down, present in cases: + _insert_device(cur, mac, alert_down=alert_down, present_last_scan=present) + conn.commit() + + insert_events(DummyDB(conn)) + fired = _down_event_macs(cur) + + assert "CC:00:00:00:00:01" not in fired, "NULL devAlertDown must not fire" + assert "CC:00:00:00:00:02" not in fired, "devAlertDown=0 must not fire" + assert "CC:00:00:00:00:03" in fired, "devAlertDown=1 absent must fire" + assert "CC:00:00:00:00:04" not in fired, "already-absent device must not fire again" + + +# --------------------------------------------------------------------------- +# Layer 2: DevicesView down-count query (post-presence-update) +# +# After update_presence_from_CurrentScan sets devPresentLastScan → 0 for absent +# devices, the sleeping suppression (devIsSleeping) becomes active for: +# - insertOnlineHistory (SUM ... WHERE devPresentLastScan=0 AND devIsSleeping=0) +# - db_helper "down" filter +# - getDown() +# --------------------------------------------------------------------------- + +class TestDownCountSleepingSuppression: + """ + Tests for the post-presence-update down-count query. + + Simulates the state AFTER update_presence_from_CurrentScan has run by + inserting devices with devPresentLastScan=0 (already absent) directly. + """ + + _DOWN_COUNT_SQL = """ + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devPresentLastScan = 0 + AND devIsSleeping = 0 + AND devIsArchived = 0 + """ + + def test_null_alert_down_excluded_from_down_count(self): + """NULL devAlertDown must not contribute to down count.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:01", alert_down=None, present_last_scan=0) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:01" not in macs + + def test_alert_down_one_included_in_down_count(self): + """devAlertDown=1 absent device must be counted as down.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:02", alert_down=1, present_last_scan=0, + last_connection=_minutes_ago(60)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:02" in macs + + def test_sleeping_device_excluded_from_down_count(self): + """ + devCanSleep=1 + absent + within sleep window → devIsSleeping=1. + Must be excluded from the down-count query. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:03", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:03" not in macs, ( + "Sleeping device must be excluded from down count" + ) + + def test_expired_sleep_window_included_in_down_count(self): + """Once the sleep window expires the device must appear in down count.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:04", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:04" in macs, ( + "Device past its sleep window must appear in down count" + ) + + def test_can_sleep_zero_always_in_down_count(self): + """devCanSleep=0 device that is absent is always counted as down.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:05", alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(5)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:05" in macs + + def test_online_history_down_count_excludes_sleeping(self): + """ + Mirrors the insertOnlineHistory SUM query exactly. + Sleeping devices must not inflate the downDevices count. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + + # Normal down + _insert_device(cur, "EE:00:00:00:00:01", alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(60)) + # Sleeping (within window) + _insert_device(cur, "EE:00:00:00:00:02", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(10)) + # Online + _insert_device(cur, "EE:00:00:00:00:03", alert_down=1, present_last_scan=1, + last_connection=_minutes_ago(1)) + conn.commit() + + cur.execute(""" + SELECT + COALESCE(SUM(CASE + WHEN devPresentLastScan = 0 + AND devAlertDown = 1 + AND devIsSleeping = 0 + THEN 1 ELSE 0 END), 0) AS downDevices + FROM DevicesView + """) + count = cur.fetchone()["downDevices"] + assert count == 1, ( + f"Expected 1 down device (sleeping device must not be counted), got {count}" + )