"""MAC-property builders and Zigbee/Z-Wave property population on approve.""" import uuid import pytest from httpx import AsyncClient from sqlalchemy import select from app.db.models import Node, PendingDevice from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair @pytest.mark.asyncio async def test_approve_zigbee_device_populates_properties( client: AsyncClient, headers, zigbee_pending_device, db_session ): """Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties.""" from sqlalchemy import select from app.db.models import Node as NodeModel payload = { "label": "bulb_1", "type": "zigbee_enddevice", "status": "online", "services": [], "check_method": "none", } res = await client.post( f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve", json=payload, headers=headers, ) assert res.status_code == 200 node = ( await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) ).scalar_one() keys = {p["key"]: p["value"] for p in node.properties} assert keys == { "IEEE": "0xABCDEF", "Vendor": "IKEA", "Model": "TRADFRI", "LQI": "180", } @pytest.mark.asyncio async def test_bulk_approve_zigbee_populates_properties( client: AsyncClient, headers, zigbee_pending_device, db_session ): from sqlalchemy import select from app.db.models import Node as NodeModel res = await client.post( "/api/v1/scan/pending/bulk-approve", json={"device_ids": [zigbee_pending_device.id]}, headers=headers, ) assert res.status_code == 200 node = ( await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) ).scalar_one() keys = {p["key"]: p["value"] for p in node.properties} assert keys["IEEE"] == "0xABCDEF" assert keys["Vendor"] == "IKEA" assert keys["Model"] == "TRADFRI" assert keys["LQI"] == "180" assert node.check_method == "none" def test_build_mac_property_returns_hidden_row(): from app.api.routes.scan import build_mac_property assert build_mac_property("aa:bb:cc:dd:ee:ff") == [ {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} ] def test_build_mac_property_empty_when_no_mac(): from app.api.routes.scan import build_mac_property assert build_mac_property(None) == [] assert build_mac_property("") == [] def test_merge_mac_property_appends_when_absent(): from app.api.routes.scan import merge_mac_property existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged # Existing prop preserved untouched. assert existing[0] in merged def test_merge_mac_property_idempotent_and_preserves_visibility(): from app.api.routes.scan import merge_mac_property existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}] merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") # No duplicate MAC row; user's visible=True choice kept. macs = [p for p in merged if p["key"] == "MAC"] assert len(macs) == 1 assert macs[0]["visible"] is True def test_merge_mac_property_noop_without_mac(): from app.api.routes.scan import merge_mac_property existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] assert merge_mac_property(existing, None) == existing @pytest.mark.asyncio async def test_approve_device_does_not_duplicate_mac_property( client: AsyncClient, headers, pending_device, db_session ): """If the approve payload already carries a MAC prop, don't add a second one.""" from sqlalchemy import select from app.db.models import Node as NodeModel res = await client.post( f"/api/v1/scan/pending/{pending_device.id}/approve", json={ "label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": [], "properties": [ {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True} ], }, headers=headers, ) assert res.status_code == 200 node = ( await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100")) ).scalar_one() mac_props = [p for p in node.properties if p["key"] == "MAC"] assert len(mac_props) == 1 # User's visibility choice is preserved. assert mac_props[0]["visible"] is True @pytest.mark.asyncio async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node( client: AsyncClient, headers, db_session ): from sqlalchemy import select from app.db.models import Edge coord, pending = await _seed_zigbee_pending_pair(db_session) res = await client.post( f"/api/v1/scan/pending/{pending.id}/approve", json={ "label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": [], }, headers=headers, ) assert res.status_code == 200 data = res.json() assert data["approved"] is True assert data["edges_created"] == 1 edges = (await db_session.execute(select(Edge))).scalars().all() assert len(edges) == 1 assert edges[0].source == coord.id assert edges[0].target == data["node_id"] assert edges[0].source_handle == "bottom" # Bare side name (canonical stored form); renders at the top like before. assert edges[0].target_handle == "top" assert edges[0].type == "iot" @pytest.mark.asyncio async def test_approve_zigbee_skips_duplicate_edge( client: AsyncClient, headers, db_session ): """Re-running the resolution does not create a second edge for the same pair.""" from sqlalchemy import select from app.db.models import Edge, PendingDevice, PendingDeviceLink coord, pending = await _seed_zigbee_pending_pair(db_session) body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers) # Simulate a second pending row + link between same coord and a new device, # but keep an existing edge in place to verify dedupe also handles # the swapped-direction case. new_pending = PendingDevice( ieee_address="0xR1B", friendly_name="r1b", suggested_type="zigbee_router", status="pending", discovery_source="zigbee", ) db_session.add(new_pending) db_session.add( PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee") ) await db_session.commit() res = await client.post( f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers ) assert res.json()["edges_created"] == 1 # only the new pair edges = (await db_session.execute(select(Edge))).scalars().all() assert len(edges) == 2 # original + new, no duplicate @pytest.mark.asyncio async def test_approve_zigbee_skips_when_other_endpoint_still_pending( client: AsyncClient, headers, db_session ): """Both endpoints pending → no edge yet, link row preserved for later.""" from sqlalchemy import select from app.db.models import Edge, PendingDevice, PendingDeviceLink a = PendingDevice( ieee_address="0xA", friendly_name="a", suggested_type="zigbee_router", status="pending", discovery_source="zigbee", ) b = PendingDevice( ieee_address="0xB", friendly_name="b", suggested_type="zigbee_enddevice", status="pending", discovery_source="zigbee", ) db_session.add_all([a, b]) db_session.add( PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") ) await db_session.commit() res = await client.post( f"/api/v1/scan/pending/{a.id}/approve", json={ "label": "a", "type": "zigbee_router", "ip": None, "status": "unknown", "services": [], }, headers=headers, ) assert res.status_code == 200 assert res.json()["edges_created"] == 0 edges = (await db_session.execute(select(Edge))).scalars().all() assert edges == [] links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() assert len(links) == 1 # preserved for later resolution @pytest.mark.asyncio async def test_approve_zigbee_resolves_link_after_second_approval( client: AsyncClient, headers, db_session ): """First approval keeps link (other endpoint pending); second approval creates the edge. The link row is retained afterwards so the same pair can be re-approved onto another canvas — it's topology, wiped only on reimport.""" from sqlalchemy import select from app.db.models import Edge, PendingDevice, PendingDeviceLink a = PendingDevice( ieee_address="0xA", friendly_name="a", suggested_type="zigbee_router", status="pending", discovery_source="zigbee", ) b = PendingDevice( ieee_address="0xB", friendly_name="b", suggested_type="zigbee_enddevice", status="pending", discovery_source="zigbee", ) db_session.add_all([a, b]) db_session.add( PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") ) await db_session.commit() body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers) res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers) assert res.json()["edges_created"] == 1 edges = (await db_session.execute(select(Edge))).scalars().all() assert len(edges) == 1 links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() assert len(links) == 1 # retained for re-approval onto other canvases @pytest.mark.asyncio async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session): active = await _add_design(db_session, "zwave") dev = PendingDevice( id=str(uuid.uuid4()), ieee_address="zwave-H-9", friendly_name="Door Sensor", suggested_type="zwave_enddevice", vendor="Aeotec", model="ZW120", status="pending", discovery_source="zwave", ) db_session.add(dev) await db_session.commit() res = await client.post( f"/api/v1/scan/pending/{dev.id}/approve", json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active}, headers=headers, ) assert res.status_code == 200 node = ( await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9")) ).scalar_one() assert node.design_id == active assert node.status == "online" assert node.check_method == "none" assert any(p["key"] == "Z-Wave ID" for p in node.properties)