Stabilize discovery lifecycle and rescan summary

This commit is contained in:
Artem Kokos
2026-05-16 10:59:31 +07:00
parent 15529961d6
commit 1ac66ec4ac
8 changed files with 604 additions and 124 deletions

140
tests/test_p1_discovery.py Normal file
View File

@@ -0,0 +1,140 @@
import ipaddress
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
TEST_DB_PATH = Path(__file__).with_name("test_ignis_discovery.db")
if TEST_DB_PATH.exists():
TEST_DB_PATH.unlink()
MASTER_KEY = "master-secret-for-discovery-tests"
os.environ["IGNIS_API_KEY"] = MASTER_KEY
os.environ["IGNIS_DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB_PATH}"
os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}"
import main # noqa: E402
from app.core.database import async_session, init_db # noqa: E402
from app.core.discovery import DiscoveryService, InterfaceSubnet # noqa: E402
from app.core.state import state_manager # noqa: E402
from app.models.device import GroupModel # noqa: E402
class DiscoveryBehaviorTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
os.environ["IGNIS_API_KEY"] = MASTER_KEY
await init_db()
await self._reset_database()
state_manager.devices.clear()
state_manager.groups.clear()
state_manager._missing_scan_counts.clear()
self.client = AsyncClient(
transport=ASGITransport(app=main.app),
base_url="http://testserver",
)
async def asyncTearDown(self):
await self.client.aclose()
state_manager.devices.clear()
state_manager.groups.clear()
state_manager._missing_scan_counts.clear()
async def _reset_database(self):
async with async_session() as session:
await session.execute(delete(GroupModel))
await session.commit()
def _headers(self) -> dict[str, str]:
return {"X-API-Key": MASTER_KEY}
def test_auto_subnets_prefer_non_vpn_private_interfaces(self):
service = DiscoveryService()
candidates = [
InterfaceSubnet(
name="wg0",
address=ipaddress.IPv4Address("10.8.0.2"),
network=ipaddress.IPv4Network("10.8.0.0/24"),
),
InterfaceSubnet(
name="wlan0",
address=ipaddress.IPv4Address("192.168.0.25"),
network=ipaddress.IPv4Network("192.168.0.0/24"),
),
InterfaceSubnet(
name="docker0",
address=ipaddress.IPv4Address("172.17.0.1"),
network=ipaddress.IPv4Network("172.17.0.0/16"),
),
InterfaceSubnet(
name="enp3s0",
address=ipaddress.IPv4Address("192.168.1.20"),
network=ipaddress.IPv4Network("192.168.0.0/23"),
),
]
with patch.dict(os.environ, {}, clear=True):
with patch.object(service, "_interface_subnets", return_value=candidates):
subnets = service._get_target_subnets()
self.assertEqual(subnets, ["192.168.0.0/24", "192.168.1.0/24"])
async def test_manual_rescan_updates_and_removes_devices_immediately(self):
state_manager.devices["stale-device"] = SimpleNamespace(
id="stale-device",
ip="192.168.0.10",
name="Old Lamp",
room="Office",
)
with patch.object(
main.discovery_service,
"scan_network",
AsyncMock(
return_value=[
{
"mac": "fresh-device",
"ip": "192.168.0.20",
"state": {"on": True, "dimming": 100, "temp": 4100},
}
]
),
):
response = await self.client.post(
"/devices/rescan",
headers=self._headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["status"], "ok")
self.assertEqual(payload["found"], 1)
self.assertEqual(payload["added"], 1)
self.assertEqual(payload["updated"], 0)
self.assertEqual(payload["removed_offline"], 1)
self.assertEqual(payload["online"], 1)
self.assertEqual(list(state_manager.devices.keys()), ["fresh-device"])
def test_background_cleanup_requires_multiple_misses(self):
state_manager.update_device({"mac": "dev-1", "ip": "192.168.0.10"})
first = state_manager.apply_discovery_snapshot(
[],
remove_missing=True,
missing_threshold=2,
)
self.assertEqual(first.removed_offline, 0)
self.assertEqual(first.pending_removal, 1)
self.assertIn("dev-1", state_manager.devices)
second = state_manager.apply_discovery_snapshot(
[],
remove_missing=True,
missing_threshold=2,
)
self.assertEqual(second.removed_offline, 1)
self.assertEqual(second.pending_removal, 0)
self.assertNotIn("dev-1", state_manager.devices)