b513fa6f4e
Break the 1897-line test_scan.py into topic modules under tests/scan/ (routes, approve, properties, run) sharing fixtures via a package conftest.py and pure builders via helpers.py. Same 80 tests, no behavior change; full suite 627 passed. ha-relevant: no
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""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)
|