The rack's link picker read `/nodes`, so it only ever offered devices
someone had already approved onto a logical canvas — two rows on a homelab
holding 74 inventory entries. A device on no canvas is still the record of
a real box, and is exactly what a rack is built out of.
`DevicePickerModal` replaces `NodePickerModal` and lists the Device
Inventory itself. Picking an entry calls the new `relinkDevice`, which
repoints the mount's `deviceId`, adopts that entry's node, status and —
unless the user renamed the plate — its label. One entry, one mount: a row
another plate stands for is not offered, and the store refuses it anyway.
The placeholder a rack-created plate left behind is dropped through the new
`DELETE /api/v1/scan/pending/{id}`, which refuses a device a rack still
mounts (409): foreign keys are off at runtime, so the mount would be left
naming a row that no longer exists.
`LinkedDevicePanel` becomes "Linked device" and now prints what discovery
found even when nothing on a canvas answers for the device; only the
canvas-side rows go missing, under a "Not on a logical canvas." note.
Also renames `pending_devices` to `device_inventory` (and
`pending_device_links` to `device_inventory_links`), with the Python and
TypeScript names that followed it. "Pending devices" was the scanner's word
for a queue of finds awaiting approval; the rows outlive approval, are
edited by hand and are what a rack mounts. Routes, payload keys and MCP
tool names are a published contract and are unchanged — `/scan/pending/*`
and the `pending_devices` key in `/stats` stay as they are.
The rename migration runs before `create_all`, or an empty new table would
be created beside the populated old one and every scanned device would read
as gone; it repairs that state too, for anyone whose app already started
mid-upgrade. Foreign keys are switched on for the rename so SQLite rewrites
the `REFERENCES` clause in `rack_devices`.
ha-relevant: maybe
101 lines
3.2 KiB
Python
101 lines
3.2 KiB
Python
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.db.models import InventoryDevice, Node, ScanRun
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_homepage_key():
|
|
original = settings.homepage_api_key
|
|
settings.homepage_api_key = ""
|
|
yield
|
|
settings.homepage_api_key = original
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
|
res = await client.get("/api/v1/stats/summary")
|
|
assert res.status_code == 403
|
|
assert "disabled" in res.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
|
settings.homepage_api_key = "topsecret"
|
|
res = await client.get("/api/v1/stats/summary")
|
|
assert res.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
|
settings.homepage_api_key = "topsecret"
|
|
res = await client.get(
|
|
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
|
)
|
|
assert res.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_empty_db(client: AsyncClient) -> None:
|
|
settings.homepage_api_key = "topsecret"
|
|
res = await client.get(
|
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
|
)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert body == {
|
|
"nodes": 0,
|
|
"online": 0,
|
|
"offline": 0,
|
|
"unknown": 0,
|
|
"pending_devices": 0,
|
|
"zigbee_devices": 0,
|
|
"last_scan_at": None,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_aggregates_counts(
|
|
client: AsyncClient, db_session: AsyncSession
|
|
) -> None:
|
|
settings.homepage_api_key = "topsecret"
|
|
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
|
db_session.add_all([
|
|
Node(type="server", label="A", status="online"),
|
|
Node(type="server", label="B", status="online"),
|
|
Node(type="server", label="C", status="offline"),
|
|
Node(type="server", label="D", status="unknown"),
|
|
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
|
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
|
InventoryDevice(ip="10.0.0.1", status="pending"),
|
|
InventoryDevice(ip="10.0.0.2", status="pending"),
|
|
InventoryDevice(ip="10.0.0.3", status="hidden"), # excluded
|
|
ScanRun(status="success", finished_at=finished),
|
|
ScanRun(status="success",
|
|
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
|
])
|
|
await db_session.commit()
|
|
|
|
res = await client.get(
|
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
|
)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert body["nodes"] == 6
|
|
assert body["online"] == 4
|
|
assert body["offline"] == 1
|
|
assert body["unknown"] == 1
|
|
assert body["pending_devices"] == 2
|
|
assert body["zigbee_devices"] == 2
|
|
# SQLite returns naive datetimes; compare prefix only.
|
|
assert body["last_scan_at"] is not None
|
|
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|