React Flow draws nothing for an edge whose handle ID resolves to no element, so
such an edge persists in the DB and is simply invisible on the canvas, with no
error anywhere. Nothing could reach that state while only the UI wrote handles;
exposing source_handle/target_handle over MCP opens it up, which is the same
failure the shrink remap already prevents from the other direction.
- POST /edges and PATCH /edges/{id} answer 422 when a caller-supplied handle
names a slot its node doesn't have — including a side it opted out of, since
left/right default to no connection point at all.
- Handles outside the per-side vocabulary ('cluster-right' and friends) belong
to their own renderer and are left alone.
- The auto-assignment corrects its own pick instead of raising: it always names
a top/bottom slot 0, which a node that zeroed those sides no longer draws.
A node with no connection point anywhere keeps the original.
- parse_side_handle in schemas/utils.py reads both the source form and the
'<side>-t' target twin the auto-assignment stores.
ha-relevant: no
484 lines
20 KiB
Python
484 lines
20 KiB
Python
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
@pytest.fixture
|
|
async def two_nodes(client: AsyncClient, headers: dict):
|
|
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
|
|
n2 = (await client.post("/api/v1/nodes", json={"type": "switch", "label": "SW1", "status": "online"}, headers=headers)).json()
|
|
return n1["id"], n2["id"]
|
|
|
|
|
|
async def test_create_edge(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source"] == src
|
|
assert data["target"] == tgt
|
|
assert data["type"] == "ethernet"
|
|
|
|
|
|
async def test_create_vlan_edge(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "vlan", "vlan_id": 20, "label": "VLAN 20"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["vlan_id"] == 20
|
|
|
|
|
|
async def test_list_edges(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
res = await client.get("/api/v1/edges", headers=headers)
|
|
assert res.status_code == 200
|
|
assert len(res.json()) == 1
|
|
|
|
|
|
async def test_delete_edge(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi"}, headers=headers)).json()["id"]
|
|
res = await client.delete(f"/api/v1/edges/{edge_id}", headers=headers)
|
|
assert res.status_code == 204
|
|
assert len((await client.get("/api/v1/edges", headers=headers)).json()) == 0
|
|
|
|
|
|
async def test_delete_edge_not_found(client: AsyncClient, headers: dict):
|
|
res = await client.delete("/api/v1/edges/nonexistent", headers=headers)
|
|
assert res.status_code == 404
|
|
|
|
|
|
async def test_update_edge(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"type": "wifi", "label": "uplink"}, headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["type"] == "wifi"
|
|
assert res.json()["label"] == "uplink"
|
|
|
|
|
|
async def test_update_edge_not_found(client: AsyncClient, headers: dict):
|
|
res = await client.patch("/api/v1/edges/nonexistent", json={"type": "wifi"}, headers=headers)
|
|
assert res.status_code == 404
|
|
|
|
|
|
async def test_create_edge_with_custom_color(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "custom_color": "#a855f7"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["custom_color"] == "#a855f7"
|
|
|
|
|
|
async def test_create_edge_with_path_style(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "path_style": "smooth"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["path_style"] == "smooth"
|
|
|
|
|
|
async def test_update_edge_custom_color_and_path_style(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"custom_color": "#39d353", "path_style": "smooth"}, headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["custom_color"] == "#39d353"
|
|
assert res.json()["path_style"] == "smooth"
|
|
|
|
|
|
async def test_create_edge_with_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["line_style"] == "dotted"
|
|
assert res.json()["width_mult"] == 3
|
|
|
|
|
|
async def test_update_edge_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"line_style": "dashed", "width_mult": 2}, headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["line_style"] == "dashed"
|
|
assert res.json()["width_mult"] == 2
|
|
|
|
|
|
async def test_create_edge_defaults_line_style_none(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["line_style"] is None
|
|
assert res.json()["width_mult"] is None
|
|
|
|
|
|
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["marker_start"] == "diamond"
|
|
assert res.json()["marker_end"] == "arrow"
|
|
|
|
|
|
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["marker_start"] == "none"
|
|
assert res.json()["marker_end"] == "none"
|
|
|
|
|
|
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["marker_end"] == "arrow"
|
|
|
|
|
|
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
|
|
assert res.status_code == 201
|
|
assert res.json()["marker_end"] == "none"
|
|
|
|
|
|
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json()["marker_end"] == "circle"
|
|
assert res.json()["marker_start"] == "none"
|
|
|
|
|
|
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
|
assert res.status_code == 401
|
|
|
|
|
|
async def test_create_edge_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict, two_nodes):
|
|
# Regression for #225: MCP create_edge sent no design_id, so edges were
|
|
# persisted with design_id=null and never rendered until a restart.
|
|
src, tgt = two_nodes
|
|
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
|
design_id = design.json()["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "type": "ethernet"},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 201
|
|
assert res.json()["design_id"] == design_id
|
|
|
|
|
|
async def test_create_edge_respects_explicit_design_id(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
|
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
|
second_id = second.json()["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "type": "ethernet", "design_id": second_id},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 201
|
|
assert res.json()["design_id"] == second_id
|
|
|
|
|
|
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={
|
|
"source": src,
|
|
"target": tgt,
|
|
"type": "cluster",
|
|
"source_handle": "cluster-right",
|
|
"target_handle": "cluster-left",
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["type"] == "cluster"
|
|
assert data["source_handle"] == "cluster-right"
|
|
assert data["target_handle"] == "cluster-left"
|
|
|
|
|
|
async def test_source_and_target_handle_persist_through_update(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (
|
|
await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "cluster-right", "target_handle": "cluster-left"},
|
|
headers=headers,
|
|
)
|
|
).json()["id"]
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"label": "corosync"}, headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["source_handle"] == "cluster-right"
|
|
assert data["target_handle"] == "cluster-left"
|
|
assert data["label"] == "corosync"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
|
|
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
async def stacked_nodes(client: AsyncClient, headers: dict):
|
|
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
|
|
top = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
|
|
headers=headers,
|
|
)
|
|
).json()
|
|
bottom = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
|
|
headers=headers,
|
|
)
|
|
).json()
|
|
return top["id"], bottom["id"]
|
|
|
|
|
|
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
|
|
"""Source above target -> downstream flow: exit bottom, enter top-t."""
|
|
top, bottom = stacked_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source_handle"] == "bottom"
|
|
assert data["target_handle"] == "top-t"
|
|
|
|
|
|
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
|
|
"""Source below target -> upstream flow: exit top, enter bottom-t."""
|
|
top, bottom = stacked_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source_handle"] == "top"
|
|
assert data["target_handle"] == "bottom-t"
|
|
|
|
|
|
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
|
|
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
|
|
src, tgt = two_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source_handle"] == "bottom"
|
|
assert data["target_handle"] == "top-t"
|
|
|
|
|
|
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
|
|
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
|
|
top, bottom = stacked_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source_handle"] == "cluster-right"
|
|
assert data["target_handle"] == "top-t"
|
|
|
|
|
|
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
|
|
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
|
|
container still resolves as downstream of a node above the container."""
|
|
parent = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
|
|
headers=headers,
|
|
)
|
|
).json()
|
|
child = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
|
|
headers=headers,
|
|
)
|
|
).json()
|
|
below = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
|
|
headers=headers,
|
|
)
|
|
).json()
|
|
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
|
|
res = await client.post(
|
|
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
|
|
)
|
|
assert res.status_code == 201
|
|
data = res.json()
|
|
assert data["source_handle"] == "bottom"
|
|
assert data["target_handle"] == "top-t"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection-point guards (#435)
|
|
# ---------------------------------------------------------------------------
|
|
# An edge whose handle ID resolves to no element is dropped by React Flow: it
|
|
# persists in the DB and is simply invisible. Handles became settable over the
|
|
# MCP, so the endpoints now check them.
|
|
|
|
|
|
async def test_create_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "source_handle": "right-3", "target_handle": "top"},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 422
|
|
assert "right-3" in res.json()["detail"]
|
|
assert (await client.get("/api/v1/edges", headers=headers)).json() == []
|
|
|
|
|
|
async def test_create_edge_rejects_a_side_the_node_opted_out_of(client: AsyncClient, headers: dict):
|
|
# left/right default to zero connection points, so even slot 0 is absent.
|
|
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
|
|
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/edges", json={"source": src, "target": tgt, "source_handle": "left"}, headers=headers
|
|
)
|
|
|
|
assert res.status_code == 422
|
|
|
|
|
|
async def test_create_edge_accepts_a_handle_the_node_opted_into(client: AsyncClient, headers: dict):
|
|
src = (
|
|
await client.post(
|
|
"/api/v1/nodes", json={"type": "proxmox", "label": "pve1", "right_handles": 1}, headers=headers
|
|
)
|
|
).json()["id"]
|
|
tgt = (
|
|
await client.post(
|
|
"/api/v1/nodes", json={"type": "proxmox", "label": "pve2", "left_handles": 1}, headers=headers
|
|
)
|
|
).json()["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "right", "target_handle": "left"},
|
|
headers=headers,
|
|
)
|
|
|
|
assert res.status_code == 201
|
|
assert res.json()["source_handle"] == "right"
|
|
|
|
|
|
async def test_create_edge_accepts_the_target_form_of_a_handle(client: AsyncClient, headers: dict):
|
|
# '<side>-t' is the invisible target twin the canvas renders beside slot 0;
|
|
# the auto-assignment stores exactly that, so it must validate.
|
|
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
|
|
tgt = (
|
|
await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1", "top_handles": 2}, headers=headers)
|
|
).json()["id"]
|
|
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "source_handle": "bottom", "target_handle": "top-2-t"},
|
|
headers=headers,
|
|
)
|
|
|
|
assert res.status_code == 201
|
|
assert res.json()["target_handle"] == "top-2-t"
|
|
|
|
|
|
async def test_create_edge_leaves_a_foreign_handle_namespace_alone(client: AsyncClient, headers: dict, two_nodes):
|
|
# 'cluster-right' is not a per-side handle; no count here describes it, so
|
|
# the guard must not judge it.
|
|
src, tgt = two_nodes
|
|
res = await client.post(
|
|
"/api/v1/edges",
|
|
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "cluster-right"},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 201
|
|
|
|
|
|
async def test_update_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
|
|
src, tgt = two_nodes
|
|
edge_id = (
|
|
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
|
).json()["id"]
|
|
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-9"}, headers=headers)
|
|
|
|
assert res.status_code == 422
|
|
edge = (await client.get("/api/v1/edges", headers=headers)).json()[0]
|
|
assert edge["source_handle"] == "bottom"
|
|
|
|
|
|
async def test_update_edge_accepts_a_handle_the_node_has(client: AsyncClient, headers: dict):
|
|
src = (
|
|
await client.post(
|
|
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
|
|
)
|
|
).json()["id"]
|
|
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
|
edge_id = (
|
|
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
|
).json()["id"]
|
|
|
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-4"}, headers=headers)
|
|
|
|
assert res.status_code == 200
|
|
assert res.json()["source_handle"] == "bottom-4"
|
|
|
|
|
|
async def test_auto_assignment_avoids_a_side_with_no_connection_point(client: AsyncClient, headers: dict):
|
|
# The caller supplied no handle, so the server corrects its own pick rather
|
|
# than raising: bottom is gone, and the node's remaining side is used.
|
|
src = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "switch", "label": "sw1", "bottom_handles": 0, "top_handles": 0, "right_handles": 2},
|
|
headers=headers,
|
|
)
|
|
).json()["id"]
|
|
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
|
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
|
|
|
assert res.status_code == 201
|
|
assert res.json()["source_handle"] == "right"
|
|
assert res.json()["target_handle"] == "top-t"
|
|
|
|
|
|
async def test_auto_assignment_keeps_its_pick_when_the_node_has_nothing(client: AsyncClient, headers: dict):
|
|
# Every side is empty: there is no better handle to reach for, and refusing
|
|
# the edge would lose more than it saves.
|
|
src = (
|
|
await client.post(
|
|
"/api/v1/nodes",
|
|
json={"type": "switch", "label": "sw1", "top_handles": 0, "bottom_handles": 0},
|
|
headers=headers,
|
|
)
|
|
).json()["id"]
|
|
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
|
|
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
|
|
|
assert res.status_code == 201
|
|
assert res.json()["source_handle"] == "bottom"
|