Merge pull request #148 from Pouzor/feat/zigbee-properties

feat(zigbee): IEEE/Vendor/Model/LQI properties + security + tests
This commit is contained in:
Rémy
2026-05-14 13:47:12 +02:00
committed by GitHub
20 changed files with 1005 additions and 103 deletions
+15 -5
View File
@@ -15,6 +15,9 @@ from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan
from app.services.zigbee_service import build_zigbee_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
class BulkActionRequest(BaseModel):
@@ -125,17 +128,22 @@ async def bulk_approve_devices(
created_nodes: list[Node] = []
for device in devices:
device.status = "approved"
node_type = device.suggested_type or "generic"
is_zigbee = node_type in _ZIGBEE_TYPES
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=device.suggested_type or "generic",
type=node_type,
ip=device.ip,
hostname=device.hostname,
status="unknown",
status="online" if is_zigbee else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi
) if is_zigbee else [],
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="ping" if device.ip else None,
check_method="none" if is_zigbee else ("ping" if device.ip else None),
)
db.add(node)
created_nodes.append(node)
@@ -225,8 +233,7 @@ async def approve_device(
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
_zigbee_types = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_is_zigbee = node_data.type in _zigbee_types
_is_zigbee = node_data.type in _ZIGBEE_TYPES
node = Node(
label=node_data.label,
type=node_data.type,
@@ -235,6 +242,9 @@ async def approve_device(
status="online" if _is_zigbee else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi
) if _is_zigbee else (node_data.properties or []),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target,
)
+27 -1
View File
@@ -23,7 +23,12 @@ from app.schemas.zigbee import (
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
from app.services.zigbee_service import (
build_zigbee_properties,
fetch_networkmap,
merge_zigbee_properties,
test_mqtt_connection,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -142,10 +147,17 @@ async def _persist_pending_import(
ieee = n.get("ieee_address")
if not ieee:
continue
props = build_zigbee_properties(
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
)
if n.get("device_type") == "Coordinator":
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
existing_node = existing.scalar_one_or_none()
if existing_node:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
coordinator_out = ZigbeeCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
@@ -161,6 +173,7 @@ async def _persist_pending_import(
check_method="none",
ieee_address=ieee,
services=[],
properties=props,
)
db.add(node)
await db.flush()
@@ -169,6 +182,19 @@ async def _persist_pending_import(
)
continue
# If the device has already been approved as a canvas Node, refresh
# its properties and skip creating a pending row (keeps approved
# devices out of pending/hidden modals on re-import).
existing_node_q = await db.execute(
select(Node).where(Node.ieee_address == ieee)
)
existing_node = existing_node_q.scalar_one_or_none()
if existing_node:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
+6 -6
View File
@@ -1,22 +1,22 @@
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
if not plain or not hashed:
return False
try:
return bool(pwd_context.verify(plain, hashed))
except ValueError:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def hash_password(password: str) -> str:
return str(pwd_context.hash(password))
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_access_token(subject: str) -> str:
+5
View File
@@ -24,6 +24,11 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
host = target or raw_ip
if not host:
return {"status": "unknown", "response_time_ms": None}
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
# against arg-injection if a malicious admin sets target like "-O".
if host.startswith("-"):
logger.warning("Rejecting check target that starts with '-': %r", host)
return {"status": "unknown", "response_time_ms": None}
start = time.monotonic()
try:
+48
View File
@@ -58,6 +58,54 @@ def _build_tls_context(insecure: bool) -> ssl.SSLContext:
return ctx
def build_zigbee_properties(
ieee: str | None,
vendor: str | None,
model: str | None,
lqi: int | None,
) -> list[dict[str, Any]]:
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
Only includes a row when the value is non-empty. Shape matches the
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
New props default to ``visible=False`` — users opt in to showing them on
the canvas card from the right panel.
"""
props: list[dict[str, Any]] = []
if ieee:
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
if vendor:
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
if model:
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
if lqi is not None:
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
return props
def merge_zigbee_properties(
existing: list[dict[str, Any]] | None,
new_props: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Merge fresh zigbee props into an existing property list.
For keys already present: update ``value`` but preserve the user's
``visible`` choice. New keys are appended with whatever visibility the
caller gave them (hidden by default per ``build_zigbee_properties``).
Non-zigbee custom properties are preserved untouched.
"""
out = [dict(p) for p in (existing or [])]
by_key = {p.get("key"): p for p in out}
for np in new_props:
key = np.get("key")
if key in by_key:
by_key[key]["value"] = np.get("value")
else:
out.append(dict(np))
return out
def _z2m_type_to_homelable(device_type: str) -> str:
"""Map a Z2M device type string to a homelable node type."""
mapping = {
+1 -2
View File
@@ -7,8 +7,7 @@ alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
bcrypt==4.2.1
python-multipart==0.0.27
apscheduler==3.10.4
python-nmap==0.7.1
+3 -5
View File
@@ -1,13 +1,11 @@
"""Generate a bcrypt password hash for config.yml."""
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
import sys
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
import bcrypt
if len(sys.argv) < 2:
print("Usage: python scripts/hash_password.py <password>")
sys.exit(1)
password = sys.argv[1]
print(pwd_context.hash(password))
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
+2 -4
View File
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from httpx import ASGITransport, AsyncClient
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.security import hash_password
from app.db.database import Base, get_db
from app.main import app
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
@pytest.fixture(autouse=True, scope="session")
def test_credentials():
"""Configure test auth credentials directly on settings."""
from app.core.config import settings
settings.auth_username = "admin"
settings.auth_password_hash = _pwd_ctx.hash("admin")
settings.auth_password_hash = hash_password("admin")
@pytest.fixture
+68
View File
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
assert res.status_code == 401
finally:
settings.auth_password_hash = original
# --- JWT-level cases ---
async def test_expired_token_rejected(client: AsyncClient):
"""A JWT whose `exp` is in the past must be refused."""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
}
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
async def test_malformed_token_rejected(client: AsyncClient):
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
assert res.status_code == 401
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
"""A token signed with a different key must not be accepted."""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
assert res.status_code == 401
async def test_missing_authorization_header_rejected(client: AsyncClient):
res = await client.get("/api/v1/nodes")
assert res.status_code == 401
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
"""No credentials configured server-side must not authenticate an empty password."""
from app.core.config import settings
original_hash = settings.auth_password_hash
settings.auth_password_hash = ""
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
assert res.status_code == 401
finally:
settings.auth_password_hash = original_hash
# --- Password helper ---
def test_verify_password_handles_empty_inputs():
"""verify_password must be safe against empty plain / empty hash without raising."""
from app.core.security import hash_password, verify_password
h = hash_password("hunter2")
assert verify_password("hunter2", h) is True
assert verify_password("", h) is False
assert verify_password("hunter2", "") is False
assert verify_password("", "") is False
+170
View File
@@ -37,6 +37,95 @@ async def pending_device(db_session):
return device
# --- _background_scan error handling ---
@pytest.fixture
async def mem_db():
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.database import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
yield factory
await engine.dispose()
@pytest.mark.asyncio
async def test_background_scan_marks_run_failed_on_exception(mem_db):
"""If run_scan() raises, the ScanRun must transition running → failed and the
session rollback path must execute without a follow-on exception."""
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.scan.run_scan",
new_callable=AsyncMock,
side_effect=RuntimeError("boom"),
),
):
await _background_scan(run_id, ["10.0.0.0/24"])
async with mem_db() as session:
refreshed = await session.get(ScanRun, run_id)
assert refreshed is not None
assert refreshed.status == "failed"
@pytest.mark.asyncio
async def test_background_scan_leaves_non_running_status_alone(mem_db):
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
must NOT overwrite that terminal status with 'failed'."""
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.scan.run_scan",
new_callable=AsyncMock,
side_effect=RuntimeError("boom"),
),
):
await _background_scan(run_id, ["10.0.0.0/24"])
async with mem_db() as session:
refreshed = await session.get(ScanRun, run_id)
assert refreshed is not None
assert refreshed.status == "cancelled"
@pytest.mark.asyncio
async def test_background_scan_success_path_invokes_run_scan(mem_db):
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
):
await _background_scan(run_id, ["10.0.0.0/24"])
mock_run_scan.assert_awaited_once()
# --- Trigger scan ---
@pytest.mark.asyncio
@@ -528,6 +617,87 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert pending_res.json() == []
@pytest.fixture
async def zigbee_pending_device(db_session):
device = PendingDevice(
id=str(uuid.uuid4()),
ip=None,
mac=None,
hostname=None,
friendly_name="bulb_1",
services=[],
suggested_type="zigbee_enddevice",
device_subtype="EndDevice",
ieee_address="0xABCDEF",
vendor="IKEA",
model="TRADFRI",
lqi=180,
status="pending",
discovery_source="zigbee",
)
db_session.add(device)
await db_session.commit()
await db_session.refresh(device)
return device
@pytest.mark.asyncio
async def test_approve_zigbee_device_populates_properties(
client: AsyncClient, headers, zigbee_pending_device, db_session
):
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
payload = {
"label": "bulb_1",
"type": "zigbee_enddevice",
"status": "online",
"services": [],
"check_method": "none",
}
res = await client.post(
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
json=payload,
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
assert keys == {
"IEEE": "0xABCDEF",
"Vendor": "IKEA",
"Model": "TRADFRI",
"LQI": "180",
}
@pytest.mark.asyncio
async def test_bulk_approve_zigbee_populates_properties(
client: AsyncClient, headers, zigbee_pending_device, db_session
):
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [zigbee_pending_device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
assert keys["IEEE"] == "0xABCDEF"
assert keys["Vendor"] == "IKEA"
assert keys["Model"] == "TRADFRI"
assert keys["LQI"] == "180"
assert node.check_method == "none"
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
+25
View File
@@ -216,6 +216,31 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"]
# --- check_node target validation ---
@pytest.mark.asyncio
async def test_check_node_rejects_flag_like_target():
"""A target starting with '-' must never reach subprocess invocation."""
from app.services.status_checker import check_node
with patch("asyncio.create_subprocess_exec") as mock_exec:
result = await check_node("ping", "-O", None)
mock_exec.assert_not_called()
assert result["status"] == "unknown"
@pytest.mark.asyncio
async def test_check_node_rejects_flag_like_ip():
from app.services.status_checker import check_node
with patch("asyncio.create_subprocess_exec") as mock_exec:
result = await check_node("ping", None, "-O")
mock_exec.assert_not_called()
assert result["status"] == "unknown"
# --- _tcp_connect ---
@pytest.mark.asyncio
+144
View File
@@ -388,6 +388,150 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
@pytest.mark.asyncio
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
nodes_with_meta[0]["vendor"] = "TI"
nodes_with_meta[0]["model"] = "CC2652"
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
coord = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
).scalar_one()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
# New zigbee props default to hidden — user opts in from the right panel.
assert all(p["visible"] is False for p in coord.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_skips_pending_for_approved_node(
db_session,
) -> None:
"""A device already approved as a canvas Node must not reappear in pending.
Its properties must still be refreshed with the latest Vendor/Model/LQI.
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
# Simulate: router was approved earlier → exists as a canvas Node.
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
ieee_address="0xR1",
services=[],
properties=[],
)
db_session.add(approved)
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 250 # new LQI from re-import
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
# No PendingDevice row was created for the approved router.
pendings = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalars().all()
assert pendings == []
# Node properties got refreshed.
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
# Brand-new props on an existing Node start hidden.
assert all(p["visible"] is False for p in refreshed.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
"""If user has already made props visible, re-import must not flip them back."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
ieee_address="0xR1",
services=[],
properties=[
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
],
)
db_session.add(approved)
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
bumped[1]["model"] = "CC2530"
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
).scalar_one()
by_key = {p["key"]: p for p in refreshed.properties}
# Existing keys keep their visibility (True).
assert by_key["IEEE"]["visible"] is True
assert by_key["Vendor"]["visible"] is True
# New key arrives hidden.
assert by_key["Model"]["visible"] is False
assert by_key["LQI"]["visible"] is False
assert by_key["LQI"]["value"] == "99"
# Non-zigbee user-added prop is preserved untouched.
assert by_key["Custom"]["value"] == "kept"
assert by_key["Custom"]["visible"] is True
@pytest.mark.asyncio
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
db_session,
) -> None:
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[0]["vendor"] = "TI"
bumped[0]["model"] = "CC2652"
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
coord = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
).scalar_one()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys["Vendor"] == "TI"
assert keys["Model"] == "CC2652"
# Newly added keys on re-import default to hidden.
by_key = {p["key"]: p for p in coord.properties}
assert by_key["Vendor"]["visible"] is False
assert by_key["Model"]["visible"] is False
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
-6
View File
@@ -552,9 +552,6 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -565,9 +562,6 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
parentContainerNodes={nodes
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
<EdgeModal
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
type Interceptor<T> = {
fulfilled?: (v: T) => T | Promise<T>
rejected?: (e: unknown) => unknown
}
interface MockInstance {
defaults: { baseURL?: string }
interceptors: {
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
}
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
patch: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
__req: Interceptor<{ headers: Record<string, string> }>
__res: Interceptor<unknown>
}
const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
const instances = hoisted.instances as MockInstance[]
vi.mock('axios', () => {
return {
default: {
create: (cfg: { baseURL?: string }) => {
const inst: MockInstance = {
defaults: { baseURL: cfg?.baseURL },
interceptors: {
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
},
get: vi.fn(() => Promise.resolve({ data: {} })),
post: vi.fn(() => Promise.resolve({ data: {} })),
patch: vi.fn(() => Promise.resolve({ data: {} })),
delete: vi.fn(() => Promise.resolve({ data: {} })),
__req: {},
__res: {},
}
hoisted.instances.push(inst)
return inst
},
},
}
})
import { useAuthStore } from '@/stores/authStore'
import * as clientModule from '../client'
describe('api/client', () => {
const mod = clientModule
const [api, publicApi] = instances
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
api.get.mockClear()
api.post.mockClear()
api.patch.mockClear()
api.delete.mockClear()
publicApi.get.mockClear()
publicApi.post.mockClear()
})
it('creates two axios instances with /api/v1 baseURL', () => {
expect(instances).toHaveLength(2)
expect(api.defaults.baseURL).toBe('/api/v1')
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
it('exports `api` matching the first created instance', () => {
expect(mod.api).toBe(api)
})
it('request interceptor adds Authorization header when token present', () => {
useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
const cfg = { headers: {} as Record<string, string> }
const out = api.__req.fulfilled!(cfg)
expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
})
it('request interceptor leaves headers untouched when no token', () => {
const cfg = { headers: {} as Record<string, string> }
const out = api.__req.fulfilled!(cfg)
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
})
it('response interceptor passes through 2xx responses', () => {
const r = { status: 200, data: { ok: true } }
expect(api.__res.fulfilled!(r)).toBe(r)
})
it('response interceptor calls logout on 401', async () => {
const logout = vi.spyOn(useAuthStore.getState(), 'logout')
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
const err = { response: { status: 401 } }
await expect(api.__res.rejected!(err)).rejects.toBe(err)
expect(logout).toHaveBeenCalled()
})
it('response interceptor does not call logout on non-401', async () => {
const logout = vi.fn()
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
const err = { response: { status: 500 } }
await expect(api.__res.rejected!(err)).rejects.toBe(err)
expect(logout).not.toHaveBeenCalled()
})
it('response interceptor handles error with no response object', async () => {
const logout = vi.fn()
useAuthStore.setState({ logout })
const err = { message: 'network down' }
await expect(api.__res.rejected!(err)).rejects.toBe(err)
expect(logout).not.toHaveBeenCalled()
})
it('publicApi has no request/response interceptors registered', () => {
expect(publicApi.__req.fulfilled).toBeUndefined()
expect(publicApi.__res.fulfilled).toBeUndefined()
})
it('authApi.login posts to /auth/login', () => {
mod.authApi.login('u', 'p')
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
})
it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas')
})
it('canvasApi.save POSTs to /canvas/save with payload', () => {
const payload = { nodes: [], edges: [], viewport: {} }
mod.canvasApi.save(payload)
expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
})
it('nodesApi CRUD calls correct endpoints', () => {
mod.nodesApi.create({ a: 1 })
expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
mod.nodesApi.update('n1', { b: 2 })
expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
mod.nodesApi.delete('n1')
expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
})
it('edgesApi CRUD calls correct endpoints', () => {
mod.edgesApi.create({ s: 'a', t: 'b' })
expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
mod.edgesApi.delete('e1')
expect(api.delete).toHaveBeenCalledWith('/edges/e1')
})
it('liveviewApi.load uses publicApi with key param', () => {
mod.liveviewApi.load('k-1')
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
expect(api.get).not.toHaveBeenCalled()
})
it('scanApi endpoints route correctly', () => {
mod.scanApi.trigger()
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
mod.scanApi.pending()
expect(api.get).toHaveBeenCalledWith('/scan/pending')
mod.scanApi.hidden()
expect(api.get).toHaveBeenCalledWith('/scan/hidden')
mod.scanApi.runs()
expect(api.get).toHaveBeenCalledWith('/scan/runs')
mod.scanApi.clearPending()
expect(api.delete).toHaveBeenCalledWith('/scan/pending')
mod.scanApi.approve('d1', { foo: 'bar' })
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
mod.scanApi.hide('d1')
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
mod.scanApi.ignore('d1')
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
mod.scanApi.bulkApprove(['a', 'b'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
mod.scanApi.bulkHide(['a'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
mod.scanApi.restore('d1')
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
mod.scanApi.bulkRestore(['a'])
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
mod.scanApi.stop('run-1')
expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
mod.scanApi.getConfig()
expect(api.get).toHaveBeenCalledWith('/scan/config')
mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
})
it('settingsApi get/save', () => {
mod.settingsApi.get()
expect(api.get).toHaveBeenCalledWith('/settings')
mod.settingsApi.save({ interval_seconds: 30 })
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
})
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
mod.zigbeeApi.testConnection(cfg)
expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
mod.zigbeeApi.importNetwork(cfg)
expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
mod.zigbeeApi.importToPending(cfg)
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
})
})
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import type { NodeData, NodeProperty } from '@/types'
import type { NodeProps, Node } from '@xyflow/react'
function renderNode(data: Partial<NodeData> = {}, selected = false) {
const fullData: NodeData = {
label: 'pve-01',
type: 'proxmox',
status: 'online',
services: [],
...data,
}
const props = {
id: 'p1',
data: fullData,
selected,
type: 'proxmox',
zIndex: 0,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
deletable: true,
draggable: true,
selectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
width: 300,
height: 200,
dragHandle: undefined,
parentId: undefined,
sourcePosition: undefined,
targetPosition: undefined,
} as unknown as NodeProps<Node<NodeData>>
return render(
<ReactFlowProvider>
<ProxmoxGroupNode {...props} />
</ReactFlowProvider>
)
}
describe('ProxmoxGroupNode', () => {
beforeEach(() => {
useCanvasStore.setState({ hideIp: false })
useThemeStore.setState({ activeTheme: 'default' })
})
it('renders the node label', () => {
const { getByText } = renderNode({ label: 'My Proxmox' })
expect(getByText('My Proxmox')).toBeDefined()
})
it('renders ip when provided', () => {
const { getByText } = renderNode({ ip: '192.168.1.10' })
expect(getByText('192.168.1.10')).toBeDefined()
})
it('renders multiple ips when comma separated', () => {
const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
expect(getByText('10.0.0.1')).toBeDefined()
expect(getByText('10.0.0.2')).toBeDefined()
})
it('masks ip when hideIp is enabled in store', () => {
useCanvasStore.setState({ hideIp: true })
const { queryByText } = renderNode({ ip: '192.168.1.10' })
expect(queryByText('192.168.1.10')).toBeNull()
})
it('renders visible properties only', () => {
const properties: NodeProperty[] = [
{ key: 'CPU', value: '16 cores', icon: null, visible: true },
{ key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
]
const { getByText, queryByText } = renderNode({ properties })
expect(getByText('CPU')).toBeDefined()
expect(getByText(/16 cores/)).toBeDefined()
expect(queryByText('Hidden')).toBeNull()
expect(queryByText(/should-not-show/)).toBeNull()
})
it('renders status dot with title matching status', () => {
const { container } = renderNode({ status: 'offline' })
const dot = container.querySelector('[title="offline"]')
expect(dot).not.toBeNull()
})
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
const { container } = renderNode({ container_mode: false })
// NodeResizer should not be present when not group-rendered
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
})
it('container_mode default renders the group border container', () => {
const { container } = renderNode({})
// Group border div has rounded-xl border-2 classes
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
})
it('renders cluster handles in both modes', () => {
const { container: groupC } = renderNode({})
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
const { container: nodeC } = renderNode({ container_mode: false })
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
})
})
+1 -31
View File
@@ -54,12 +54,11 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
@@ -94,10 +93,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
onClose()
}
const filteredParentNodes = form.type === 'docker_container'
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
: parentContainerNodes
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
@@ -325,31 +320,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue placeholder="None (standalone)">
{form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
: 'None (standalone)'}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{filteredParentNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1">
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
interface PendingDevicesModalProps {
open: boolean
@@ -252,13 +253,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const handleApprove = async (device: PendingDevice) => {
try {
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type)
const properties = zigbee ? buildZigbeeProperties(device) : []
const nodeData = {
label: fallbackLabel,
type: (device.suggested_type ?? 'generic') as NodeType,
type,
ip: device.ip ?? undefined,
hostname: device.hostname ?? undefined,
status: 'unknown',
status: zigbee ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
properties,
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
@@ -266,7 +271,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
})
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
@@ -310,17 +315,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const type = (d.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type)
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as NodeType,
type,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: deviceLabel(d),
type: (d.suggested_type ?? 'generic') as NodeType,
type,
ip: d.ip ?? undefined,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
status: zigbee ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
properties: zigbee ? buildZigbeeProperties(d) : [],
},
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CustomStyleModal } from '../CustomStyleModal'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { toast } from 'sonner'
describe('CustomStyleModal', () => {
beforeEach(() => {
useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } })
useCanvasStore.setState({ hasUnsavedChanges: false })
vi.mocked(toast.success).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<CustomStyleModal open={false} onClose={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders title and tabs', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText('Custom Style Editor')).toBeDefined()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined()
})
it('starts with empty selection placeholder', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText(/Select a node type/)).toBeDefined()
})
it('switches to edges tab and shows the right placeholder', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
})
it('selecting a node type opens the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect(screen.getByText(/Apply to existing/)).toBeDefined()
expect(screen.getByText('Default size')).toBeDefined()
})
it('selecting an edge type opens the edge editor with path style buttons', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined()
})
it('Apply-to-existing node button calls store and toasts', () => {
const applyTypeNodeStyle = vi.fn()
useCanvasStore.setState({ applyTypeNodeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ }))
expect(applyTypeNodeStyle).toHaveBeenCalledOnce()
expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router')
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router'))
})
it('Apply-to-existing edge button calls store and toasts', () => {
const applyTypeEdgeStyle = vi.fn()
useCanvasStore.setState({ applyTypeEdgeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle).toHaveBeenCalledOnce()
expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet')
})
it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
useCanvasStore.setState({ markUnsaved })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
expect(markUnsaved).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved'))
})
it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
const applyAllCustomStyles = vi.fn()
useCanvasStore.setState({ markUnsaved, applyAllCustomStyles })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' }))
expect(applyAllCustomStyles).toHaveBeenCalledOnce()
expect(markUnsaved).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('Cancel button closes without saving', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
useCanvasStore.setState({ markUnsaved })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
expect(markUnsaved).not.toHaveBeenCalled()
})
it('editing path style updates the edge draft', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
const smoothBtn = screen.getByRole('button', { name: 'Smooth' })
fireEvent.click(smoothBtn)
// The clicked button should now be styled selected (cyan border)
expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)')
})
it('changing width input updates node draft', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const widthInputs = screen.getAllByRole('spinbutton')
fireEvent.change(widthInputs[0], { target: { value: '250' } })
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
})
})
@@ -311,47 +311,15 @@ describe('NodeModal', () => {
expect(screen.queryByText('Reset to defaults')).toBeNull()
})
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
// ── Parent Container selector removed ────────────────────────────────
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
renderModal({
initial: { ...BASE, type },
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('Container 01')).toBeDefined()
})
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
it('does not render the Parent Container selector', () => {
renderModal({ initial: BASE })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('docker_container shows only docker_host parents', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
],
})
expect(screen.getByText('My Docker Host')).toBeDefined()
expect(screen.queryByText('My Proxmox')).toBeNull()
})
it('docker_container hides Parent Container when no docker_host is available', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
})
it('does not render Parent Container for docker_container either', () => {
renderModal({ initial: { ...BASE, type: 'docker_container' } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
+23
View File
@@ -0,0 +1,23 @@
import type { NodeProperty, NodeType } from '@/types'
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
export function isZigbeeType(type: NodeType | string | undefined | null): boolean {
return !!type && (ZIGBEE_TYPES as string[]).includes(type)
}
/** Build the IEEE/Vendor/Model/LQI property rows shown in the right panel.
* Matches backend `build_zigbee_properties`. */
export function buildZigbeeProperties(input: {
ieee_address?: string | null
vendor?: string | null
model?: string | null
lqi?: number | null
}): NodeProperty[] {
const props: NodeProperty[] = []
if (input.ieee_address) props.push({ key: 'IEEE', value: input.ieee_address, icon: null, visible: false })
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
if (input.lqi != null) props.push({ key: 'LQI', value: String(input.lqi), icon: null, visible: false })
return props
}