Files
homelable/backend/tests/test_stats.py
T
Pouzor 9954fccf75 feat(rack): link a mount to any Device Inventory entry
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
2026-08-10 00:23:10 +02:00

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")