Files
homelable/mcp/tests/test_devices.py
T
Pouzor 4c8831d5df feat(mcp): expose the rack canvas and the inventory write routes
The MCP server covered the logical canvas only. A client could create a
design whose design_type is `rack` and then had no way to put a rack, a
mount or a cable in it, and it could triage the Device Inventory but
never create, edit or delete an entry — though every route already
existed. 24 tools become 47.

Racks (mcp/app/racks.py), 13 tools: list_racks, get_rack,
list_rack_inventory, list_faceplates, create_rack, update_rack,
delete_rack, mount_device, mount_accessory, unmount_device, move_device,
set_device_faceplate, patch_cable, unpatch_cable.

`POST /api/v1/racks/save` is full-state upsert-and-prune, so every write
is load, mutate, save it all back. Two traps are encoded once, in the
helpers: the response rows carry `design_id` and the save schemas do not
(echoing one back verbatim is a 422), and the viewport has to be echoed
or the save resets the user's pan and zoom. The plate, its colour and its
ports need no special handling — /racks/save writes those through to the
inventory row itself.

Two frontend files are copied rather than imported, since the MCP server
is a separate service:

  - mcp/app/faceplates.py — the 21-plate catalog from
    frontend/src/rack/faceplates.ts, data only. `bank()` reproduces the
    port arithmetic exactly, so a plate seeded here lands its ports where
    the renderer draws them.
  - mcp/app/rack_layout.py — canPlace / findSlot / freeUnits from
    frontend/src/rack/layout.ts, to *choose* a slot. The backend still
    polices the one it is sent.

Both are guarded against drift: test_faceplates_sync.py parses
faceplates.ts and compares every plate down to each port's coordinates,
the way test_node_types_sync.py guards NODE_TYPES, and
test_layout_parity.py runs the frontend's own layout cases against the
Python port.

Devices (mcp/app/devices.py), 10 tools: create_device, update_device,
delete_device, bulk_approve_devices, bulk_hide_devices,
bulk_restore_devices, rescan_device, list_proxmox_children,
get_scan_config, update_scan_config.

Verified against a running backend: the rack flow end to end (create,
mount, patch, move, swap a plate, shrink, unmount) plus every refusal
path, and the device tools including a byte-identical scan-config
write-back.

ha-relevant: no
2026-09-04 15:10:05 +02:00

139 lines
4.9 KiB
Python

import pytest
from unittest.mock import AsyncMock, patch
from app.devices import DEVICE_TOOLS, dispatch_device
@pytest.fixture
def mock_backend():
with patch("app.devices.backend") as m:
m.post = AsyncMock(return_value={"id": "d1"})
m.patch = AsyncMock(return_value={"id": "d1"})
m.delete = AsyncMock(return_value={"deleted": True})
m.get = AsyncMock(return_value=[])
yield m
@pytest.mark.anyio
async def test_create_device(mock_backend):
args = {"hostname": "sw-core", "suggested_type": "switch", "ip": "192.168.1.2"}
result = await dispatch_device("create_device", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/scan/pending", args)
assert result == {"id": "d1"}
@pytest.mark.anyio
async def test_create_device_from_a_rack(mock_backend):
await dispatch_device(
"create_device", {"hostname": "patch-a", "discovery_source": "rack"}
)
_, body = mock_backend.post.call_args[0]
assert body["discovery_source"] == "rack"
@pytest.mark.anyio
async def test_update_device_sends_everything_but_the_id(mock_backend):
await dispatch_device("update_device", {"id": "d1", "notes": "in the garage", "ram_gb": 64})
mock_backend.patch.assert_called_once_with(
"/api/v1/scan/pending/d1", {"notes": "in the garage", "ram_gb": 64}
)
@pytest.mark.anyio
async def test_update_device_carries_the_rack_model(mock_backend):
"""The faceplate belongs to the inventory row, so it is edited here too."""
await dispatch_device("update_device", {
"id": "d1", "rack_faceplate_id": "switch-48", "rack_u_height": 1, "rack_col_span": 12,
})
_, body = mock_backend.patch.call_args[0]
assert body["rack_faceplate_id"] == "switch-48"
@pytest.mark.anyio
async def test_delete_device(mock_backend):
await dispatch_device("delete_device", {"id": "d1"})
mock_backend.delete.assert_called_once_with("/api/v1/scan/pending/d1")
@pytest.mark.anyio
async def test_bulk_approve_devices(mock_backend):
await dispatch_device(
"bulk_approve_devices", {"device_ids": ["a", "b"], "design_id": "canvas-1"}
)
mock_backend.post.assert_called_once_with(
"/api/v1/scan/pending/bulk-approve", {"device_ids": ["a", "b"], "design_id": "canvas-1"}
)
@pytest.mark.anyio
async def test_bulk_approve_devices_without_a_design(mock_backend):
"""No design_id means the backend's default (first) canvas — sending a null
would not be the same thing."""
await dispatch_device("bulk_approve_devices", {"device_ids": ["a"]})
_, body = mock_backend.post.call_args[0]
assert body == {"device_ids": ["a"]}
@pytest.mark.anyio
async def test_bulk_hide_devices(mock_backend):
await dispatch_device("bulk_hide_devices", {"device_ids": ["a", "b"]})
mock_backend.post.assert_called_once_with(
"/api/v1/scan/pending/bulk-hide", {"device_ids": ["a", "b"]}
)
@pytest.mark.anyio
async def test_bulk_restore_devices(mock_backend):
await dispatch_device("bulk_restore_devices", {"device_ids": ["a"]})
mock_backend.post.assert_called_once_with(
"/api/v1/scan/pending/bulk-restore", {"device_ids": ["a"]}
)
@pytest.mark.anyio
async def test_rescan_device(mock_backend):
await dispatch_device("rescan_device", {"id": "d1", "ports": "80,443", "verify_tls": True})
mock_backend.post.assert_called_once_with(
"/api/v1/scan/pending/d1/rescan", {"ports": "80,443", "verify_tls": True}
)
@pytest.mark.anyio
async def test_list_proxmox_children(mock_backend):
await dispatch_device("list_proxmox_children", {"id": "pve1"})
mock_backend.get.assert_called_once_with("/api/v1/scan/pending/pve1/proxmox-children")
@pytest.mark.anyio
async def test_get_scan_config(mock_backend):
await dispatch_device("get_scan_config", {})
mock_backend.get.assert_called_once_with("/api/v1/scan/config")
@pytest.mark.anyio
async def test_update_scan_config(mock_backend):
args = {"ranges": ["192.168.1.0/24"], "http_probe_enabled": True}
await dispatch_device("update_scan_config", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/scan/config", args)
@pytest.mark.anyio
async def test_unknown_device_tool(mock_backend):
with pytest.raises(ValueError, match="Unknown device tool"):
await dispatch_device("frobnicate_device", {})
def test_create_device_only_offers_the_manual_sources():
"""`rack` and `manual` are the only sources the backend accepts by hand —
a device may not claim to have been discovered by a scan."""
create = next(t for t in DEVICE_TOOLS if t.name == "create_device")
assert create.inputSchema["properties"]["discovery_source"]["enum"] == ["manual", "rack"]
@pytest.mark.anyio
async def test_device_tools_are_reachable_from_the_main_dispatch(mock_backend):
from app.tools import _dispatch
await _dispatch("delete_device", {"id": "d1"})
mock_backend.delete.assert_called_once_with("/api/v1/scan/pending/d1")