b513fa6f4e
Break the 1897-line test_scan.py into topic modules under tests/scan/ (routes, approve, properties, run) sharing fixtures via a package conftest.py and pure builders via helpers.py. Same 80 tests, no behavior change; full suite 627 passed. ha-relevant: no
724 lines
27 KiB
Python
724 lines
27 KiB
Python
"""Approve / hide / restore / ignore / bulk device flows and conflict handling."""
|
|
import uuid
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Design, Node, PendingDevice
|
|
from tests.scan.helpers import _add_design, _node
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
|
|
# A node with no design_id is not "on a canvas".
|
|
db_session.add(_node(None, ip="192.168.1.100"))
|
|
await db_session.commit()
|
|
|
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert res.json()[0]["canvas_count"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
|
node_payload = {
|
|
"label": "My Server",
|
|
"type": "server",
|
|
"ip": "192.168.1.100",
|
|
"hostname": "my-server",
|
|
"status": "unknown",
|
|
"services": [],
|
|
}
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json=node_payload,
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["approved"] is True
|
|
assert "node_id" in data
|
|
|
|
# Approved devices stay in the inventory (status != "hidden") so they keep
|
|
# showing with an "In N canvas" badge — they are no longer dropped.
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
inventory = pending_res.json()
|
|
assert len(inventory) == 1
|
|
assert inventory[0]["id"] == pending_device.id
|
|
assert inventory[0]["status"] == "approved"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
|
client: AsyncClient, headers, db_session
|
|
):
|
|
"""Approving a device whose IEEE is already on the target design prompts the
|
|
user (409) instead of silently merging/replacing — same UX as ip/mac."""
|
|
design = Design(name="d1")
|
|
db_session.add(design)
|
|
await db_session.flush()
|
|
existing = Node(
|
|
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
|
services=[], design_id=design.id,
|
|
)
|
|
db_session.add(existing)
|
|
device = PendingDevice(
|
|
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
|
status="pending", discovery_source="zigbee",
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{device.id}/approve",
|
|
json={
|
|
"label": "sensor", "type": "zigbee_enddevice",
|
|
"status": "online", "services": [], "design_id": design.id,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 409
|
|
detail = res.json()["detail"]
|
|
assert detail["duplicate"] is True
|
|
assert detail["existing_node_id"] == existing.id
|
|
assert detail["match"] == "ieee"
|
|
assert detail["value"] == "0xZZZ"
|
|
|
|
# No second node created; device stays pending until the user decides.
|
|
nodes = (
|
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
|
).scalars().all()
|
|
assert len(nodes) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_force_creates_duplicate_ieee(
|
|
client: AsyncClient, headers, db_session
|
|
):
|
|
"""force=True lets the user place a second card for the same IEEE."""
|
|
design = Design(name="d1")
|
|
db_session.add(design)
|
|
await db_session.flush()
|
|
db_session.add(Node(
|
|
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
|
services=[], design_id=design.id,
|
|
))
|
|
device = PendingDevice(
|
|
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
|
status="pending", discovery_source="zigbee",
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{device.id}/approve",
|
|
json={
|
|
"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
|
"services": [], "design_id": design.id, "force": True,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
nodes = (
|
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
|
).scalars().all()
|
|
assert len(nodes) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_conflicts_on_existing_ip(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""An ordinary host whose ip already sits on the target design is NOT
|
|
silently duplicated: the approve returns 409 with the existing node so the
|
|
UI can ask the user."""
|
|
design = await _add_design(db_session, "Home")
|
|
existing = _node(design, ip="192.168.1.100")
|
|
db_session.add(existing)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
"status": "unknown", "services": [], "design_id": design},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 409
|
|
detail = res.json()["detail"]
|
|
assert detail["duplicate"] is True
|
|
assert detail["existing_node_id"] == existing.id
|
|
assert detail["match"] == "ip"
|
|
assert detail["value"] == "192.168.1.100"
|
|
|
|
# No node created, device left pending (user hasn't decided yet).
|
|
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
|
assert len(nodes) == 1
|
|
await db_session.refresh(pending_device)
|
|
assert pending_device.status == "pending"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_conflicts_on_existing_mac(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
|
|
design = await _add_design(db_session, "Home")
|
|
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
|
|
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
|
|
db_session.add(existing)
|
|
await db_session.commit()
|
|
|
|
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "dup", "type": "server", "ip": "192.168.1.55",
|
|
"mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [],
|
|
"design_id": design},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 409
|
|
assert res.json()["detail"]["match"] == "mac"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_conflicts_on_ip_in_comma_list(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""The existing node's ip holds an IPv6 before the IPv4 the device scanned
|
|
as. Exact-string matching missed it (issue #258); per-token matching catches
|
|
the duplicate."""
|
|
design = await _add_design(db_session, "Home")
|
|
existing = _node(design, ip="fe80::1, 192.168.1.100")
|
|
db_session.add(existing)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
"status": "unknown", "services": [], "design_id": design},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 409
|
|
detail = res.json()["detail"]
|
|
assert detail["existing_node_id"] == existing.id
|
|
assert detail["match"] == "ip"
|
|
assert detail["value"] == "192.168.1.100"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_no_conflict_on_ip_substring(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""The ip guard must match whole addresses, not substrings: a node at
|
|
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
|
|
design = await _add_design(db_session, "Home")
|
|
db_session.add(_node(design, ip="10.0.0.40"))
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "new", "type": "server", "ip": "10.0.0.4",
|
|
"mac": None, "status": "unknown", "services": [], "design_id": design},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_force_creates_duplicate(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""force=True (user confirmed) bypasses the guard and creates the node."""
|
|
design = await _add_design(db_session, "Home")
|
|
db_session.add(_node(design, ip="192.168.1.100"))
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
"status": "unknown", "services": [], "design_id": design, "force": True},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
|
assert len(nodes) == 2 # duplicate deliberately created
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_allows_same_ip_on_other_design(
|
|
client: AsyncClient, headers, db_session, pending_device
|
|
):
|
|
"""The guard is per-design: the same host on a different canvas is fine."""
|
|
other = await _add_design(db_session, "Lab")
|
|
target = await _add_design(db_session, "Home")
|
|
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "ok", "type": "server", "ip": "192.168.1.100",
|
|
"status": "unknown", "services": [], "design_id": target},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_places_already_approved_on_another_design(
|
|
client: AsyncClient, headers, db_session
|
|
):
|
|
"""A device already approved on ANOTHER canvas (global status="approved")
|
|
must still be placeable on a new design — status is global, canvas
|
|
membership is per-design (mirrors bulk_approve)."""
|
|
other = await _add_design(db_session, "Other")
|
|
target = await _add_design(db_session, "Network Topology")
|
|
# Device is on `other` already (its global status is "approved").
|
|
db_session.add(_node(other, ieee="0x00158d0005292b83"))
|
|
device = PendingDevice(
|
|
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
|
|
suggested_type="zigbee_enddevice", status="approved",
|
|
discovery_source="zigbee",
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{device.id}/approve",
|
|
json={"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
|
"services": [], "design_id": target},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
# A node now exists on the target design too (one per canvas).
|
|
nodes = (
|
|
await db_session.execute(
|
|
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
|
|
)
|
|
).scalars().all()
|
|
assert {n.design_id for n in nodes} == {other, target}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device):
|
|
"""A user-hidden device is not approvable via this endpoint."""
|
|
pending_device.status = "hidden"
|
|
db_session.add(pending_device)
|
|
await db_session.commit()
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "x", "type": "server", "status": "unknown", "services": []},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
|
node_payload = {
|
|
"label": "Ghost",
|
|
"type": "generic",
|
|
"ip": "10.0.0.1",
|
|
"status": "unknown",
|
|
"services": [],
|
|
}
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/nonexistent-id/approve",
|
|
json=node_payload,
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["hidden"] is True
|
|
|
|
# Should no longer appear in pending
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert pending_res.json() == []
|
|
|
|
# Should appear in hidden
|
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
assert len(hidden_res.json()) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
|
# Hide first
|
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
|
|
# Restore
|
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["restored"] is True
|
|
|
|
# Now back in pending, gone from hidden
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert len(pending_res.json()) == 1
|
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
assert hidden_res.json() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
|
assert res.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
|
# Hide
|
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-restore",
|
|
headers=headers,
|
|
json={"device_ids": [pending_device.id]},
|
|
)
|
|
assert res.status_code == 200
|
|
assert res.json()["restored"] == 1
|
|
assert res.json()["skipped"] == 0
|
|
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert len(pending_res.json()) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["ignored"] is True
|
|
|
|
# Device should be gone from both pending and hidden
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
assert pending_res.json() == []
|
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
assert hidden_res.json() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["approved"] == 2
|
|
assert len(data["node_ids"]) == 2
|
|
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
|
assert len(data["device_ids"]) == 2
|
|
assert data["skipped"] == 0
|
|
# Approved devices stay in the inventory, now marked "approved".
|
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
inventory = pending_res.json()
|
|
assert len(inventory) == 2
|
|
assert all(d["status"] == "approved" for d in inventory)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
|
client: AsyncClient, headers, db_session, two_pending_devices
|
|
):
|
|
"""Regression: a device already approved (status='approved', e.g. placed on
|
|
another canvas) must still get a node on the design being approved onto.
|
|
|
|
Previously bulk-approve filtered status=='pending', so selecting an
|
|
already-approved device created no node — the user saw fewer nodes than
|
|
they selected."""
|
|
ids = [d.id for d in two_pending_devices]
|
|
design_a = await _add_design(db_session, "Canvas A")
|
|
design_b = await _add_design(db_session, "Canvas B")
|
|
|
|
# Approve both onto design A.
|
|
res_a = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": ids, "design_id": design_a},
|
|
headers=headers,
|
|
)
|
|
assert res_a.json()["approved"] == 2
|
|
|
|
# Re-approve the same (now status='approved') devices onto design B.
|
|
res_b = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": ids, "design_id": design_b},
|
|
headers=headers,
|
|
)
|
|
data_b = res_b.json()
|
|
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
|
|
assert data_b["skipped"] == 0
|
|
|
|
# Two nodes now exist on each design.
|
|
from app.db.models import Node as NodeModel
|
|
nodes_b = (
|
|
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
|
|
).scalars().all()
|
|
assert len(nodes_b) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_skips_device_already_on_target_design(
|
|
client: AsyncClient, headers, db_session, two_pending_devices
|
|
):
|
|
"""A device already on the target canvas (same ip) is not placed twice."""
|
|
ids = [d.id for d in two_pending_devices]
|
|
design = await _add_design(db_session, "Canvas")
|
|
# First device already sits on the canvas (matched by ip).
|
|
db_session.add(_node(design, ip="192.168.1.10"))
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": ids, "design_id": design},
|
|
headers=headers,
|
|
)
|
|
data = res.json()
|
|
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
|
assert data["skipped"] == 1
|
|
|
|
from app.db.models import Node as NodeModel
|
|
nodes = (
|
|
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
|
|
).scalars().all()
|
|
# The pre-existing node plus the one newly approved — no duplicate for .10.
|
|
assert len(nodes) == 2
|
|
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
|
client: AsyncClient, headers, db_session, two_pending_devices
|
|
):
|
|
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
|
|
as the plain IPv4 is still recognised as already placed (issue #258)."""
|
|
ids = [d.id for d in two_pending_devices]
|
|
design = await _add_design(db_session, "Canvas")
|
|
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": ids, "design_id": design},
|
|
headers=headers,
|
|
)
|
|
data = res.json()
|
|
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
|
assert data["skipped"] == 1
|
|
assert data["skipped_devices"][0]["value"] == "192.168.1.10"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_reports_skipped_devices(
|
|
client: AsyncClient, headers, db_session, two_pending_devices
|
|
):
|
|
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
|
|
(with the existing node id) instead of silently dropping it."""
|
|
ids = [d.id for d in two_pending_devices]
|
|
design = await _add_design(db_session, "Canvas")
|
|
existing = _node(design, ip="192.168.1.10")
|
|
db_session.add(existing)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": ids, "design_id": design},
|
|
headers=headers,
|
|
)
|
|
data = res.json()
|
|
assert data["approved"] == 1
|
|
skipped = data["skipped_devices"]
|
|
assert len(skipped) == 1
|
|
entry = skipped[0]
|
|
assert entry["match"] == "ip"
|
|
assert entry["value"] == "192.168.1.10"
|
|
assert entry["existing_node_id"] == existing.id
|
|
assert entry["device_id"] in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_copies_mac_to_node_and_properties(
|
|
client: AsyncClient, headers, pending_device, db_session
|
|
):
|
|
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Node as NodeModel
|
|
# Payload intentionally omits mac — it must come from the pending device.
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
node = (
|
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
|
).scalar_one()
|
|
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
|
assert mac_props == [
|
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
|
client: AsyncClient, headers, db_session
|
|
):
|
|
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Node as NodeModel
|
|
device = PendingDevice(
|
|
id=str(uuid.uuid4()),
|
|
ip="192.168.1.55",
|
|
mac="11:22:33:44:55:66",
|
|
hostname="host-mac",
|
|
services=[],
|
|
suggested_type="generic",
|
|
status="pending",
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": [device.id]},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
node = (
|
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
|
).scalar_one()
|
|
assert node.mac == "11:22:33:44:55:66"
|
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
|
assert mac_props == [
|
|
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
|
]
|
|
|
|
|
|
@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."""
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Node as NodeModel
|
|
ids = [d.id for d in two_pending_devices]
|
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
assert res.status_code == 200
|
|
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
|
for n in nodes:
|
|
if n.ip:
|
|
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Node as NodeModel
|
|
res = await client.post(
|
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
|
assert node is not None
|
|
assert node.check_method == "ping"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
# Approve first device individually first
|
|
await client.post(
|
|
f"/api/v1/scan/pending/{ids[0]}/approve",
|
|
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
|
headers=headers,
|
|
)
|
|
# Bulk approve both — first one is already approved (not pending), should be skipped
|
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["approved"] == 1
|
|
assert data["skipped"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["hidden"] == 2
|
|
assert data["skipped"] == 0
|
|
# Should appear in hidden list
|
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
assert len(hidden_res.json()) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
# Hide first device individually first
|
|
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
|
# Bulk hide both — first is already hidden (not pending anymore)
|
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["hidden"] == 1
|
|
assert data["skipped"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
|
ids = [d.id for d in two_pending_devices]
|
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
|
|
"""bulk-approve must place nodes on the design_id sent by the UI, not the
|
|
first design — otherwise approved devices land on the wrong canvas."""
|
|
first = await _add_design(db_session, "Default") # first design (fallback)
|
|
active = await _add_design(db_session, "zwave") # the design the user is on
|
|
dev = PendingDevice(
|
|
id=str(uuid.uuid4()),
|
|
ieee_address="zwave-H-2",
|
|
friendly_name="Living Room Plug",
|
|
suggested_type="zwave_router",
|
|
device_subtype="Router",
|
|
vendor="Aeotec",
|
|
model="ZW096",
|
|
status="pending",
|
|
discovery_source="zwave",
|
|
)
|
|
db_session.add(dev)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(
|
|
"/api/v1/scan/pending/bulk-approve",
|
|
json={"device_ids": [dev.id], "design_id": active},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
assert res.json()["approved"] == 1
|
|
|
|
node = (
|
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
|
|
).scalar_one()
|
|
assert node.design_id == active
|
|
assert node.design_id != first
|
|
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
|
|
assert node.status == "online"
|
|
assert node.check_method == "none"
|
|
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
|