2026-08-14 00:39:33 +02:00
|
|
|
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe).
|
|
|
|
|
|
|
|
|
|
Identity is the device link now: two nodes are duplicates when they draw the
|
|
|
|
|
same `device_inventory` row on the same design. The device facts live on that
|
|
|
|
|
row, so nothing has to be merged out of the extras before they go — only edges
|
|
|
|
|
and parent links are re-pointed.
|
|
|
|
|
"""
|
2026-07-03 01:52:56 +02:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
from app.db.models import Design, Edge, InventoryDevice, Node
|
|
|
|
|
from app.services.node_dedupe import dedupe_nodes_by_device
|
2026-07-03 01:52:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _design(db, name="d1"):
|
|
|
|
|
d = Design(name=name)
|
|
|
|
|
db.add(d)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
async def _device(db, ieee="0xAAA", **kwargs):
|
|
|
|
|
device = InventoryDevice(ieee_address=ieee, status="approved", **kwargs)
|
|
|
|
|
db.add(device)
|
|
|
|
|
await db.flush()
|
|
|
|
|
return device
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 01:52:56 +02:00
|
|
|
@pytest.mark.asyncio
|
2026-08-14 00:39:33 +02:00
|
|
|
async def test_collapses_same_device_same_design(db_session):
|
2026-07-03 01:52:56 +02:00
|
|
|
d = await _design(db_session)
|
2026-08-14 00:39:33 +02:00
|
|
|
device = await _device(db_session)
|
2026-07-03 01:52:56 +02:00
|
|
|
keep = Node(
|
|
|
|
|
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
2026-08-14 00:39:33 +02:00
|
|
|
device_id=device.id, pos_x=100, pos_y=200,
|
2026-07-03 01:52:56 +02:00
|
|
|
)
|
|
|
|
|
db_session.add(keep)
|
|
|
|
|
await db_session.flush()
|
2026-08-14 00:39:33 +02:00
|
|
|
dup = Node(label="Sensor", type="zigbee_enddevice", design_id=d.id, device_id=device.id)
|
2026-07-03 01:52:56 +02:00
|
|
|
db_session.add(dup)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
removed = await dedupe_nodes_by_device(db_session)
|
2026-07-03 01:52:56 +02:00
|
|
|
assert removed == 1
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
nodes = (
|
|
|
|
|
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
|
|
|
|
).scalars().all()
|
2026-07-03 01:52:56 +02:00
|
|
|
assert len(nodes) == 1
|
|
|
|
|
survivor = nodes[0]
|
|
|
|
|
assert survivor.id == keep.id # oldest kept
|
|
|
|
|
assert survivor.pos_x == 100 # canvas position preserved
|
2026-08-14 00:39:33 +02:00
|
|
|
# The device facts were never on the node, so none can be lost here.
|
|
|
|
|
assert await db_session.get(InventoryDevice, device.id) is not None
|
2026-07-03 01:52:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-08-14 00:39:33 +02:00
|
|
|
async def test_preserves_same_device_across_designs(db_session):
|
2026-07-03 01:52:56 +02:00
|
|
|
"""Same device on two canvases is valid — must NOT be merged."""
|
|
|
|
|
d1 = await _design(db_session, "d1")
|
|
|
|
|
d2 = await _design(db_session, "d2")
|
2026-08-14 00:39:33 +02:00
|
|
|
device = await _device(db_session, ieee="0xBBB")
|
2026-07-03 01:52:56 +02:00
|
|
|
for d in (d1, d2):
|
2026-08-14 00:39:33 +02:00
|
|
|
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, device_id=device.id))
|
2026-07-03 01:52:56 +02:00
|
|
|
await db_session.flush()
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
removed = await dedupe_nodes_by_device(db_session)
|
2026-07-03 01:52:56 +02:00
|
|
|
assert removed == 0
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
nodes = (
|
|
|
|
|
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
|
|
|
|
).scalars().all()
|
2026-07-03 01:52:56 +02:00
|
|
|
assert len(nodes) == 2
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_leaves_furniture_alone(db_session):
|
|
|
|
|
"""Zones and text carry no device link, so they are never duplicates."""
|
|
|
|
|
d = await _design(db_session)
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
db_session.add(Node(label="Zone", type="groupRect", design_id=d.id))
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
|
|
|
|
|
assert await dedupe_nodes_by_device(db_session) == 0
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 01:52:56 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_repoints_edges_and_drops_dupes(db_session):
|
|
|
|
|
d = await _design(db_session)
|
2026-08-14 00:39:33 +02:00
|
|
|
device = await _device(db_session, ieee="0xCCC")
|
|
|
|
|
keep = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
2026-07-03 01:52:56 +02:00
|
|
|
other = Node(label="B", type="server", design_id=d.id)
|
|
|
|
|
db_session.add_all([keep, other])
|
|
|
|
|
await db_session.flush()
|
2026-08-14 00:39:33 +02:00
|
|
|
dup = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
2026-07-03 01:52:56 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
removed = await dedupe_nodes_by_device(db_session)
|
2026-07-03 01:52:56 +02:00
|
|
|
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)
|
2026-08-14 00:39:33 +02:00
|
|
|
device = await _device(db_session, ieee="0xDDD")
|
|
|
|
|
keep = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
2026-07-03 01:52:56 +02:00
|
|
|
db_session.add(keep)
|
|
|
|
|
await db_session.flush()
|
2026-08-14 00:39:33 +02:00
|
|
|
dup = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
2026-07-03 01:52:56 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-08-14 00:39:33 +02:00
|
|
|
await dedupe_nodes_by_device(db_session)
|
2026-07-03 01:52:56 +02:00
|
|
|
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)
|
2026-08-14 00:39:33 +02:00
|
|
|
device = await _device(db_session, ieee="0xEEE")
|
|
|
|
|
db_session.add(Node(label="X", type="server", design_id=d.id, device_id=device.id))
|
2026-07-03 01:52:56 +02:00
|
|
|
await db_session.flush()
|
2026-08-14 00:39:33 +02:00
|
|
|
assert await dedupe_nodes_by_device(db_session) == 0
|
|
|
|
|
assert await dedupe_nodes_by_device(db_session) == 0
|