fix: tolerate same device on multiple canvases in zigbee/zwave import
Zigbee and Z-Wave imports looked up canvas nodes by ieee_address with scalar_one_or_none(), assuming one node per IEEE globally. A device placed on two designs (one Node per canvas — a supported feature) made re-import crash with MultipleResultsFound. - Both mesh imports now refresh properties on every matching node instead of a single row (loop over .scalars().all()). - approve_device guards against a true duplicate: same IEEE already on the SAME design reuses that node instead of inserting a second one. - New node_dedupe service: loss-free repair keyed on (ieee, design_id). Collapses only genuine same-canvas duplicates (merges properties/services/ missing fields, re-points edges + parent_id, drops self-loops/parallel edges). Cross-design placements preserved. Runs at start of both imports and bulk-approve. No-op on healthy DBs. - IP correlation path already handled multiple nodes; left unchanged. Tests: dedupe unit tests (collapse, cross-design preservation, edge/parent re-point, idempotent), zigbee + zwave multi-canvas regression, approve no-dupe guard. ha-relevant: yes
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, Edge, Node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
|
||||
|
||||
async def _design(db, name="d1"):
|
||||
d = Design(name=name)
|
||||
db.add(d)
|
||||
await db.flush()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collapses_same_ieee_same_design(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(
|
||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
||||
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
|
||||
pos_x=100, pos_y=200,
|
||||
)
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
dup = Node(
|
||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
||||
ieee_address="0xAAA", ip="10.0.0.5",
|
||||
properties=[{"key": "LQI", "value": "88", "visible": False}],
|
||||
)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
assert removed == 1
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
survivor = nodes[0]
|
||||
assert survivor.id == keep.id # oldest kept
|
||||
assert survivor.pos_x == 100 # canvas position preserved
|
||||
assert survivor.ip == "10.0.0.5" # missing field filled from dup
|
||||
keys = {p["key"] for p in survivor.properties}
|
||||
assert keys == {"IEEE", "LQI"} # properties merged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_same_ieee_across_designs(db_session):
|
||||
"""Same device on two canvases is valid — must NOT be merged."""
|
||||
d1 = await _design(db_session, "d1")
|
||||
d2 = await _design(db_session, "d2")
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, ieee_address="0xBBB"))
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
assert removed == 0
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
other = Node(label="B", type="server", design_id=d.id)
|
||||
db_session.add_all([keep, other])
|
||||
await db_session.flush()
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
# keep<->other and dup<->other (parallel after repoint), plus dup<->keep (self-loop).
|
||||
db_session.add_all([
|
||||
Edge(source=keep.id, target=other.id, type="ethernet", design_id=d.id),
|
||||
Edge(source=dup.id, target=other.id, type="ethernet", design_id=d.id),
|
||||
Edge(source=dup.id, target=keep.id, type="ethernet", design_id=d.id),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
assert removed == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
# self-loop dropped, parallel edge collapsed -> a single keep<->other edge
|
||||
assert len(edges) == 1
|
||||
e = edges[0]
|
||||
assert {e.source, e.target} == {keep.id, other.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_child_parent(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
|
||||
db_session.add(child)
|
||||
await db_session.flush()
|
||||
|
||||
await dedupe_nodes_by_ieee(db_session)
|
||||
await db_session.refresh(child)
|
||||
assert child.parent_id == keep.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_noop_when_unique(db_session):
|
||||
d = await _design(db_session)
|
||||
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
|
||||
await db_session.flush()
|
||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
||||
@@ -311,6 +311,44 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
|
||||
assert inventory[0]["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_no_dupe_same_ieee_same_design(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Approving a device whose IEEE is already on the target design must reuse
|
||||
the existing node, not create a duplicate (source of the crash bug)."""
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
existing = Node(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{device.id}/approve",
|
||||
json={
|
||||
"label": "sensor", "type": "zigbee_enddevice",
|
||||
"status": "online", "services": [], "design_id": design.id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["node_id"] == existing.id # reused, not new
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1 # no duplicate created
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||
node_payload = {
|
||||
|
||||
@@ -618,6 +618,43 @@ async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||
assert by_key["Model"]["visible"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_device_on_multiple_canvases(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""Regression: a device approved onto TWO designs (one Node each) must not
|
||||
crash re-import with MultipleResultsFound — props refresh on both nodes."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Design, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
db_session.add_all([d1, d2])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="router_1", type="zigbee_router", status="online",
|
||||
check_method="none", ieee_address="0xR1", services=[],
|
||||
properties=[], design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 240
|
||||
# Must not raise.
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
for n in nodes:
|
||||
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
|
||||
assert lqi == "240" # refreshed on every canvas
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
|
||||
@@ -445,3 +445,38 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
||||
"""Regression: a device on TWO designs (one Node each) must not crash
|
||||
re-import with MultipleResultsFound — props refresh on both nodes."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Design, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
db_session.add_all([d1, d2])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="Wall Plug", type="zwave_router", status="online",
|
||||
check_method="none", ieee_address="zwave-0xh-2", services=[],
|
||||
properties=[], design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["model"] = "ZW200"
|
||||
# Must not raise.
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
for n in nodes:
|
||||
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
|
||||
assert model == "ZW200" # refreshed on every canvas
|
||||
|
||||
Reference in New Issue
Block a user