Stabilize discovery lifecycle and rescan summary
This commit is contained in:
140
tests/test_p1_discovery.py
Normal file
140
tests/test_p1_discovery.py
Normal 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)
|
||||
Reference in New Issue
Block a user