Files
homelable/backend/tests/test_racks.py
T
Pouzor 4ccad61e73 feat(rack): let a mount say when it shows its ports, and never end a cable on hidden metal
Two things about when a faceplate draws its sockets.

A mount now carries `portVisibility`: `auto` leaves the faceplate in charge —
switches and patch panels permanently, everything else on focus — while
`always` and `hover` override that either way. The selector sits in the port
section of the device modal. It is a canvas display choice, so it lives on the
rack mount and never reaches the inventory row the mount points at; `auto` is
the default, so nothing about existing racks changes.

The fix underneath: with the cabling overlay on `hover`, hovering a switch drew
its runs, but the far end landed on a plate that was not itself focused and so
drew no socket — a cable stopping on blank metal. A port a *drawn* cable ends
on is now always rendered, on both plates, whatever the plate rule or the mode
says. `cableVisibility.ts` owns the question of which cables are on screen and
`CableLayer` draws from the same answer, so the two can no longer disagree.

Fixes #403

ha-relevant: yes
2026-09-04 01:40:34 +02:00

1070 lines
45 KiB
Python

import pytest
from httpx import AsyncClient
from sqlalchemy import text
from app.db.models import InventoryDevice
pytestmark = pytest.mark.asyncio
async def _design(client: AsyncClient, headers: dict, *, name="Rack Room", design_type="rack") -> str:
res = await client.post(
"/api/v1/designs",
json={"name": name, "icon": "server", "design_type": design_type},
headers=headers,
)
assert res.status_code == 201, res.text
return res.json()["id"]
def _state(design_id: str, **overrides) -> dict:
"""A minimal but complete rack payload: one rack, two devices, one patch."""
payload = {
"design_id": design_id,
"racks": [
{
"id": "rack-1",
"name": "Main",
"u_height": 12,
"width_standard": "19",
"numbering": "bottom-up",
"location": "garage",
"style": {"frame": "#1c2129", "showNumbers": True},
"pos_x": 40,
"pos_y": 60,
}
],
"devices": [
{
"id": "dev-sw",
"rack_id": "rack-1",
"label": "sw-24",
"u_start": 10,
"u_height": 1,
"col_start": 0,
"col_span": 12,
"faceplate_id": "switch-24",
"status": "online",
"ports": [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}],
},
{
"id": "dev-nas",
"rack_id": "rack-1",
"label": "nas",
"u_start": 4,
"u_height": 2,
"col_start": 0,
"col_span": 12,
"faceplate_id": "nas-2u",
"status": "unknown",
"ports": [{"id": "p2", "label": "eth0", "type": "rj45", "x": 0.7, "y": 0.5}],
},
],
"cables": [
{
"id": "cbl-1",
"from_device_id": "dev-sw",
"from_port_id": "p1",
"to_device_id": "dev-nas",
"to_port_id": "p2",
"type": "ethernet",
"color": "#39d353",
}
],
"viewport": {"x": 10, "y": 20, "zoom": 1.5},
}
payload.update(overrides)
return payload
class TestDesignType:
async def test_creates_a_rack_design(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.get("/api/v1/designs", headers=headers)
design = next(d for d in res.json() if d["id"] == design_id)
assert design["design_type"] == "rack"
async def test_rejects_an_unknown_design_type(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/designs",
json={"name": "Nope", "design_type": "spaceship"},
headers=headers,
)
assert res.status_code == 422
class TestDeviceRackModel:
"""The inventory row owns the front panel; the mount only places it.
A device wears the same faceplate, size, colour and ports in every rack, so a
save writes them onto `device_inventory` and a load reads them back out.
"""
async def _mounted(self, client: AsyncClient, headers: dict, db_session, **device) -> str:
design_id = await _design(client, headers)
db_session.add(InventoryDevice(id="inv-sw", ip="192.168.1.10", suggested_type="switch"))
await db_session.commit()
state = _state(design_id, cables=[])
state["devices"] = [{**state["devices"][0], "device_id": "inv-sw", **device}]
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
return design_id
async def test_writes_the_mount_panel_onto_the_inventory_row(
self, client: AsyncClient, headers, db_session
):
await self._mounted(client, headers, db_session, color="#ff6e00")
entry = await db_session.get(InventoryDevice, "inv-sw")
await db_session.refresh(entry)
assert entry.rack_faceplate_id == "switch-24"
assert entry.rack_u_height == 1
assert entry.rack_col_span == 12
assert entry.rack_color == "#ff6e00"
assert entry.rack_ports == [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}]
async def test_leaves_the_inventory_alone_for_an_accessory(
self, client: AsyncClient, headers, db_session
):
design_id = await _design(client, headers)
db_session.add(InventoryDevice(id="inv-untouched", ip="192.168.1.11"))
await db_session.commit()
state = _state(design_id, cables=[])
state["devices"] = [{**state["devices"][0], "faceplate_id": "shelf-1u", "ports": []}]
await client.post("/api/v1/racks/save", json=state, headers=headers)
entry = await db_session.get(InventoryDevice, "inv-untouched")
await db_session.refresh(entry)
assert entry.rack_faceplate_id is None
async def test_load_serves_the_inventory_panel_over_the_mount_copy(
self, client: AsyncClient, headers, db_session
):
design_id = await self._mounted(client, headers, db_session)
# Another rack modelled the same device differently since this mount was
# written: the row wins, ports included.
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_faceplate_id = "switch-48"
entry.rack_color = "#a855f7"
entry.rack_ports = [{"id": "px", "label": "uplink", "type": "sfp+", "x": 0.9, "y": 0.4}]
await db_session.commit()
device = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"][0]
assert device["faceplate_id"] == "switch-48"
assert device["color"] == "#a855f7"
assert [p["id"] for p in device["ports"]] == ["px"]
async def test_load_keeps_a_size_that_no_longer_fits_this_rack(
self, client: AsyncClient, headers, db_session
):
# The mount sits at U 10 of a 12U rack. Grown to 4U elsewhere, it would
# now run past the top rail — geometry is global, a rack is not.
design_id = await self._mounted(client, headers, db_session)
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_u_height = 4
entry.rack_col_span = 12
await db_session.commit()
device = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"][0]
assert device["u_height"] == 1
# The plate itself cannot overflow a rail, so it still comes from the row.
assert device["faceplate_id"] == "switch-24"
async def test_load_keeps_a_width_that_overruns_the_column_grid(
self, client: AsyncClient, headers, db_session
):
design_id = await self._mounted(client, headers, db_session, col_start=6, col_span=6)
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_col_span = 12
await db_session.commit()
device = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"][0]
assert device["col_span"] == 6
async def test_the_tray_offers_the_saved_panel(
self, client: AsyncClient, headers, db_session
):
design_id = await self._mounted(client, headers, db_session, color="#ff6e00")
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
item = next(i for i in items if i["id"] == "inv-sw")
assert item["rack_faceplate_id"] == "switch-24"
assert item["rack_color"] == "#ff6e00"
assert [p["id"] for p in item["rack_ports"]] == ["p1"]
async def test_a_never_racked_device_reports_no_panel(
self, client: AsyncClient, headers, db_session
):
design_id = await _design(client, headers)
db_session.add(InventoryDevice(id="inv-fresh", ip="192.168.1.12", suggested_type="nas"))
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
item = next(i for i in items if i["id"] == "inv-fresh")
assert item["rack_faceplate_id"] is None
assert item["rack_ports"] == []
async def test_a_clamped_size_is_not_written_back_over_the_model(
self, client: AsyncClient, headers, db_session
):
# The mount is served 1U because 4U no longer fits above U 10. Saving the
# canvas then echoes that 1U back — and must not shrink the device in the
# rack where it really is 4U.
design_id = await self._mounted(client, headers, db_session)
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_u_height = 4
entry.rack_col_span = 12
await db_session.commit()
served = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"][0]
assert served["u_height"] == 1
state = _state(design_id, cables=[])
state["devices"] = [{**state["devices"][0], "device_id": "inv-sw", "u_height": 1}]
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
await db_session.refresh(entry)
assert entry.rack_u_height == 4
async def test_a_real_resize_still_reaches_the_model(
self, client: AsyncClient, headers, db_session
):
design_id = await self._mounted(client, headers, db_session)
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_u_height = 1
await db_session.commit()
state = _state(design_id, cables=[])
state["devices"] = [
{**state["devices"][0], "device_id": "inv-sw", "u_start": 8, "u_height": 3}
]
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
await db_session.refresh(entry)
assert entry.rack_u_height == 3
async def test_load_does_not_inflate_a_mount_onto_its_neighbour(
self, client: AsyncClient, headers, db_session
):
# A 4U model fits the rails at U 1 of a 12U rack, but another device sits
# at U 2. Two plates drawn on the same U is not a state the canvas can
# produce, so the load must not invent it.
design_id = await _design(client, headers)
db_session.add(InventoryDevice(id="inv-sw", ip="192.168.1.10", suggested_type="switch"))
await db_session.commit()
state = _state(design_id, cables=[])
state["devices"] = [
{**state["devices"][0], "device_id": "inv-sw", "u_start": 1, "u_height": 1},
{**state["devices"][1], "u_start": 2, "u_height": 1},
]
assert (
await client.post("/api/v1/racks/save", json=state, headers=headers)
).status_code == 200
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_u_height = 4
await db_session.commit()
devices = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"]
assert next(d for d in devices if d["id"] == "dev-sw")["u_height"] == 1
async def test_load_inflates_a_mount_that_has_the_room(
self, client: AsyncClient, headers, db_session
):
design_id = await self._mounted(client, headers, db_session, u_start=1, u_height=1)
entry = await db_session.get(InventoryDevice, "inv-sw")
entry.rack_u_height = 4
await db_session.commit()
device = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["devices"][0]
assert device["u_height"] == 4
class TestSaveAndLoad:
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert loaded["racks"][0]["u_height"] == 12
assert loaded["racks"][0]["style"]["showNumbers"] is True
assert {d["id"] for d in loaded["devices"]} == {"dev-sw", "dev-nas"}
assert loaded["devices"][0]["ports"][0]["type"] == "rj45"
assert loaded["cables"][0]["from_port_id"] == "p1"
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
async def test_round_trips_the_port_visibility_override(
self, client: AsyncClient, headers
):
# When a plate draws its sockets is a per-mount display choice, so it
# rides on the mount and not on the inventory row behind it.
design_id = await _design(client, headers)
state = _state(design_id, cables=[])
state["devices"][0]["port_visibility"] = "hover"
state["devices"][1]["port_visibility"] = "always"
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
by_id = {d["id"]: d for d in loaded["devices"]}
assert by_id["dev-sw"]["port_visibility"] == "hover"
assert by_id["dev-nas"]["port_visibility"] == "always"
async def test_defaults_port_visibility_to_auto(self, client: AsyncClient, headers):
# A payload from before the field existed — and any unknown value —
# keeps the old behaviour: the faceplate decides.
design_id = await _design(client, headers)
state = _state(design_id, cables=[])
state["devices"][1]["port_visibility"] = "sometimes"
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert {d["port_visibility"] for d in loaded["devices"]} == {"auto"}
async def test_keeps_a_mount_that_follows_its_node_check(
self, client: AsyncClient, headers
):
# "Check device" is stored as `auto` on the mount: the rack has no
# checker, it reads the linked node's live status at render time. The
# status column is free-form, so this only guards it staying that way.
design_id = await _design(client, headers)
state = _state(design_id)
state["devices"][0]["status"] = "auto"
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
mount = next(d for d in loaded["devices"] if d["id"] == "dev-sw")
assert mount["status"] == "auto"
async def test_prunes_what_the_client_dropped(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
trimmed = _state(design_id)
trimmed["devices"] = [d for d in trimmed["devices"] if d["id"] == "dev-sw"]
trimmed["cables"] = []
await client.post("/api/v1/racks/save", json=trimmed, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert {d["id"] for d in loaded["devices"]} == {"dev-sw"}
assert loaded["cables"] == []
async def test_updates_an_existing_device_in_place(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
moved = _state(design_id)
moved["devices"][1]["u_start"] = 1
moved["devices"][1]["label"] = "nas-renamed"
await client.post("/api/v1/racks/save", json=moved, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
nas = next(d for d in loaded["devices"] if d["id"] == "dev-nas")
assert (nas["u_start"], nas["label"]) == (1, "nas-renamed")
async def test_keeps_designs_isolated(self, client: AsyncClient, headers):
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={second}", headers=headers)).json()
assert loaded["racks"] == []
assert loaded["devices"] == []
async def test_refuses_to_steal_a_row_from_another_design(
self, client: AsyncClient, headers
):
"""Ids come from the client, so a stale tab or a copy can collide.
The upsert sets `design_id` from the payload: without a guard, saving
design B with an id owned by design A moved A's rack — devices and
cables with it — into B, and A silently lost it.
"""
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
res = await client.post("/api/v1/racks/save", json=_state(second), headers=headers)
assert res.status_code == 409
kept = (await client.get(f"/api/v1/racks?design_id={first}", headers=headers)).json()
assert [r["id"] for r in kept["racks"]] == ["rack-1"]
assert len(kept["devices"]) == 2
async def test_rejects_an_unknown_design(self, client: AsyncClient, headers):
res = await client.post("/api/v1/racks/save", json=_state("nope"), headers=headers)
assert res.status_code == 404
async def test_rejects_a_device_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["devices"][0]["rack_id"] = "rack-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
async def test_rejects_a_cable_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["cables"][0]["to_device_id"] = "dev-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
@pytest.mark.parametrize(
"patch",
[
{"u_height": 0},
{"width_standard": "23"},
{"numbering": "sideways"},
],
)
async def test_rejects_invalid_rack_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["racks"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
@pytest.mark.parametrize(
"patch",
[
{"u_start": 0},
{"col_start": 12},
{"col_span": 13},
],
)
async def test_rejects_invalid_device_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_taller_than_its_rack(self, client: AsyncClient, headers):
# Each field is legal on its own; only the rack says otherwise. A mount
# above the top rail draws outside the chassis with no way to drag it back.
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0]["u_start"] = 40 # rack is 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_whose_height_overruns_the_rack(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 4}) # 11..14 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_device_that_ends_on_the_top_rail(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 2}) # 11..12 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_a_span_that_overruns_the_column_grid(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 11, "col_span": 12})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_half_width_pair_sharing_one_u(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 0, "col_span": 6})
payload["devices"][1].update({"col_start": 6, "col_span": 6})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_an_unknown_cable_type(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["type"] = "power"
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_round_trips_cable_annotations(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0].update(
{
"label": "Uplink to core",
"label_visible": True,
"properties": [
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
{"key": "VLAN", "value": "20", "icon": None, "visible": False},
],
}
)
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label"] == "Uplink to core"
assert cable["label_visible"] is True
assert [p["key"] for p in cable["properties"]] == ["Length", "VLAN"]
assert cable["properties"][1]["visible"] is False
async def test_defaults_cable_annotations_when_absent(
self, client: AsyncClient, headers
):
# A client that predates the feature sends neither key; the response must
# still carry usable values rather than nulls.
design_id = await _design(client, headers)
assert (
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label_visible"] is False
assert cable["properties"] == []
async def test_drops_cable_properties_with_no_key(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": " ", "value": "orphan", "icon": None, "visible": True},
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
]
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert [p["key"] for p in cable["properties"]] == ["Length"]
async def test_updates_cable_annotations_on_a_second_save(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": "Length", "value": "2 m", "icon": None, "visible": True}
]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
payload["cables"][0]["properties"] = []
payload["cables"][0]["label_visible"] = True
await client.post("/api/v1/racks/save", json=payload, headers=headers)
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["properties"] == []
assert cable["label_visible"] is True
async def test_requires_auth(self, client: AsyncClient):
assert (await client.get("/api/v1/racks?design_id=any")).status_code == 401
class TestInventory:
async def test_lists_rackable_devices_only(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pve-01", "suggested_type": "proxmox"},
headers=headers,
)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "phone", "suggested_type": "mobile"},
headers=headers,
)
res = await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
labels = [i["label"] for i in res.json()["items"]]
assert "pve-01" in labels
assert "phone" not in labels
async def test_names_a_device_the_way_the_inventory_does(
self, client: AsyncClient, headers, db_session
):
"""A scan find with no hostname is named after the app it runs, not its IP.
The rack picker used to show "192.168.1.63 · 192.168.1.63" where the
Device Inventory showed "jellyfin".
"""
design_id = await _design(client, headers)
db_session.add(
InventoryDevice(
id="pd-services",
ip="192.168.1.63",
services=[
{"port": 443, "category": "web", "service_name": "https"},
{"port": 8096, "category": "media", "service_name": "jellyfin"},
],
suggested_type="server",
)
)
db_session.add(InventoryDevice(id="pd-bare", ip="192.168.1.64", suggested_type="server"))
# A portless service is no fingerprint: the inventory skips it, so must
# this — `None not in _COMMON_PORTS` is True and used to let it through.
db_session.add(
InventoryDevice(
id="pd-portless",
ip="192.168.1.65",
services=[{"port": None, "category": "media", "service_name": "jellyfin"}],
suggested_type="server",
)
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == "pd-services")["label"] == "jellyfin"
# Nothing better to say: the IP stays the label, and the UI stops doubling it.
assert next(i for i in items if i["id"] == "pd-bare")["label"] == "192.168.1.64"
assert next(i for i in items if i["id"] == "pd-portless")["label"] == "192.168.1.65"
async def test_flags_devices_already_mounted(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == created["id"])["racked"] is True
async def test_resolves_the_canvas_node_for_live_status(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.9", "status": "online"},
headers=headers,
)
).json()
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.9", "suggested_type": "nas"},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["label"] == "nas")
assert entry["node_id"] == node["id"]
assert entry["node_status"] == "online"
async def test_reports_what_the_canvas_node_knows(
self, client: AsyncClient, headers, db_session
):
"""The rack prints the logical view's facts, so the endpoint must ship them."""
rack_design = await _design(client, headers)
network = await _design(client, headers, name="Network", design_type="network")
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does. Seeded before the node so the node links to this
# row rather than minting a second one for the same host.
db_session.add(
InventoryDevice(
id="pd-nas",
hostname="nas",
ip="192.168.1.9",
mac="aa:bb:cc:dd:ee:ff",
os="TrueNAS",
suggested_type="nas",
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
)
)
await db_session.commit()
await client.post(
"/api/v1/nodes",
json={
"type": "nas",
"label": "nas-truenas",
"design_id": network,
"ip": "192.168.1.9",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "nas.lan",
"os": "TrueNAS SCALE",
"status": "online",
"check_method": "http",
},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["ip"] == "192.168.1.9")
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
assert entry["os"] == "TrueNAS"
assert entry["services"] == [{"port": 445, "name": "smb"}]
assert entry["node_label"] == "nas-truenas"
assert entry["node_type"] == "nas"
# The node no longer keeps its own copy: both views read the row, which
# the node create filled in without overwriting what the scan found.
assert entry["node_hostname"] == "nas"
assert entry["node_os"] == "TrueNAS"
assert entry["node_check_method"] == "http"
assert entry["node_design_id"] == network
assert entry["node_design_name"] == "Network"
async def test_stamps_last_seen_with_an_offset(self, client: AsyncClient, headers, db_session):
"""SQLite reads `last_seen` back naive, and a naive ISO string is *local*
time to `new Date()` — the panel printed it shifted by the viewer's offset.
"""
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "seen", "ip": "192.168.1.77"},
headers=headers,
)
).json()
await db_session.execute(
text("UPDATE device_inventory SET last_seen = :ts WHERE id = :id"),
{"ts": "2026-08-08 10:00:00.000000", "id": node["device_id"]},
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == node["device_id"])
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
"""A link the user picked in the rack beats the IEEE/IP guess.
The guess is what failed on gear with no MAC and no address, and it is
wrong — not merely absent — when two hosts have swapped IPs.
"""
design_id = await _design(client, headers)
picked = (
await client.post(
"/api/v1/nodes",
json={"type": "pdu", "label": "pdu-main", "status": "online"},
headers=headers,
)
).json()
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "someone-else", "ip": "192.168.1.90"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu", "ip": "192.168.1.90", "suggested_type": "generic"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = picked["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == device["id"])
assert entry["node_id"] == picked["id"] != guessed["id"]
assert entry["node_label"] == "pdu-main"
assert entry["node_status"] == "online"
async def test_falls_back_when_the_named_node_is_gone(self, client: AsyncClient, headers):
"""A mount keeps its `node_id` in the payload the client last sent, so a
node deleted meanwhile must not report a node that no longer exists.
"""
design_id = await _design(client, headers)
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.91"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.91", "suggested_type": "nas"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = "node-that-was-deleted"
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == device["id"])
assert entry["node_id"] == guessed["id"]
async def test_leaves_the_node_fields_empty_without_a_match(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu-main", "suggested_type": "generic"},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["label"] == "pdu-main")
assert entry["node_id"] is None
assert entry["node_label"] is None
assert entry["node_design_name"] is None
assert entry["services"] == []
async def test_reports_the_services_a_canvas_added(self, client: AsyncClient, headers, db_session):
"""Services documented on a canvas reach the rack — they are the device's.
The scan found the host but no services; the user typed one on the
logical canvas, and the rack prints it without the node being consulted.
"""
design_id = await _design(client, headers)
db_session.add(
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
)
await db_session.commit()
await client.post(
"/api/v1/nodes",
json={
"type": "server",
"label": "media",
"ip": "192.168.1.63",
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == "pd-noservices")
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "patch-panel", "suggested_type": "generic"},
headers=headers,
)
).json()
assert created["discovery_source"] == "manual"
assert created["status"] == "pending"
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert any(d["id"] == created["id"] for d in listed)
class TestRackSourcedInventory:
"""Gear created from a rack canvas shares the Device Inventory, but never a
logical canvas: it documents a mount, not a host."""
async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"):
return (
await client.post(
"/api/v1/scan/pending",
json={"hostname": hostname, "discovery_source": "rack"},
headers=headers,
)
).json()
async def test_records_the_rack_source(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
assert created["discovery_source"] == "rack"
assert created["discovery_sources"] == ["rack"]
async def test_rejects_an_unknown_source(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/scan/pending",
json={"hostname": "spoof", "discovery_source": "zigbee"},
headers=headers,
)
assert res.status_code == 422
async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
res = await client.post(
f"/api/v1/scan/pending/{created['id']}/approve",
json={"type": "generic", "label": "patch-house"},
headers=headers,
)
assert res.status_code == 409
assert "rack" in res.json()["detail"].lower()
async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers):
rack_device = await self._rack_device(client, headers)
scanned = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"},
headers=headers,
)
).json()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [rack_device["id"], scanned["id"]]},
headers=headers,
)
body = res.json()
assert body["approved"] == 1
assert body["device_ids"] == [scanned["id"]]
skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"])
assert skipped["match"] == "rack"
async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = await self._rack_device(client, headers, hostname="blank-panel")
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert any(i["id"] == created["id"] for i in items)
class TestDesignLifecycle:
async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers):
keeper = await _design(client, headers, name="Keeper", design_type="network")
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert (await client.delete(f"/api/v1/designs/{design_id}", headers=headers)).status_code == 204
assert (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).status_code == 404
# The other design is untouched.
assert (await client.get(f"/api/v1/racks?design_id={keeper}", headers=headers)).status_code == 200
async def test_unmounting_keeps_the_inventory_entry(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
# Unmount: the device disappears from the rack payload entirely.
unmounted = _state(design_id)
unmounted["devices"] = [d for d in unmounted["devices"] if d["id"] != "dev-sw"]
unmounted["cables"] = []
await client.post("/api/v1/racks/save", json=unmounted, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == created["id"])
assert entry["racked"] is False
async def test_copying_a_rack_design_duplicates_its_racks(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
copy = (
await client.post(
f"/api/v1/designs/{design_id}/copy",
json={"name": "Rack Room (copy)", "icon": "server"},
headers=headers,
)
).json()
assert copy["design_type"] == "rack"
loaded = (await client.get(f"/api/v1/racks?design_id={copy['id']}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert len(loaded["devices"]) == 2
assert len(loaded["cables"]) == 1
# Fresh ids, so editing the copy cannot touch the original.
assert loaded["racks"][0]["id"] != "rack-1"
assert {d["id"] for d in loaded["devices"]}.isdisjoint({"dev-sw", "dev-nas"})
# Cables still join the copied devices, not the originals.
copied_ids = {d["id"] for d in loaded["devices"]}
assert loaded["cables"][0]["from_device_id"] in copied_ids
assert loaded["cables"][0]["to_device_id"] in copied_ids
class TestOverlappingMounts:
async def test_rejects_two_mounts_claiming_the_same_u(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
state = _state(design_id, cables=[])
state["devices"][1]["u_start"] = 10 # straight onto dev-sw
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 422
assert "overlap" in res.text
async def test_accepts_two_mounts_sharing_a_u_side_by_side(
self, client: AsyncClient, headers
):
# Half-width plates on the same U are the whole point of the column grid.
design_id = await _design(client, headers)
state = _state(design_id, cables=[])
state["devices"][0].update(col_start=0, col_span=6)
state["devices"][1].update(u_start=10, u_height=1, col_start=6, col_span=6)
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
async def test_lets_two_racks_use_the_same_u(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
state = _state(design_id, cables=[])
state["racks"].append({**state["racks"][0], "id": "rack-2", "name": "Second"})
state["devices"][1].update(rack_id="rack-2", u_start=10, u_height=1)
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text