A node recorded as its own parent (`parent_id = id`) made the child walk in `translateWaypointsForMovedNodes` recurse forever. The resulting stack overflow was thrown inside the `onNodesChange` reducer, so the whole state update was discarded: the node still selected but would never move. It also renders unparented, sitting wherever its stored coordinates put it rather than inside the container it appears to belong to. Guard the walk, then close every path that could write the row: - `propagate` keeps a `walked` set, matching the cycle guard `orderParentsFirst` already carries. Covers longer cycles (a -> b -> a) too, not just self-parent. - `updateNode` / `addNode` drop a `parent_id` equal to the node's own id. The key is removed rather than nulled, so the rest of the edit still lands and an existing real parent is left alone. - `importYaml` resolves parents by label, so a node naming its own label — or a duplicate label mapping back to it — imported as its own parent. Warn and skip. - `node_dedupe` re-pointed every child of a duplicate at the canonical node, including the canonical node itself when it had been nested under one of its own duplicates. Detach instead, mirroring the self-loop edge deletion below it. - `NodeSave` and the node PATCH route normalize it away. Dropped rather than rejected: a canvas that already carries the bad row must still be able to save, and a 422 would cost the user the whole save. `_repair_self_parent_nodes` clears what is already persisted at startup. The real parent is not recoverable from the row, so NULL returns the node to the top level where the user can re-nest it. Idempotent, and never fatal to boot. A longer cycle is deliberately left alone by the repair — there is no single right link to cut, and the runtime guard keeps the canvas usable either way. ha-relevant: yes
743 lines
36 KiB
Python
743 lines
36 KiB
Python
import uuid
|
|
|
|
from httpx import AsyncClient
|
|
|
|
|
|
def node_payload(**kwargs):
|
|
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
|
|
|
|
|
def edge_payload(src, tgt, **kwargs):
|
|
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
|
|
|
|
|
|
# ── load_canvas ───────────────────────────────────────────────────────────────
|
|
|
|
async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
|
res = await client.get("/api/v1/canvas", headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["nodes"] == []
|
|
assert data["edges"] == []
|
|
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
|
|
|
|
|
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
|
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
|
# uses this to show the demo canvas only to genuinely new users.
|
|
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert data["initialized"] is False
|
|
|
|
|
|
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
|
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
|
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
|
# and must not get the demo re-seeded.
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
|
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert data["nodes"] == []
|
|
assert data["initialized"] is True
|
|
|
|
|
|
async def test_load_canvas_requires_auth(client: AsyncClient):
|
|
res = await client.get("/api/v1/canvas")
|
|
assert res.status_code == 401
|
|
|
|
|
|
# ── save_canvas ───────────────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
n2 = node_payload(label="Switch", type="switch")
|
|
e1 = edge_payload(n1["id"], n2["id"])
|
|
|
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 1, "y": 2, "zoom": 1.5}}, headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json() == {"saved": True}
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert len(canvas["nodes"]) == 2
|
|
assert len(canvas["edges"]) == 1
|
|
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
|
|
|
|
|
async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
n2 = node_payload(label="Switch", type="switch")
|
|
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
assert edge["marker_start"] == "diamond"
|
|
assert edge["marker_end"] == "arrow"
|
|
|
|
|
|
async def test_save_canvas_round_trips_line_style_and_width(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
n2 = node_payload(label="Switch", type="switch")
|
|
e1 = edge_payload(n1["id"], n2["id"], type="wifi", line_style="dotted", width_mult=3)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
assert edge["line_style"] == "dotted"
|
|
assert edge["width_mult"] == 3
|
|
|
|
|
|
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
n2 = node_payload(label="Switch", type="switch")
|
|
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
assert edge["marker_start"] == "arrow"
|
|
assert edge["marker_end"] == "none"
|
|
|
|
|
|
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
n2 = node_payload(label="Switch", type="switch")
|
|
e1 = edge_payload(n1["id"], n2["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
assert edge["marker_start"] == "none"
|
|
assert edge["marker_end"] == "none"
|
|
|
|
|
|
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
|
# Regression (#243): top/left/right_handles must persist across save+reload,
|
|
# not just bottom_handles.
|
|
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
|
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
|
assert res.status_code == 200
|
|
|
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
assert node["top_handles"] == 2
|
|
assert node["bottom_handles"] == 3
|
|
assert node["left_handles"] == 1
|
|
assert node["right_handles"] == 4
|
|
|
|
|
|
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
|
|
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
|
|
n1 = node_payload(label="Srv", type="server")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
|
|
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
assert node["top_handles"] == 1
|
|
assert node["bottom_handles"] == 1
|
|
assert node["left_handles"] == 0
|
|
assert node["right_handles"] == 0
|
|
|
|
|
|
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Router", type="router")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
# created_at / updated_at always set; last_seen / last_scan null until observed.
|
|
assert node["created_at"] is not None
|
|
assert node["updated_at"] is not None
|
|
assert "last_seen" in node
|
|
assert node["last_scan"] is None
|
|
|
|
|
|
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Old Label")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_updated = {**n1, "label": "New Label", "ip": "10.0.0.1"}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert len(canvas["nodes"]) == 1
|
|
assert canvas["nodes"][0]["label"] == "New Label"
|
|
assert canvas["nodes"][0]["ip"] == "10.0.0.1"
|
|
|
|
|
|
async def test_save_canvas_deletes_removed_nodes(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(label="Keep")
|
|
n2 = node_payload(label="Remove")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert len(canvas["nodes"]) == 1
|
|
assert canvas["nodes"][0]["label"] == "Keep"
|
|
|
|
|
|
async def test_save_canvas_deletes_removed_edges(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["edges"] == []
|
|
|
|
|
|
async def test_save_canvas_persists_viewport_on_update(client: AsyncClient, headers: dict):
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 10, "y": 20, "zoom": 2}}, headers=headers)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 5, "y": 5, "zoom": 0.5}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["viewport"] == {"x": 5, "y": 5, "zoom": 0.5}
|
|
|
|
|
|
async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(custom_colors={"border": "#ff0000", "icon": "#00ff00"})
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
|
|
|
|
|
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
|
|
"""label_position and text_size are stored in custom_colors and returned unchanged."""
|
|
n1 = node_payload(custom_colors={
|
|
"border": "#00d4ff",
|
|
"border_style": "solid",
|
|
"border_width": 3,
|
|
"label_position": "outside",
|
|
"text_size": 16,
|
|
"text_color": "#e6edf3",
|
|
})
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
cc = canvas["nodes"][0]["custom_colors"]
|
|
assert cc["label_position"] == "outside"
|
|
assert cc["text_size"] == 16
|
|
assert cc["border_width"] == 3
|
|
|
|
|
|
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], custom_color="#a855f7", path_style="smooth")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
edge = canvas["edges"][0]
|
|
assert edge["custom_color"] == "#a855f7"
|
|
assert edge["path_style"] == "smooth"
|
|
|
|
|
|
async def test_save_canvas_persists_custom_icon(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(custom_icon="cctv")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["custom_icon"] == "cctv"
|
|
|
|
|
|
async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(custom_icon="cctv")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_cleared = {**n1, "custom_icon": None}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["custom_icon"] is None
|
|
|
|
|
|
async def test_save_canvas_requires_auth(client: AsyncClient):
|
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
|
assert res.status_code == 401
|
|
|
|
|
|
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["cpu_count"] == 8
|
|
assert node["cpu_model"] == "Intel i7-12700K"
|
|
assert node["ram_gb"] == 32.0
|
|
assert node["disk_gb"] == 500.0
|
|
|
|
|
|
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(cpu_count=4, ram_gb=16.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["cpu_count"] == 4
|
|
assert node["ram_gb"] == 16.0
|
|
assert node["cpu_model"] is None
|
|
assert node["disk_gb"] is None
|
|
|
|
|
|
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["show_hardware"] is True
|
|
|
|
|
|
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["show_hardware"] is False
|
|
|
|
|
|
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
|
# toggle reset on every reload.
|
|
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(show_port_numbers=True)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["show_port_numbers"] is True
|
|
|
|
|
|
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["show_port_numbers"] is False
|
|
|
|
|
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["cpu_count"] is None
|
|
assert node["ram_gb"] is None
|
|
|
|
|
|
# ── node width / height (resizable nodes) ─────────────────────────────────────
|
|
|
|
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(width=320.0, height=180.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["width"] == 320.0
|
|
assert node["height"] == 180.0
|
|
|
|
|
|
async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["width"] is None
|
|
assert canvas["nodes"][0]["height"] is None
|
|
|
|
|
|
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(width=140.0, height=50.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_resized = {**n1, "width": 280.0, "height": 120.0}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["width"] == 280.0
|
|
assert node["height"] == 120.0
|
|
|
|
|
|
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(width=300.0, height=200.0)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_cleared = {**n1, "width": None, "height": None}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["width"] is None
|
|
assert canvas["nodes"][0]["height"] is None
|
|
|
|
|
|
# ── properties ────────────────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["properties"] == []
|
|
|
|
|
|
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
|
props = [
|
|
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
|
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
|
]
|
|
n1 = node_payload(properties=props)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
returned = canvas["nodes"][0]["properties"]
|
|
assert len(returned) == 2
|
|
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
|
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
|
|
|
|
|
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_updated = {**n1, "properties": [
|
|
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
|
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
|
]}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
props = canvas["nodes"][0]["properties"]
|
|
assert len(props) == 2
|
|
assert props[0]["value"] == "64 GB"
|
|
assert props[1]["key"] == "Disk"
|
|
|
|
|
|
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
|
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
|
n1 = node_payload(properties=props)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["properties"][0]["icon"] is None
|
|
|
|
|
|
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
n1_cleared = {**n1, "properties": []}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["properties"] == []
|
|
|
|
|
|
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["edges"][0]["waypoints"] is None
|
|
|
|
|
|
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
returned = canvas["edges"][0]["waypoints"]
|
|
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
|
|
|
|
|
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
|
|
|
|
|
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(bottom_handles=3)
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
edge = canvas["edges"][0]
|
|
assert edge["source_handle"] == "bottom-1"
|
|
assert edge["target_handle"] == "top"
|
|
|
|
|
|
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["edges"][0]["animated"] == "snake"
|
|
|
|
|
|
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["edges"][0]["animated"] == "basic"
|
|
|
|
|
|
# ── node fields ───────────────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(
|
|
type="server",
|
|
label="Main Server",
|
|
hostname="server.local",
|
|
ip="192.168.1.10",
|
|
mac="aa:bb:cc:dd:ee:ff",
|
|
os="Ubuntu 22.04",
|
|
status="online",
|
|
check_method="http",
|
|
check_target="http://192.168.1.10",
|
|
services=[{"name": "nginx", "port": 80}],
|
|
notes="Primary web server",
|
|
pos_x=150.0,
|
|
pos_y=250.0,
|
|
bottom_handles=2,
|
|
)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["hostname"] == "server.local"
|
|
assert node["ip"] == "192.168.1.10"
|
|
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
|
assert node["os"] == "Ubuntu 22.04"
|
|
assert node["status"] == "online"
|
|
assert node["check_method"] == "http"
|
|
assert node["check_target"] == "http://192.168.1.10"
|
|
assert node["services"] == [{"name": "nginx", "port": 80}]
|
|
assert node["notes"] == "Primary web server"
|
|
assert node["pos_x"] == 150.0
|
|
assert node["pos_y"] == 250.0
|
|
assert node["bottom_handles"] == 2
|
|
|
|
|
|
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(bottom_handles=4)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["bottom_handles"] == 4
|
|
|
|
|
|
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["bottom_handles"] == 1
|
|
|
|
|
|
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
|
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
|
n1 = node_payload(services=services, notes="My NAS device")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["services"] == services
|
|
assert node["notes"] == "My NAS device"
|
|
|
|
|
|
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
|
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
|
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"][0]["services"] == services
|
|
|
|
|
|
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
|
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node = canvas["nodes"][0]
|
|
assert node["check_method"] == "ping"
|
|
assert node["check_target"] == "192.168.1.1"
|
|
|
|
|
|
# ── parent/child nodes ────────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
|
parent = node_payload(type="proxmox", label="PVE Host")
|
|
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
|
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
|
assert node_map[parent["id"]]["parent_id"] is None
|
|
|
|
|
|
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
|
parent = node_payload(type="proxmox", label="PVE Host")
|
|
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
# Remove both parent and child
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["nodes"] == []
|
|
|
|
|
|
# ── groupRect / group node ────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
|
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
|
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
node_map = {n["id"]: n for n in canvas["nodes"]}
|
|
assert node_map[group["id"]]["type"] == "group"
|
|
assert node_map[group["id"]]["width"] == 400.0
|
|
assert node_map[group["id"]]["height"] == 300.0
|
|
assert node_map[member["id"]]["parent_id"] == group["id"]
|
|
|
|
|
|
# ── viewport ──────────────────────────────────────────────────────────────────
|
|
|
|
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
|
|
|
|
|
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
|
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
|
|
|
|
|
# ── edge types ────────────────────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
edge = canvas["edges"][0]
|
|
assert edge["type"] == "vlan"
|
|
assert edge["vlan_id"] == 10
|
|
assert edge["label"] == "VLAN 10"
|
|
|
|
|
|
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
|
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
|
n1 = node_payload()
|
|
n2 = node_payload()
|
|
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
|
|
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
edge = canvas["edges"][0]
|
|
assert edge["label"] == "updated"
|
|
assert edge["custom_color"] == "#ff0000"
|
|
|
|
|
|
# ── custom_style ──────────────────────────────────────────────────────────────
|
|
|
|
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
|
custom_style = {
|
|
"nodes": {
|
|
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
|
},
|
|
"edges": {
|
|
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
|
},
|
|
}
|
|
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
|
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
|
assert res.status_code == 200
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert canvas["custom_style"] is not None
|
|
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
|
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
|
|
|
|
|
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
|
res = await client.get("/api/v1/canvas", headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["custom_style"] is None
|
|
|
|
|
|
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
|
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
|
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
|
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
|
|
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
assert "proxmox" in canvas["custom_style"]["nodes"]
|
|
assert "server" not in canvas["custom_style"]["nodes"]
|
|
|
|
|
|
# ── self-parent guard (#370) ─────────────────────────────────────────────────
|
|
|
|
async def test_save_canvas_drops_a_node_parented_to_itself(client: AsyncClient, headers: dict):
|
|
# A self-parent row freezes the node on the canvas and survives a reload, so
|
|
# the save normalizes it away. Dropped rather than rejected: a canvas that
|
|
# already carries the bad row must still be able to save.
|
|
n = node_payload(type="lxc", label="pihole")
|
|
n["parent_id"] = n["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/canvas/save", json={"nodes": [n], "edges": [], "viewport": {}}, headers=headers
|
|
)
|
|
assert res.status_code == 200
|
|
|
|
saved = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]
|
|
assert len(saved) == 1
|
|
assert saved[0]["parent_id"] is None
|
|
|
|
|
|
async def test_save_canvas_keeps_a_real_parent(client: AsyncClient, headers: dict):
|
|
host = node_payload(type="proxmox", label="pve", container_mode=True)
|
|
child = node_payload(type="lxc", label="pihole", parent_id=host["id"])
|
|
|
|
res = await client.post(
|
|
"/api/v1/canvas/save",
|
|
json={"nodes": [host, child], "edges": [], "viewport": {}},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
|
|
saved = {n["label"]: n for n in (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]}
|
|
assert saved["pihole"]["parent_id"] == host["id"]
|