Completes the split: `nodes` now holds only how a device is drawn on one canvas, and every device fact reaches the API from the inventory row. - The device columns are removed from `nodes` (SQLite table rebuild, the same shape as the existing device_inventory and canvas_state rebuilds). The drop is skipped, and logged, while any non-furniture node is still unlinked: those columns are the last copy of that node's facts. The backfill therefore reads them with raw SQL — by the time it runs, the model no longer declares them. - The status checker iterates devices, not nodes: one check per device however many canvases draw it, writing `status_live` / `last_seen` / `response_time_ms` on the row. `/ws/status` messages carry `device_id` and the node ids they light up. Hidden devices are not probed. - Readers repointed: scanner (last_scan lands on the row), proxmox (its node tier collapses into the inventory tier, keeping only the cluster handles), zigbee/zwave (one property refresh serves every canvas), rack inventory, liveview, stats, node dedupe. - `POST /scan/pending` merges into the row that already describes the host instead of minting a second one — one device is one row, whichever way it was documented. - Standalone keeps parity: the canvas blob gains `devices`, split on save and hydrated on load. A blob written before the split still reads. The rack inventory had a related bug: a mount that names a node explicitly printed the mount's device rather than the pinned node's. It now reads the node's own row. Tests that built a node with device columns are ported to the link; where a behaviour genuinely moved (properties refresh once on the row, last_scan is the device's) the assertion moved with it rather than being dropped. ha-relevant: yes
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""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.
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Design, Edge, InventoryDevice, Node
|
|
from app.services.node_dedupe import dedupe_nodes_by_device
|
|
|
|
|
|
async def _design(db, name="d1"):
|
|
d = Design(name=name)
|
|
db.add(d)
|
|
await db.flush()
|
|
return d
|
|
|
|
|
|
async def _device(db, ieee="0xAAA", **kwargs):
|
|
device = InventoryDevice(ieee_address=ieee, status="approved", **kwargs)
|
|
db.add(device)
|
|
await db.flush()
|
|
return device
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_collapses_same_device_same_design(db_session):
|
|
d = await _design(db_session)
|
|
device = await _device(db_session)
|
|
keep = Node(
|
|
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
|
device_id=device.id, 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, device_id=device.id)
|
|
db_session.add(dup)
|
|
await db_session.flush()
|
|
|
|
removed = await dedupe_nodes_by_device(db_session)
|
|
assert removed == 1
|
|
|
|
nodes = (
|
|
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
|
).scalars().all()
|
|
assert len(nodes) == 1
|
|
survivor = nodes[0]
|
|
assert survivor.id == keep.id # oldest kept
|
|
assert survivor.pos_x == 100 # canvas position preserved
|
|
# 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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preserves_same_device_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")
|
|
device = await _device(db_session, ieee="0xBBB")
|
|
for d in (d1, d2):
|
|
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, device_id=device.id))
|
|
await db_session.flush()
|
|
|
|
removed = await dedupe_nodes_by_device(db_session)
|
|
assert removed == 0
|
|
|
|
nodes = (
|
|
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
|
).scalars().all()
|
|
assert len(nodes) == 2
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repoints_edges_and_drops_dupes(db_session):
|
|
d = await _design(db_session)
|
|
device = await _device(db_session, ieee="0xCCC")
|
|
keep = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
|
other = Node(label="B", type="server", design_id=d.id)
|
|
db_session.add_all([keep, other])
|
|
await db_session.flush()
|
|
dup = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
|
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_device(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)
|
|
device = await _device(db_session, ieee="0xDDD")
|
|
keep = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
|
db_session.add(keep)
|
|
await db_session.flush()
|
|
dup = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
|
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_device(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)
|
|
device = await _device(db_session, ieee="0xEEE")
|
|
db_session.add(Node(label="X", type="server", design_id=d.id, device_id=device.id))
|
|
await db_session.flush()
|
|
assert await dedupe_nodes_by_device(db_session) == 0
|
|
assert await dedupe_nodes_by_device(db_session) == 0
|