mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
2387 lines
84 KiB
Python
2387 lines
84 KiB
Python
"""Unit tests for fabric topology + LLDP parsers (no CDP discovery)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
import unittest
|
||
from unittest.mock import patch
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.exc import OperationalError
|
||
|
||
from netx_api import topology_lldp as lldp
|
||
from netx_api import topology_service as svc
|
||
from netx_api.db import Base, SessionLocal, engine
|
||
from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, TOPOLOGY_NE_SOURCE, WEBCRT_NE_SOURCE
|
||
from netx_api.models import (
|
||
ManagedNE,
|
||
TopoDiscoverJob,
|
||
TopoDiscoverJobItem,
|
||
TopoFabricEdge,
|
||
TopoFabricNode,
|
||
TopoFolder,
|
||
TopoView,
|
||
TopoViewEdgeStyle,
|
||
TopoViewNode,
|
||
)
|
||
from netx_api.topology_schemas import (
|
||
FabricDiscoverRequest,
|
||
TopologyFolderCreate,
|
||
TopologyPlaceholderCreate,
|
||
TopologyViewCreate,
|
||
ViewNodesAdd,
|
||
ViewPopulateRequest,
|
||
ViewPositionsPatch,
|
||
ViewProjectNeighborsRequest,
|
||
ViewNodeIn,
|
||
)
|
||
|
||
|
||
CISCO_LLDP_BRIEF = """
|
||
R2#show lldp neighbors
|
||
Capability codes:
|
||
(R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device
|
||
(W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other
|
||
|
||
Device ID Local Intf Hold-time Capability Port ID
|
||
r1 Gi0/1 120 B,R Ethernet1/0/1
|
||
|
||
Total entries displayed: 1
|
||
"""
|
||
|
||
CISCO_LLDP_DETAIL = """
|
||
------------------------------------------------
|
||
Local Intf: Gi0/1
|
||
Chassis id: 707b.5c6e.d130
|
||
Port id: Ethernet1/0/1
|
||
System Name: r1
|
||
Management Addresses:
|
||
IP: 192.168.0.1
|
||
"""
|
||
|
||
HUAWEI_LLDP = """
|
||
GigabitEthernet0/0/1 has 1 neighbor(s):
|
||
|
||
Neighbor index : 1
|
||
Chassis ID : 00e0-fc12-3456
|
||
Port ID : GigabitEthernet0/0/2
|
||
System name : r1
|
||
Management address : 192.168.0.127
|
||
Local Interface: GigabitEthernet0/0/1
|
||
"""
|
||
|
||
ZTE_LLDP_BRIEF = """
|
||
Local Interface Chassis ID Port ID System Name
|
||
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
||
"""
|
||
|
||
ZTE_LLDP_SCOPE = """
|
||
Total neighbors: 12
|
||
Local Interface Scope Chassis ID Port ID Holdtime System Name
|
||
-----------------------------------------------------------------------------------------
|
||
xgei-1/1/0/1 NB 026e.8219.bc57 xgei-1/1/0/26 100 CSR1_6120HSC
|
||
xgei-1/1/0/3 NB 00d0.0000.081f gei-1/2/0/3 113 OLT/CPE_6180H
|
||
cgei-1/1/0/34 NB 0022.9354.6e60 cgei-0/3/0/34 110 AG5
|
||
"""
|
||
|
||
|
||
class LldpParserTests(unittest.TestCase):
|
||
def test_cisco_brief(self) -> None:
|
||
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
|
||
self.assertEqual(len(hits), 1)
|
||
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
||
self.assertEqual(hits[0].local_port, "Gi0/1")
|
||
self.assertEqual(hits[0].remote_port, "Ethernet1/0/1")
|
||
|
||
def test_cisco_detail(self) -> None:
|
||
hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL)
|
||
self.assertEqual(len(hits), 1)
|
||
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
||
self.assertEqual(hits[0].remote_ip, "192.168.0.1")
|
||
|
||
def test_no_vendor_skips_discover(self) -> None:
|
||
self.assertFalse(lldp.can_discover_lldp(vendor="", device_type=""))
|
||
self.assertFalse(lldp.can_discover_lldp(vendor="", device_type="generic"))
|
||
self.assertTrue(lldp.can_discover_lldp(vendor="Cisco", device_type="cisco_ios"))
|
||
hits = lldp.parse_neighbor_output(
|
||
CISCO_LLDP_BRIEF,
|
||
vendor="",
|
||
device_type="generic",
|
||
command="show lldp neighbors",
|
||
)
|
||
self.assertEqual(hits, [])
|
||
|
||
def test_huawei(self) -> None:
|
||
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
||
self.assertGreaterEqual(len(hits), 1)
|
||
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
||
self.assertEqual(hits[0].local_port, "GigabitEthernet0/0/1")
|
||
|
||
def test_zte_brief(self) -> None:
|
||
hits = lldp.parse_zte_lldp(ZTE_LLDP_BRIEF)
|
||
self.assertEqual(len(hits), 1)
|
||
self.assertEqual(hits[0].remote_name, "R1")
|
||
|
||
def test_zte_scope_holdtime(self) -> None:
|
||
hits = lldp.parse_zte_lldp(ZTE_LLDP_SCOPE)
|
||
self.assertEqual(len(hits), 3)
|
||
self.assertEqual(hits[0].remote_name, "CSR1_6120HSC")
|
||
self.assertEqual(hits[1].remote_name, "OLT/CPE_6180H")
|
||
self.assertEqual(hits[1].remote_port, "gei-1/2/0/3")
|
||
|
||
def test_pick_command_lldp_only(self) -> None:
|
||
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
||
self.assertEqual(tag, "lldp")
|
||
self.assertEqual(cmd, "show lldp neighbors detail")
|
||
|
||
def test_pick_command_community_vendors(self) -> None:
|
||
self.assertEqual(
|
||
lldp.pick_neighbor_command(vendor="H3C", device_type="hp_comware")[0],
|
||
"display lldp neighbor-information list",
|
||
)
|
||
self.assertEqual(
|
||
lldp.pick_neighbor_command(vendor="Juniper", device_type="juniper_junos")[0],
|
||
"show lldp neighbors",
|
||
)
|
||
self.assertEqual(
|
||
lldp.pick_neighbor_command(vendor="Nokia", device_type="alcatel_aos")[0],
|
||
"show lldp remote-system",
|
||
)
|
||
key, stub = lldp.parser_meta(vendor="H3C", device_type="hp_comware")
|
||
self.assertEqual(key, "h3c")
|
||
self.assertFalse(stub)
|
||
key, stub = lldp.parser_meta(vendor="Ericsson", device_type="ericsson_ipos")
|
||
self.assertTrue(stub)
|
||
|
||
|
||
class DeadlockHelperTests(unittest.TestCase):
|
||
def test_is_deadlock_error_detects_pg_message(self) -> None:
|
||
exc = Exception(
|
||
'(psycopg.errors.DeadlockDetected) 检测到死锁 DETAIL: 进程28484等待'
|
||
)
|
||
self.assertTrue(svc._is_deadlock_error(exc))
|
||
|
||
def test_is_deadlock_error_ignores_other(self) -> None:
|
||
self.assertFalse(svc._is_deadlock_error(ValueError("unique violation")))
|
||
|
||
|
||
class FabricTopologyTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
Base.metadata.create_all(bind=engine)
|
||
from netx_api.topology_migrate import ensure_topology_schema
|
||
|
||
with engine.begin() as conn:
|
||
ensure_topology_schema(conn)
|
||
|
||
def setUp(self) -> None:
|
||
self.db = SessionLocal()
|
||
# Shared SQLite DB across tests — wipe fabric/view state for isolation.
|
||
for model in (
|
||
TopoViewEdgeStyle,
|
||
TopoViewNode,
|
||
TopoView,
|
||
TopoFolder,
|
||
TopoFabricEdge,
|
||
TopoFabricNode,
|
||
TopoDiscoverJobItem,
|
||
TopoDiscoverJob,
|
||
):
|
||
self.db.query(model).delete()
|
||
self.db.commit()
|
||
|
||
def tearDown(self) -> None:
|
||
self.db.close()
|
||
|
||
def test_apply_discover_hits_retries_deadlock(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"R2-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 20}",
|
||
)
|
||
peer_ne = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name="r1",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 120}",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.add(peer_ne)
|
||
self.db.commit()
|
||
node = svc.ensure_fabric_node_for_managed(self.db, ne)
|
||
svc.ensure_fabric_node_for_managed(self.db, peer_ne)
|
||
self.db.commit()
|
||
hits = [
|
||
lldp.NeighborHit(
|
||
remote_name="r1",
|
||
local_port="Gi0/1",
|
||
remote_port="Ethernet1/0/1",
|
||
)
|
||
]
|
||
calls = {"n": 0}
|
||
real_upsert = svc.upsert_fabric_edge
|
||
|
||
def flaky_upsert(*args, **kwargs):
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise OperationalError(
|
||
"INSERT",
|
||
{},
|
||
Exception("DeadlockDetected: fake"),
|
||
)
|
||
return real_upsert(*args, **kwargs)
|
||
|
||
with (
|
||
patch.object(svc, "upsert_fabric_edge", side_effect=flaky_upsert),
|
||
patch("netx_api.topology_discover_scan.upsert_fabric_edge", side_effect=flaky_upsert),
|
||
patch("netx_api.topology_discover_scan._sleep_deadlock_backoff", return_value=None),
|
||
):
|
||
out = svc._apply_discover_hits(
|
||
self.db,
|
||
fabric_node_id=node.id,
|
||
hits=hits,
|
||
auto_add_unmatched=False,
|
||
)
|
||
self.assertTrue(out.get("ok"), out)
|
||
self.assertEqual(calls["n"], 2)
|
||
self.assertGreaterEqual(int(out.get("edges_added") or 0), 1)
|
||
|
||
def test_apply_discover_hits_skips_self_loop(self) -> None:
|
||
"""LLDP advertising itself must not fail the whole apply; other peers still count."""
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"R-self-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 20}",
|
||
)
|
||
peer_ne = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name=f"r-peer-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 120}",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.add(peer_ne)
|
||
self.db.commit()
|
||
node = svc.ensure_fabric_node_for_managed(self.db, ne)
|
||
peer = svc.ensure_fabric_node_for_managed(self.db, peer_ne)
|
||
self.db.commit()
|
||
hits = [
|
||
lldp.NeighborHit(
|
||
remote_name=ne.name,
|
||
remote_ip=ne.ip_address,
|
||
local_port="Gi0/0",
|
||
remote_port="Gi0/0",
|
||
),
|
||
lldp.NeighborHit(
|
||
remote_name=peer_ne.name,
|
||
remote_ip=peer_ne.ip_address,
|
||
local_port="Gi0/1",
|
||
remote_port="Ethernet1/0/1",
|
||
),
|
||
]
|
||
out = svc._apply_discover_hits(
|
||
self.db,
|
||
fabric_node_id=node.id,
|
||
hits=hits,
|
||
auto_add_unmatched=False,
|
||
)
|
||
self.assertTrue(out.get("ok"), out)
|
||
self.assertEqual(int(out.get("edges_added") or 0), 1)
|
||
edges = (
|
||
self.db.query(TopoFabricEdge)
|
||
.filter(
|
||
(TopoFabricEdge.a_node_id == node.id) | (TopoFabricEdge.b_node_id == node.id)
|
||
)
|
||
.all()
|
||
)
|
||
self.assertEqual(len(edges), 1)
|
||
ids = {edges[0].a_node_id, edges[0].b_node_id}
|
||
self.assertEqual(ids, {node.id, peer.id})
|
||
|
||
skipped, action = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=node.id,
|
||
b_node_id=node.id,
|
||
a_port="Lo0",
|
||
b_port="Lo0",
|
||
source="lldp",
|
||
)
|
||
self.assertIsNone(skipped)
|
||
self.assertEqual(action, "skipped_self_loop")
|
||
|
||
def _region(self, name: str = "Test-Region") -> str:
|
||
"""Return the unique L2「根图」canvas id under a new top-level「根」."""
|
||
top = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name=name, kind="region")
|
||
)
|
||
child = (
|
||
self.db.query(TopoFolder)
|
||
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||
.first()
|
||
)
|
||
return child.id if child is not None else top.id
|
||
|
||
def test_view_crud_and_positions(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"ne-{suffix}",
|
||
name=f"R-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.0.0.{(int(suffix[:2], 16) % 200) + 1}",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.commit()
|
||
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"R-{suffix}")),
|
||
)
|
||
graph = svc.add_nodes_to_view(
|
||
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
||
)
|
||
self.assertEqual(len(graph.nodes), 1)
|
||
fid = graph.nodes[0].fabric_node_id
|
||
graph2 = svc.patch_view_positions(
|
||
self.db,
|
||
view.id,
|
||
ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]),
|
||
)
|
||
self.assertEqual(graph2.nodes[0].x, 120)
|
||
self.assertEqual(graph2.nodes[0].y, 80)
|
||
|
||
summary = svc.get_fabric_summary(self.db)
|
||
self.assertGreaterEqual(summary.node_count, 1)
|
||
|
||
svc.delete_view(self.db, view.id)
|
||
self.db.delete(ne)
|
||
self.db.commit()
|
||
|
||
def test_create_topology_placeholder_on_view(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"Vph-{suffix}", folder_id=self._region(f"Rph-{suffix}")),
|
||
)
|
||
graph = svc.create_topology_placeholder_on_view(
|
||
self.db,
|
||
view.id,
|
||
TopologyPlaceholderCreate(name=f"SW-{suffix}", ip_address="", x=42, y=77),
|
||
)
|
||
self.assertEqual(len(graph.nodes), 1)
|
||
node = graph.nodes[0]
|
||
self.assertEqual(node.name, f"SW-{suffix}")
|
||
self.assertEqual(node.x, 42)
|
||
self.assertEqual(node.y, 77)
|
||
self.assertTrue(node.managed_ne_id)
|
||
ne = self.db.get(ManagedNE, node.managed_ne_id)
|
||
self.assertIsNotNone(ne)
|
||
assert ne is not None
|
||
self.assertEqual(ne.source, TOPOLOGY_NE_SOURCE)
|
||
self.assertEqual(ne.ip_address, "")
|
||
self.assertEqual(ne.device_type, "generic")
|
||
|
||
def test_legacy_single_map_heals_into_root_map(self) -> None:
|
||
"""Pre-architecture: physical view hung on top-level region (no「根图」)."""
|
||
from uuid import uuid4
|
||
|
||
from netx_api.models import TopoFolder, TopoView
|
||
from netx_api.topology_common import _utcnow
|
||
from netx_api.topology_membership import VIEW_KIND_PHYSICAL
|
||
|
||
now = _utcnow()
|
||
boot = svc.bootstrap_topology_tree(self.db)
|
||
root = self.db.get(TopoFolder, boot["root_id"])
|
||
self.assertIsNotNone(root)
|
||
assert root is not None
|
||
legacy = TopoFolder(
|
||
id=uuid4().hex,
|
||
parent_id=root.id,
|
||
kind="region",
|
||
name="旧单图站点",
|
||
sort_order=0,
|
||
is_system=False,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
old_view = TopoView(
|
||
id=uuid4().hex,
|
||
folder_id=legacy.id,
|
||
parent_view_id=None,
|
||
kind=VIEW_KIND_PHYSICAL,
|
||
role="core",
|
||
name="拓扑图",
|
||
remark="",
|
||
sort_order=0,
|
||
filter={},
|
||
viewport={},
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.db.add(legacy)
|
||
self.db.add(old_view)
|
||
self.db.commit()
|
||
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
site = next(c for c in tree.root.children if c.id == legacy.id)
|
||
self.assertEqual(site.views, [])
|
||
self.assertEqual(len(site.children), 1)
|
||
root_map = site.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
self.assertTrue(any(v.id == old_view.id for v in root_map.views))
|
||
# Creating another top-level root still works beside the healed legacy site.
|
||
created = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="新根", kind="region")
|
||
)
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
self.assertTrue(any(c.id == created.id for c in tree2.root.children))
|
||
self.assertTrue(any(c.id == legacy.id for c in tree2.root.children))
|
||
|
||
def test_topology_tree_root_region_and_leaf(self) -> None:
|
||
tree = svc.get_topology_tree(self.db)
|
||
self.assertIsNotNone(tree.root)
|
||
assert tree.root is not None
|
||
self.assertEqual(tree.root.kind, "root")
|
||
# No default Unassigned region — only user-created regions under hidden root.
|
||
self.assertEqual(tree.root.children, [])
|
||
|
||
region = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="East", kind="region")
|
||
)
|
||
# Top-level「根」is nav-only; unique L2「根图」holds the canvas.
|
||
tree1 = svc.get_topology_tree(self.db)
|
||
assert tree1.root is not None
|
||
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
||
self.assertEqual(east0.views, [])
|
||
self.assertEqual(len(east0.children), 1)
|
||
root_map = east0.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
self.assertEqual(len(root_map.views), 1)
|
||
self.assertEqual(root_map.views[0].name, "根图")
|
||
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name="East-Custom",
|
||
folder_id=root_map.id,
|
||
kind="custom",
|
||
),
|
||
)
|
||
self.assertEqual(view.folder_id, root_map.id)
|
||
self.assertEqual(view.kind, "custom")
|
||
self.assertIn("membership", view.filter)
|
||
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
east = next(c for c in tree2.root.children if c.id == region.id)
|
||
rm = east.children[0]
|
||
self.assertTrue(any(v.id == view.id for v in rm.views))
|
||
self.assertEqual(len(rm.views), 2)
|
||
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views))
|
||
|
||
def test_tree_ne_count_distinct_membership_and_home(self) -> None:
|
||
"""Directory N = distinct fabric ids (membership ∪ region_folder_id); parent unions."""
|
||
site = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="NE-Count-Site", kind="region")
|
||
)
|
||
tree0 = svc.get_topology_tree(self.db)
|
||
assert tree0.root is not None
|
||
site_f = next(c for c in tree0.root.children if c.id == site.id)
|
||
root_map = site_f.children[0]
|
||
self.assertEqual(site_f.ne_count, 0)
|
||
self.assertEqual(root_map.ne_count, 0)
|
||
self.assertEqual(root_map.views[0].node_count, 0)
|
||
|
||
a = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="Zone-A", kind="region", parent_id=site.id),
|
||
)
|
||
b = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="Zone-B", kind="region", parent_id=site.id),
|
||
)
|
||
tree1 = svc.get_topology_tree(self.db)
|
||
assert tree1.root is not None
|
||
site1 = next(c for c in tree1.root.children if c.id == site.id)
|
||
rm1 = site1.children[0]
|
||
za = next(c for c in rm1.children if c.id == a.id)
|
||
zb = next(c for c in rm1.children if c.id == b.id)
|
||
# Empty nested regions (+ region icons on parent) still 0 NE.
|
||
self.assertEqual(za.ne_count, 0)
|
||
self.assertEqual(zb.ne_count, 0)
|
||
self.assertEqual(rm1.ne_count, 0)
|
||
|
||
suffix = uuid4().hex[:8]
|
||
shared = TopoFabricNode(
|
||
id=f"ne-shared-{suffix}",
|
||
name=f"Shared-{suffix}",
|
||
ip="10.66.0.1",
|
||
managed_ne_id=f"mne-shared-{suffix}",
|
||
)
|
||
only_a = TopoFabricNode(
|
||
id=f"ne-a-{suffix}",
|
||
name=f"OnlyA-{suffix}",
|
||
ip="10.66.0.2",
|
||
managed_ne_id=f"mne-a-{suffix}",
|
||
)
|
||
home_only = TopoFabricNode(
|
||
id=f"ne-home-{suffix}",
|
||
name=f"Home-{suffix}",
|
||
ip="10.66.0.3",
|
||
managed_ne_id=f"mne-home-{suffix}",
|
||
region_folder_id=a.id,
|
||
)
|
||
self.db.add_all([shared, only_a, home_only])
|
||
self.db.add(
|
||
TopoViewNode(
|
||
id=uuid4().hex,
|
||
view_id=za.views[0].id,
|
||
fabric_node_id=shared.id,
|
||
x=0,
|
||
y=0,
|
||
)
|
||
)
|
||
self.db.add(
|
||
TopoViewNode(
|
||
id=uuid4().hex,
|
||
view_id=zb.views[0].id,
|
||
fabric_node_id=shared.id,
|
||
x=1,
|
||
y=1,
|
||
)
|
||
)
|
||
self.db.add(
|
||
TopoViewNode(
|
||
id=uuid4().hex,
|
||
view_id=za.views[0].id,
|
||
fabric_node_id=only_a.id,
|
||
x=2,
|
||
y=2,
|
||
)
|
||
)
|
||
self.db.commit()
|
||
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
site2 = next(c for c in tree2.root.children if c.id == site.id)
|
||
rm2 = site2.children[0]
|
||
za2 = next(c for c in rm2.children if c.id == a.id)
|
||
zb2 = next(c for c in rm2.children if c.id == b.id)
|
||
|
||
# Zone-A: shared + only_a + home_only (region_folder_id, no membership)
|
||
self.assertEqual(za2.ne_count, 3)
|
||
self.assertEqual(za2.views[0].node_count, 2)
|
||
# Zone-B: shared only
|
||
self.assertEqual(zb2.ne_count, 1)
|
||
self.assertEqual(zb2.views[0].node_count, 1)
|
||
# Parent 根图: distinct union → shared, only_a, home_only = 3 (not 3+1)
|
||
self.assertEqual(rm2.ne_count, 3)
|
||
self.assertEqual(site2.ne_count, 3)
|
||
|
||
def test_manual_root_map_name_follows_locale(self) -> None:
|
||
"""English UI creates Root map; zh (default) keeps 根图."""
|
||
en_root = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="West-EN", kind="region", locale="en")
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
west = next(c for c in tree.root.children if c.id == en_root.id)
|
||
self.assertEqual(len(west.children), 1)
|
||
self.assertEqual(west.children[0].name, "Root map")
|
||
self.assertEqual(west.children[0].views[0].name, "Root map")
|
||
|
||
zh_root = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="East-ZH", kind="region", locale="zh")
|
||
)
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
east = next(c for c in tree2.root.children if c.id == zh_root.id)
|
||
self.assertEqual(east.children[0].name, "根图")
|
||
|
||
def test_site_physical_and_custom_flat(self) -> None:
|
||
top = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||
)
|
||
tree0 = svc.get_topology_tree(self.db)
|
||
assert tree0.root is not None
|
||
nav = next(c for c in tree0.root.children if c.id == top.id)
|
||
root_map = nav.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
auto_phys = root_map.views[0]
|
||
self.assertEqual(auto_phys.kind, "physical")
|
||
|
||
custom = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name="Custom-A",
|
||
folder_id=root_map.id,
|
||
kind="custom",
|
||
),
|
||
)
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
reg2 = next(c for c in tree2.root.children if c.id == top.id).children[0]
|
||
ids = {v.id for v in reg2.views}
|
||
self.assertIn(auto_phys.id, ids)
|
||
self.assertIn(custom.id, ids)
|
||
# physical first
|
||
self.assertEqual(reg2.views[0].kind, "physical")
|
||
|
||
with self.assertRaises(Exception):
|
||
svc.delete_view(self.db, auto_phys.id)
|
||
svc.delete_view(self.db, custom.id)
|
||
# force-delete physical does not recreate another map
|
||
svc.delete_view(self.db, auto_phys.id, force=True)
|
||
tree3 = svc.get_topology_tree(self.db)
|
||
assert tree3.root is not None
|
||
reg3 = next(c for c in tree3.root.children if c.id == top.id).children[0]
|
||
self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0)
|
||
|
||
def test_delete_region_cascades_views(self) -> None:
|
||
region = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="Del-Region", kind="region")
|
||
)
|
||
tree0 = svc.get_topology_tree(self.db)
|
||
assert tree0.root is not None
|
||
nav = next(c for c in tree0.root.children if c.id == region.id)
|
||
root_map = nav.children[0]
|
||
custom = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name="Custom-Del",
|
||
folder_id=root_map.id,
|
||
kind="custom",
|
||
),
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
reg = next(c for c in tree.root.children if c.id == region.id)
|
||
self.assertTrue(any(v.id == custom.id for v in reg.children[0].views))
|
||
|
||
out = svc.delete_folder(self.db, region.id)
|
||
self.assertTrue(out.get("deleted"))
|
||
tree2 = svc.get_topology_tree(self.db)
|
||
assert tree2.root is not None
|
||
self.assertFalse(any(c.id == region.id for c in tree2.root.children))
|
||
self.assertIsNone(self.db.get(TopoView, custom.id))
|
||
self.assertEqual(
|
||
self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(),
|
||
0,
|
||
)
|
||
|
||
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
|
||
# Top-level「根」is nav-only; canvas work happens under unique「根图」.
|
||
container = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="Site-Group", kind="region")
|
||
)
|
||
parent = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(
|
||
name="Parent-Canvas", kind="region", parent_id=container.id
|
||
),
|
||
)
|
||
child = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="Child-Icon", kind="region", parent_id=parent.id),
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
group = next(c for c in tree.root.children if c.id == container.id)
|
||
self.assertEqual(group.views, [])
|
||
self.assertEqual(len(group.children), 1)
|
||
root_map = group.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
# Creating under the nav「根」remounts onto「根图」.
|
||
p = next(c for c in root_map.children if c.id == parent.id)
|
||
self.assertTrue(p.views)
|
||
graph = svc.get_view_graph(self.db, p.views[0].id)
|
||
regions = [n for n in graph.nodes if n.kind == "region"]
|
||
self.assertEqual(len(regions), 1)
|
||
self.assertEqual(regions[0].folder_id, child.id)
|
||
self.assertEqual(regions[0].label, "Child-Icon")
|
||
self.assertTrue(str(regions[0].fabric_node_id).startswith("region:"))
|
||
|
||
# Drag/save positions for synthetic region nodes.
|
||
moved = svc.patch_view_positions(
|
||
self.db,
|
||
p.views[0].id,
|
||
ViewPositionsPatch(
|
||
positions=[
|
||
ViewNodeIn(
|
||
fabric_node_id=regions[0].fabric_node_id, x=321, y=210
|
||
)
|
||
],
|
||
return_graph=True,
|
||
),
|
||
)
|
||
if hasattr(moved, "nodes"):
|
||
hit = next(n for n in moved.nodes if n.kind == "region")
|
||
else:
|
||
hit = next(
|
||
n
|
||
for n in svc.get_view_graph(self.db, p.views[0].id).nodes
|
||
if n.kind == "region"
|
||
)
|
||
self.assertEqual(hit.x, 321)
|
||
self.assertEqual(hit.y, 210)
|
||
|
||
svc.delete_folder(self.db, child.id)
|
||
graph2 = svc.get_view_graph(self.db, p.views[0].id)
|
||
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
|
||
|
||
def test_top_level_stays_nav_when_adding_child(self) -> None:
|
||
"""Creating under「根」remounts onto unique「根图」; top stays nav-only."""
|
||
top = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="East-Nav", kind="region")
|
||
)
|
||
child = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="Site-A", kind="region", parent_id=top.id),
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
east = next(c for c in tree.root.children if c.id == top.id)
|
||
self.assertEqual(east.views, [])
|
||
self.assertEqual(len(east.children), 1)
|
||
root_map = east.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
self.assertEqual(len(root_map.views), 1)
|
||
site = next(c for c in root_map.children if c.id == child.id)
|
||
self.assertEqual(len(site.views), 1)
|
||
self.assertEqual(site.views[0].name, "Site-A")
|
||
self.assertNotEqual(site.views[0].name, "Physical topology")
|
||
|
||
def test_root_auto_spawns_unique_root_map(self) -> None:
|
||
top = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="West", kind="region")
|
||
)
|
||
# Second create under the same「根」must remount under existing「根图」.
|
||
a = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="A", kind="region", parent_id=top.id),
|
||
)
|
||
b = svc.create_folder(
|
||
self.db,
|
||
TopologyFolderCreate(name="B", kind="region", parent_id=top.id),
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
west = next(c for c in tree.root.children if c.id == top.id)
|
||
self.assertEqual(len(west.children), 1)
|
||
root_map = west.children[0]
|
||
self.assertEqual(root_map.name, "根图")
|
||
child_ids = {c.id for c in root_map.children}
|
||
self.assertEqual(child_ids, {a.id, b.id})
|
||
|
||
def test_root_map_rename_ok_but_not_direct_delete(self) -> None:
|
||
from fastapi import HTTPException
|
||
|
||
from netx_api.topology_schemas import TopologyFolderUpdate
|
||
|
||
top = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name="North", kind="region")
|
||
)
|
||
tree = svc.get_topology_tree(self.db)
|
||
assert tree.root is not None
|
||
north = next(c for c in tree.root.children if c.id == top.id)
|
||
root_map = north.children[0]
|
||
self.assertTrue(root_map.is_system)
|
||
renamed = svc.update_folder(
|
||
self.db, root_map.id, TopologyFolderUpdate(name="主画布")
|
||
)
|
||
self.assertEqual(renamed.name, "主画布")
|
||
with self.assertRaises(HTTPException) as ctx:
|
||
svc.delete_folder(self.db, root_map.id)
|
||
self.assertEqual(ctx.exception.detail, "cannot_delete_system_folder")
|
||
# Deleting the manual「根」cascades the「根图」.
|
||
out = svc.delete_folder(self.db, top.id)
|
||
self.assertTrue(out.get("deleted"))
|
||
self.assertIsNone(self.db.get(TopoFolder, root_map.id))
|
||
|
||
def test_classify_role_region_and_slices(self) -> None:
|
||
from netx_api import topology_classify as clf
|
||
from netx_api.topology_schemas import (
|
||
ClassifyRuleCreate,
|
||
SliceGenerateRequest,
|
||
)
|
||
|
||
suffix = uuid4().hex[:6]
|
||
region = svc.create_folder(
|
||
self.db, TopologyFolderCreate(name=f"East-{suffix}", kind="region")
|
||
)
|
||
nes = []
|
||
for name, ip in (
|
||
(f"CORE-A-{suffix}", "10.1.1.1"),
|
||
(f"CORE-B-{suffix}", "10.1.1.2"),
|
||
(f"AGG-A-{suffix}", "10.1.2.1"),
|
||
(f"ACC-A-{suffix}", "10.1.3.1"),
|
||
):
|
||
ne = ManagedNE(
|
||
id=f"clf-{suffix}-{name[:8]}",
|
||
name=name,
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=ip,
|
||
)
|
||
self.db.add(ne)
|
||
nes.append(ne)
|
||
self.db.commit()
|
||
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||
self.db.commit()
|
||
svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=nodes[0].id,
|
||
b_node_id=nodes[2].id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=nodes[2].id,
|
||
b_node_id=nodes[3].id,
|
||
a_port="Gi0/2",
|
||
b_port="Gi0/3",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
clf.create_rule(
|
||
self.db,
|
||
ClassifyRuleCreate(
|
||
scope="role",
|
||
name="core",
|
||
pattern=r"^CORE-",
|
||
priority=10,
|
||
payload={"role": "core"},
|
||
),
|
||
)
|
||
clf.create_rule(
|
||
self.db,
|
||
ClassifyRuleCreate(
|
||
scope="role",
|
||
name="agg",
|
||
pattern=r"^AGG-",
|
||
priority=20,
|
||
payload={"role": "aggregation"},
|
||
),
|
||
)
|
||
clf.create_rule(
|
||
self.db,
|
||
ClassifyRuleCreate(
|
||
scope="role",
|
||
name="acc",
|
||
pattern=r"^ACC-",
|
||
priority=30,
|
||
payload={"role": "access"},
|
||
),
|
||
)
|
||
clf.create_rule(
|
||
self.db,
|
||
ClassifyRuleCreate(
|
||
scope="region",
|
||
name="east",
|
||
pattern=rf"-{suffix}$",
|
||
priority=10,
|
||
payload={"folder_id": region.id},
|
||
),
|
||
)
|
||
prev = clf.preview_classify(self.db)
|
||
self.assertGreaterEqual(prev.role_matched, 4)
|
||
applied = clf.apply_classify(self.db)
|
||
self.assertGreaterEqual(applied.role_updated, 4)
|
||
self.db.refresh(nodes[0])
|
||
self.assertEqual(nodes[0].role, "core")
|
||
self.assertEqual(nodes[0].region_folder_id, region.id)
|
||
|
||
dry = clf.generate_slices(
|
||
self.db,
|
||
SliceGenerateRequest(
|
||
folder_id=region.id,
|
||
template="core_agg",
|
||
dry_run=True,
|
||
max_nodes=50,
|
||
),
|
||
)
|
||
self.assertGreaterEqual(dry.map_count, 1)
|
||
self.assertTrue(dry.dry_run)
|
||
real = clf.generate_slices(
|
||
self.db,
|
||
SliceGenerateRequest(
|
||
folder_id=region.id,
|
||
template="core_agg",
|
||
dry_run=False,
|
||
max_nodes=50,
|
||
),
|
||
)
|
||
self.assertFalse(real.dry_run)
|
||
self.assertTrue(real.created_view_ids)
|
||
search = clf.search_fabric_nodes_with_views(self.db, keyword=f"CORE-A-{suffix}")
|
||
self.assertGreaterEqual(search["total"], 1)
|
||
self.assertTrue(search["items"][0].get("views"))
|
||
|
||
from netx_api.topology_schemas import FabricNodesBulkTagRequest, FabricNodesMatchRequest
|
||
|
||
matched = clf.match_fabric_nodes(
|
||
self.db,
|
||
FabricNodesMatchRequest(pattern=rf"^AGG-A-{suffix}$", match_field="name"),
|
||
)
|
||
self.assertEqual(matched.total_matched, 1)
|
||
bulk = clf.bulk_tag_fabric_nodes(
|
||
self.db,
|
||
FabricNodesBulkTagRequest(
|
||
pattern=rf"^ACC-A-{suffix}$",
|
||
role="access",
|
||
region_folder_id=region.id,
|
||
),
|
||
)
|
||
self.assertEqual(bulk.updated, 1)
|
||
|
||
def test_project_neighbors_respects_max_nodes(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
nes = []
|
||
for i in range(4):
|
||
ne = ManagedNE(
|
||
id=f"cap-{suffix}-{i}",
|
||
name=f"N{i}-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.9.{(int(suffix[:2], 16) % 200)}.{i + 1}",
|
||
)
|
||
self.db.add(ne)
|
||
nes.append(ne)
|
||
self.db.commit()
|
||
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||
self.db.commit()
|
||
# Line: 0-1-2-3
|
||
for a, b in ((0, 1), (1, 2), (2, 3)):
|
||
svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=nodes[a].id,
|
||
b_node_id=nodes[b].id,
|
||
a_port=f"Gi0/{a}",
|
||
b_port=f"Gi0/{b}",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name=f"Cap-{suffix}",
|
||
folder_id=self._region(f"CapR-{suffix}"),
|
||
role="core",
|
||
filter={
|
||
"membership": {
|
||
"expand_hops": 3,
|
||
"max_nodes": 2,
|
||
"frozen": False,
|
||
}
|
||
},
|
||
),
|
||
)
|
||
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id]))
|
||
g = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
||
self.assertLessEqual(len(g.nodes), 2)
|
||
self.assertTrue(g.truncated or len(g.nodes) == 2)
|
||
|
||
# Seed-scoped project: expand only from node 0 → only node 1 among line peers.
|
||
view2 = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name=f"CapSeed-{suffix}",
|
||
folder_id=self._region(f"CapSeedR-{suffix}"),
|
||
role="core",
|
||
filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
|
||
),
|
||
)
|
||
# Place endpoints 0 and 2 (not adjacent); seed from 0 should add 1, not 3.
|
||
svc.add_nodes_to_view(
|
||
self.db, view2.id, ViewNodesAdd(managed_ne_ids=[nes[0].id, nes[2].id])
|
||
)
|
||
g2 = svc.project_fabric_neighbors_to_view(
|
||
self.db,
|
||
view2.id,
|
||
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]),
|
||
)
|
||
ids2 = {n.fabric_node_id for n in g2.nodes}
|
||
self.assertIn(nodes[0].id, ids2)
|
||
self.assertIn(nodes[1].id, ids2)
|
||
self.assertIn(nodes[2].id, ids2)
|
||
self.assertNotIn(nodes[3].id, ids2)
|
||
|
||
# Peers already on canvas must not raise a false truncated banner.
|
||
g3 = svc.project_fabric_neighbors_to_view(
|
||
self.db,
|
||
view2.id,
|
||
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]),
|
||
)
|
||
self.assertFalse(g3.truncated)
|
||
|
||
pop = svc.populate_view(
|
||
self.db,
|
||
view.id,
|
||
ViewPopulateRequest(
|
||
dry_run=True,
|
||
membership={"seed_fabric_node_ids": [nodes[0].id], "expand_hops": 3, "max_nodes": 2},
|
||
),
|
||
)
|
||
self.assertLessEqual(pop.candidate_count, 4)
|
||
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2)
|
||
|
||
def test_project_neighbors_dry_run_does_not_persist(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
nes = []
|
||
for i in range(3):
|
||
ne = ManagedNE(
|
||
id=f"dry-{suffix}-{i}",
|
||
name=f"D{i}-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.19.{(int(suffix[:2], 16) % 200)}.{i + 1}",
|
||
)
|
||
self.db.add(ne)
|
||
nes.append(ne)
|
||
self.db.commit()
|
||
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||
self.db.commit()
|
||
for a, b in ((0, 1), (1, 2)):
|
||
svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=nodes[a].id,
|
||
b_node_id=nodes[b].id,
|
||
a_port=f"Gi0/{a}",
|
||
b_port=f"Gi0/{b}",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(
|
||
name=f"Dry-{suffix}",
|
||
folder_id=self._region(f"DryR-{suffix}"),
|
||
role="core",
|
||
filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
|
||
),
|
||
)
|
||
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id]))
|
||
before = (
|
||
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
|
||
)
|
||
g = svc.project_fabric_neighbors_to_view(
|
||
self.db,
|
||
view.id,
|
||
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True),
|
||
)
|
||
ids = {n.fabric_node_id for n in g.nodes}
|
||
self.assertIn(nodes[0].id, ids)
|
||
self.assertIn(nodes[1].id, ids)
|
||
after = (
|
||
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
|
||
)
|
||
self.assertEqual(after, before)
|
||
|
||
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name="R2",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name="R1",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.add(ne_b)
|
||
self.db.commit()
|
||
# Ensure fabric nodes exist for matching
|
||
svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
self.db.commit()
|
||
|
||
fake_exec = {
|
||
"ok": True,
|
||
"output": CISCO_LLDP_DETAIL,
|
||
"commands": ["show lldp neighbors detail"],
|
||
}
|
||
with patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec):
|
||
job = svc.start_discover_job(
|
||
self.db,
|
||
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
|
||
)
|
||
# Wait for background thread
|
||
for _ in range(50):
|
||
out = svc.get_discover_job(self.db, job.id)
|
||
if out.status in {"done", "failed"}:
|
||
break
|
||
time.sleep(0.05)
|
||
out = svc.get_discover_job(self.db, job.id)
|
||
self.assertEqual(out.status, "done", out.error)
|
||
edges = svc.list_fabric_edges(self.db, page=1, page_size=50)
|
||
self.assertGreaterEqual(edges["total"], 1)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_merge_duplicate_and_project_skips_orphans(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name="R2",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name="R1",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.add(ne_b)
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
orphan = TopoFabricNode(
|
||
id=uuid4().hex,
|
||
managed_ne_id=None,
|
||
ume_ne_id=None,
|
||
name="r1",
|
||
ip="",
|
||
vendor="",
|
||
device_type="",
|
||
attrs={"from_lldp_unmatched": True},
|
||
)
|
||
# Simulate raced duplicate of R2 (same managed id not allowed by constraint —
|
||
# use name/ip collision path with a second orphan twin).
|
||
orphan_r2 = TopoFabricNode(
|
||
id=uuid4().hex,
|
||
managed_ne_id=None,
|
||
ume_ne_id=None,
|
||
name="R2",
|
||
ip=ne_a.ip_address,
|
||
vendor="",
|
||
device_type="",
|
||
attrs={"from_lldp_unmatched": True},
|
||
)
|
||
self.db.add(orphan)
|
||
self.db.add(orphan_r2)
|
||
self.db.commit()
|
||
# Path: orphan_r2 -- fb -- fa -- orphan (the bug shape)
|
||
svc.upsert_fabric_edge(
|
||
self.db, a_node_id=orphan_r2.id, b_node_id=fb.id, a_port="g0", b_port="g1", source="lldp"
|
||
)
|
||
svc.upsert_fabric_edge(
|
||
self.db, a_node_id=fb.id, b_node_id=fa.id, a_port="g2", b_port="g3", source="lldp"
|
||
)
|
||
svc.upsert_fabric_edge(
|
||
self.db, a_node_id=fa.id, b_node_id=orphan.id, a_port="g4", b_port="g5", source="lldp"
|
||
)
|
||
self.db.commit()
|
||
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"MR-{suffix}")),
|
||
)
|
||
graph = svc.add_nodes_to_view(
|
||
self.db,
|
||
view.id,
|
||
ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]),
|
||
)
|
||
self.assertEqual(len(graph.nodes), 4)
|
||
|
||
merged = svc.merge_duplicate_fabric_nodes(self.db)
|
||
self.assertGreaterEqual(merged["merged"], 2)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, orphan.id))
|
||
self.assertIsNone(self.db.get(TopoFabricNode, orphan_r2.id))
|
||
|
||
# Re-place inventory nodes only, then project should stay at 2.
|
||
graph2 = svc.add_nodes_to_view(
|
||
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id])
|
||
)
|
||
# View may still have stale placements pointing at deleted ids — project cleans.
|
||
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
||
self.assertEqual(len(projected.nodes), 2)
|
||
labels = sorted((n.name or n.label).upper() for n in projected.nodes)
|
||
self.assertEqual(labels, ["R1", "R2"])
|
||
self.assertGreaterEqual(len(projected.edges), 1)
|
||
endpoint_ids = {n.fabric_node_id for n in projected.nodes}
|
||
for e in projected.edges:
|
||
self.assertIn(e.a_node_id, endpoint_ids)
|
||
self.assertIn(e.b_node_id, endpoint_ids)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_match_hit_prefers_inventory(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"ne-{suffix}",
|
||
name="R1",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 20}",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.commit()
|
||
inv = svc.ensure_fabric_node_for_managed(self.db, ne)
|
||
orphan = TopoFabricNode(
|
||
id=uuid4().hex,
|
||
name="r1",
|
||
ip="",
|
||
attrs={"from_lldp_unmatched": True},
|
||
)
|
||
self.db.add(orphan)
|
||
self.db.commit()
|
||
hit = lldp.NeighborHit(
|
||
remote_name="r1",
|
||
remote_ip="",
|
||
local_port="Gi0/0",
|
||
remote_port="Gi0/1",
|
||
)
|
||
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||
self.assertEqual(peer.id, inv.id)
|
||
self.db.delete(ne)
|
||
self.db.commit()
|
||
|
||
def test_lldp_unmatched_creates_ssh_placeholder_ne(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
peer_name = f"Peer-{suffix}"
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"Core-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
|
||
source="manual",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.commit()
|
||
svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
self.db.commit()
|
||
|
||
lldp_out = f"""
|
||
------------------------------------------------
|
||
Local Intf: Gi0/1
|
||
Chassis id: 707b.5c6e.d130
|
||
Port id: Ethernet1/0/1
|
||
System Name: {peer_name}
|
||
Management Addresses:
|
||
IP: 198.51.100.200
|
||
"""
|
||
fake_exec = {
|
||
"ok": True,
|
||
"output": lldp_out,
|
||
"commands": ["show lldp neighbors detail"],
|
||
}
|
||
with patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec):
|
||
job = svc.start_discover_job(
|
||
self.db,
|
||
FabricDiscoverRequest(
|
||
scope="ne_ids", ne_ids=[ne_a.id], concurrency=1, auto_add_unmatched=True
|
||
),
|
||
)
|
||
for _ in range(50):
|
||
out = svc.get_discover_job(self.db, job.id)
|
||
if out.status in {"done", "failed"}:
|
||
break
|
||
time.sleep(0.05)
|
||
out = svc.get_discover_job(self.db, job.id)
|
||
self.assertEqual(out.status, "done", out.error)
|
||
|
||
placeholders = (
|
||
self.db.query(ManagedNE)
|
||
.filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE)
|
||
.all()
|
||
)
|
||
placeholder = next(
|
||
(p for p in placeholders if svc._norm_host(p.name) == svc._norm_host(peer_name)),
|
||
None,
|
||
)
|
||
self.assertIsNotNone(placeholder)
|
||
assert placeholder is not None
|
||
self.assertEqual(placeholder.ip_address, "")
|
||
self.assertEqual(placeholder.username, "")
|
||
self.assertEqual(placeholder.password_enc, "")
|
||
self.assertEqual(placeholder.protocol, "ssh")
|
||
self.assertEqual(placeholder.device_type, "generic")
|
||
self.assertEqual(placeholder.source_ref, "198.51.100.200")
|
||
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"V-{suffix}", folder_id=self._region(f"PR-{suffix}")),
|
||
)
|
||
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
|
||
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
||
self.assertEqual(len(projected.nodes), 2)
|
||
self.assertGreaterEqual(len(projected.edges), 1)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(placeholder)
|
||
self.db.commit()
|
||
|
||
def test_match_prefers_real_ne_over_webcrt_same_ip(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ip = f"198.51.100.{(int(suffix[:2], 16) % 80) + 30}"
|
||
real = ManagedNE(
|
||
id=f"real-{suffix}",
|
||
name="R2",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=ip,
|
||
source="manual",
|
||
)
|
||
webcrt = ManagedNE(
|
||
id=f"wcrt-{suffix}",
|
||
name=f"{ip} (1)",
|
||
vendor="Other",
|
||
device_type="generic",
|
||
ip_address=ip,
|
||
source=WEBCRT_NE_SOURCE,
|
||
)
|
||
self.db.add(real)
|
||
self.db.add(webcrt)
|
||
self.db.commit()
|
||
inv = svc.ensure_fabric_node_for_managed(self.db, real)
|
||
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
|
||
self.db.commit()
|
||
# LLDP management IP alone is not identity — need System Name.
|
||
ip_only = lldp.NeighborHit(
|
||
remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
|
||
)
|
||
self.assertIsNone(svc._match_hit_to_fabric_node(self.db, ip_only, self_id="self"))
|
||
|
||
hit = lldp.NeighborHit(
|
||
remote_name="R2", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
|
||
)
|
||
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||
self.assertEqual(peer.id, inv.id)
|
||
self.assertNotEqual(peer.id, ghost.id)
|
||
|
||
merged = svc.merge_duplicate_fabric_nodes(self.db)
|
||
self.assertGreaterEqual(merged["merged"], 1)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, ghost.id))
|
||
self.db.delete(real)
|
||
self.db.delete(webcrt)
|
||
self.db.commit()
|
||
|
||
def test_match_ignores_lldp_mgmt_ip_prefers_hostname(self) -> None:
|
||
"""Two inventory NEs both named r1 with different IPs — match by name, not LLDP IP."""
|
||
suffix = uuid4().hex[:8]
|
||
ne_real = ManagedNE(
|
||
id=f"real-{suffix}",
|
||
name="r1",
|
||
vendor="Huawei",
|
||
device_type="huawei",
|
||
ip_address="192.168.0.127",
|
||
source="manual",
|
||
)
|
||
ne_wrong = ManagedNE(
|
||
id=f"wrong-{suffix}",
|
||
name="r1-lab", # different hostname key
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address="203.0.113.184",
|
||
source="manual",
|
||
)
|
||
self.db.add(ne_real)
|
||
self.db.add(ne_wrong)
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_real)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_wrong)
|
||
self.db.commit()
|
||
hit = lldp.NeighborHit(
|
||
remote_name="r1",
|
||
remote_ip="203.0.113.184", # misleading interface/mgmt IP
|
||
local_port="Gi0/1",
|
||
remote_port="Ethernet1/0/1",
|
||
)
|
||
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||
self.assertEqual(peer.id, fa.id)
|
||
self.assertNotEqual(peer.id, fb.id)
|
||
self.db.delete(ne_real)
|
||
self.db.delete(ne_wrong)
|
||
self.db.commit()
|
||
|
||
def test_same_hostname_port_cutover_merges_not_missing(self) -> None:
|
||
"""Duplicate fabric rows for same hostname must not leave a red replaced edge."""
|
||
suffix = uuid4().hex[:8]
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"R2-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"192.168.0.{(int(suffix[:2], 16) % 80) + 10}",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name="r1",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address="192.168.0.127",
|
||
source="manual",
|
||
)
|
||
ne_c = ManagedNE(
|
||
id=f"nec-{suffix}",
|
||
name="r1",
|
||
vendor="Other",
|
||
device_type="generic",
|
||
ip_address="203.0.113.184",
|
||
source=LLDP_DISCOVERED_NE_SOURCE,
|
||
)
|
||
self.db.add_all([ne_a, ne_b, ne_c])
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
|
||
self.db.commit()
|
||
|
||
old, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/1",
|
||
b_port="Ethernet1/0/1",
|
||
source="lldp",
|
||
)
|
||
new, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fc.id,
|
||
a_port="Gi0/1",
|
||
b_port="Ethernet1/0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
handled = svc._mark_replaced_port_peers(
|
||
self.db,
|
||
self_id=fa.id,
|
||
local_port="Gi0/1",
|
||
peer_id=fc.id,
|
||
new_edge_id=new.id,
|
||
)
|
||
self.db.commit()
|
||
self.db.expire_all()
|
||
|
||
# Placeholder fabric absorbed into real inventory; no missing/replaced link.
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fc.id))
|
||
edges = (
|
||
self.db.query(TopoFabricEdge)
|
||
.filter(
|
||
or_(TopoFabricEdge.a_node_id == fa.id, TopoFabricEdge.b_node_id == fa.id)
|
||
)
|
||
.all()
|
||
)
|
||
active = [e for e in edges if e.status == "active"]
|
||
missing = [e for e in edges if e.status == "missing"]
|
||
self.assertEqual(len(active), 1)
|
||
self.assertEqual(len(missing), 0)
|
||
peer_id = active[0].b_node_id if active[0].a_node_id == fa.id else active[0].a_node_id
|
||
self.assertEqual(peer_id, fb.id)
|
||
self.assertTrue(handled)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.delete(ne_c)
|
||
self.db.commit()
|
||
|
||
def test_neighborhood(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"A-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
|
||
vendor="ZTE",
|
||
device_type="zte_zxros",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name=f"B-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
|
||
vendor="ZTE",
|
||
device_type="zte_zxros",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.add(ne_b)
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
self.db.commit()
|
||
svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="gei-0/1/0/1",
|
||
b_port="gei-0/1/0/2",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
nb = svc.get_fabric_neighborhood(self.db, fa.id, depth=1)
|
||
self.assertEqual(len(nb.nodes), 2)
|
||
self.assertEqual(len(nb.edges), 1)
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def _pair_nodes(self, suffix: str) -> tuple[TopoFabricNode, TopoFabricNode, ManagedNE, ManagedNE]:
|
||
ne_a = ManagedNE(
|
||
id=f"nea-{suffix}",
|
||
name=f"A-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"neb-{suffix}",
|
||
name=f"B-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.add(ne_b)
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
self.db.commit()
|
||
return fa, fb, ne_a, ne_b
|
||
|
||
def test_merge_lldp_placeholder_into_real_inventory(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
# Real inventory NE + fabric node.
|
||
real = ManagedNE(
|
||
id=f"real-{suffix}",
|
||
name=f"R1-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
)
|
||
self.db.add(real)
|
||
self.db.commit()
|
||
fr = svc.ensure_fabric_node_for_managed(self.db, real)
|
||
# LLDP placeholder with same hostname key + seen mgmt IP.
|
||
ph = svc.ensure_lldp_discovered_managed_ne(
|
||
self.db,
|
||
remote_name=f"R1-{suffix}",
|
||
remote_ip=real.ip_address,
|
||
)
|
||
fp = svc.ensure_fabric_node_for_managed(self.db, ph)
|
||
self.db.commit()
|
||
# Edge hanging off placeholder should retarget to real.
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fr.id,
|
||
b_node_id=fp.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
# Need a third node so edge isn't self-loop after merge… actually A=real B=placeholder
|
||
# after absorb B→A becomes self-loop and edge is deleted. Use external peer.
|
||
peer_ne = ManagedNE(
|
||
id=f"peer-{suffix}",
|
||
name=f"P-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
)
|
||
self.db.add(peer_ne)
|
||
self.db.commit()
|
||
fpeer = svc.ensure_fabric_node_for_managed(self.db, peer_ne)
|
||
edge2, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fp.id,
|
||
b_node_id=fpeer.id,
|
||
a_port="Gi1/0",
|
||
b_port="Gi1/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
edge2_id = edge2.id
|
||
|
||
out = svc.merge_duplicate_fabric_nodes(self.db)
|
||
self.assertGreaterEqual(out["merged"], 1)
|
||
self.assertGreaterEqual(out.get("placeholders_removed", 0), 1)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fp.id))
|
||
self.assertIsNone(self.db.get(ManagedNE, ph.id))
|
||
# Edge from placeholder→peer should now be real→peer.
|
||
moved = self.db.get(TopoFabricEdge, edge2_id)
|
||
self.assertIsNotNone(moved)
|
||
assert moved is not None
|
||
ends = {moved.a_node_id, moved.b_node_id}
|
||
self.assertEqual(ends, {fr.id, fpeer.id})
|
||
|
||
self.db.delete(real)
|
||
self.db.delete(peer_ne)
|
||
self.db.commit()
|
||
|
||
def test_list_fabric_edges_missing_filter_and_names(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
svc._apply_missing_and_purge(
|
||
self.db, scanned_ok={fa.id}, touched_edge_ids=set()
|
||
)
|
||
self.db.commit()
|
||
|
||
missing = svc.list_fabric_edges(self.db, status="missing", page=1, page_size=50)
|
||
self.assertGreaterEqual(missing["total"], 1)
|
||
hit = next(i for i in missing["items"] if i["id"] == edge.id)
|
||
self.assertEqual(hit["status"], "missing")
|
||
self.assertTrue(hit["a_name"] or hit["b_name"])
|
||
self.assertGreaterEqual(int((hit.get("attrs") or {}).get("miss_count") or 0), 1)
|
||
|
||
by_kw = svc.list_fabric_edges(
|
||
self.db, keyword=ne_a.name[:6], status="missing", page=1, page_size=50
|
||
)
|
||
self.assertTrue(any(i["id"] == edge.id for i in by_kw["items"]))
|
||
|
||
active = svc.list_fabric_edges(self.db, status="active", page=1, page_size=50)
|
||
self.assertFalse(any(i["id"] == edge.id for i in active["items"]))
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_edge_missing_after_one_absent_cycle(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 1)
|
||
self.assertEqual(purged, 0)
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "missing")
|
||
self.assertEqual(int((edge.attrs or {}).get("miss_count") or 0), 1)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_edge_purge_after_four_missing_cycles(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
edge_id = edge.id
|
||
self.db.commit()
|
||
|
||
for cycle in range(1, 5):
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
if cycle < 4:
|
||
self.assertEqual(purged, 0)
|
||
row = self.db.get(TopoFabricEdge, edge_id)
|
||
self.assertIsNotNone(row)
|
||
self.assertEqual(int((row.attrs or {}).get("miss_count") or 0), cycle)
|
||
else:
|
||
self.assertEqual(purged, 1)
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_peer_replace_marks_old_edge_missing(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
ne_c = ManagedNE(
|
||
id=f"nec-{suffix}",
|
||
name=f"C-{suffix}",
|
||
ip_address=f"198.51.100.{(int(suffix[4:6], 16) % 80) + 50}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
)
|
||
self.db.add(ne_c)
|
||
self.db.commit()
|
||
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
|
||
self.db.commit()
|
||
|
||
old, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
new, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fc.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/2",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
handled = svc._mark_replaced_port_peers(
|
||
self.db,
|
||
self_id=fa.id,
|
||
local_port="Gi0/0",
|
||
peer_id=fc.id,
|
||
new_edge_id=new.id,
|
||
)
|
||
self.db.commit()
|
||
self.assertIn(old.id, handled)
|
||
self.db.refresh(old)
|
||
self.assertEqual(old.status, "missing")
|
||
self.assertEqual((old.attrs or {}).get("replaced_by_edge_id"), new.id)
|
||
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
|
||
|
||
# Same-job: replaced id in touched → no second miss bump.
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids={new.id, *handled},
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 0)
|
||
self.assertEqual(purged, 0)
|
||
self.db.refresh(old)
|
||
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.delete(ne_c)
|
||
self.db.commit()
|
||
|
||
def test_edge_reactivate_clears_miss_attrs(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
svc._apply_missing_and_purge(
|
||
self.db, scanned_ok={fa.id}, touched_edge_ids=set()
|
||
)
|
||
self.db.commit()
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "missing")
|
||
|
||
edge2, action = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(action, "updated")
|
||
self.assertEqual(edge2.id, edge.id)
|
||
self.assertEqual(edge2.status, "active")
|
||
attrs = edge2.attrs or {}
|
||
self.assertNotIn("miss_count", attrs)
|
||
self.assertNotIn("first_missing_at", attrs)
|
||
self.assertNotIn("replaced_by_edge_id", attrs)
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_scan_fail_does_not_mark_missing(self) -> None:
|
||
"""Endpoint not in scanned_ok (SSH/parse fail) → leave edge active."""
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok=set(), # scan failed — nothing judged
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 0)
|
||
self.assertEqual(purged, 0)
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "active")
|
||
|
||
# Manual edges never auto-missing.
|
||
manual, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi1/0",
|
||
b_port="Gi1/1",
|
||
source="manual",
|
||
)
|
||
self.db.commit()
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.db.refresh(manual)
|
||
self.assertEqual(manual.status, "active")
|
||
self.assertEqual(manual.source, "manual")
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_ume_edge_not_marked_missing_by_lldp_miss(self) -> None:
|
||
"""Valid LLDP scan on endpoint must not red-line UME-authority edges."""
|
||
suffix = uuid4().hex[:8]
|
||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="xxvgei-1/1/0/1",
|
||
b_port="xxvgei-1/1/0/2",
|
||
source="ume",
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(edge.source, "ume")
|
||
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 0)
|
||
self.assertEqual(purged, 0)
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "active")
|
||
self.assertEqual(edge.source, "ume")
|
||
|
||
# Dual provenance still UME-protected; LLDP mark is cleared.
|
||
edge.attrs = {"sources": ["lldp", "ume"]}
|
||
edge.source = "ume"
|
||
self.db.commit()
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 0)
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "active")
|
||
self.assertEqual(edge.source, "ume")
|
||
self.assertNotIn("lldp", (edge.attrs or {}).get("sources", []))
|
||
self.assertIn("ume", (edge.attrs or {}).get("sources", []))
|
||
|
||
# Pure LLDP on same node still miss-eligible.
|
||
lldp_edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
newly, purged = svc._apply_missing_and_purge(
|
||
self.db,
|
||
scanned_ok={fa.id},
|
||
touched_edge_ids=set(),
|
||
)
|
||
self.db.commit()
|
||
self.assertEqual(newly, 1)
|
||
self.db.refresh(lldp_edge)
|
||
self.assertEqual(lldp_edge.status, "missing")
|
||
self.db.refresh(edge)
|
||
self.assertEqual(edge.status, "active")
|
||
|
||
self.db.delete(ne_a)
|
||
self.db.delete(ne_b)
|
||
self.db.commit()
|
||
|
||
def test_delete_managed_purges_orphan_fabric_and_edges(self) -> None:
|
||
"""Managed-only delete → detach then purge fabric node + incident edges."""
|
||
from netx_api import ne_service
|
||
from netx_api.topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
||
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"detach-{suffix}",
|
||
name=f"DET-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.commit()
|
||
region_id = self._region(f"Detach-{suffix}")
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"DV-{suffix}", folder_id=region_id),
|
||
)
|
||
graph = svc.add_nodes_to_view(
|
||
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
||
)
|
||
fid = graph.nodes[0].fabric_node_id
|
||
peer = TopoFabricNode(
|
||
id=f"peer-{suffix}",
|
||
name=f"PEER-{suffix}",
|
||
ip=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.2",
|
||
ume_ne_id=f"ume-peer-{suffix}",
|
||
)
|
||
self.db.add(peer)
|
||
self.db.commit()
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fid,
|
||
b_node_id=peer.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="lldp",
|
||
)
|
||
self.db.commit()
|
||
edge_id = str(edge.id)
|
||
|
||
ne_service.delete_managed_ne(self.db, ne.id)
|
||
self.db.expire_all()
|
||
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fid))
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
self.assertEqual(
|
||
self.db.query(TopoViewNode)
|
||
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid)
|
||
.count(),
|
||
0,
|
||
)
|
||
# Peer still UME-linked — not purged.
|
||
self.assertIsNotNone(self.db.get(TopoFabricNode, peer.id))
|
||
|
||
# Historical dangling ref → detach then purge (fully orphaned).
|
||
ghost_id = f"ghost-{suffix}"
|
||
dangling = TopoFabricNode(
|
||
id=f"dang-{suffix}",
|
||
managed_ne_id=ghost_id,
|
||
name=f"DANG-{suffix}",
|
||
ip="10.66.9.9",
|
||
)
|
||
self.db.add(dangling)
|
||
self.db.commit()
|
||
stats = reconcile_dangling_fabric_links(self.db)
|
||
self.db.commit()
|
||
self.db.expire_all()
|
||
self.assertGreaterEqual(int(stats["detached_managed_nodes"]), 1)
|
||
self.assertGreaterEqual(int(stats["purged_orphans"]), 1)
|
||
self.assertIsNone(self.db.get(TopoFabricNode, dangling.id))
|
||
|
||
def test_both_bound_keeps_fabric_until_fully_orphaned(self) -> None:
|
||
from netx_api import ne_service
|
||
from netx_api.topology_inventory_lifecycle import detach_fabric_from_ume
|
||
|
||
suffix = uuid4().hex[:8]
|
||
ne = ManagedNE(
|
||
id=f"both-m-{suffix}",
|
||
name=f"BOTH-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.67.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||
)
|
||
self.db.add(ne)
|
||
self.db.commit()
|
||
fab = svc.ensure_fabric_node_for_managed(self.db, ne)
|
||
fab.ume_ne_id = f"ume-both-{suffix}"
|
||
self.db.commit()
|
||
peer = TopoFabricNode(
|
||
id=f"both-peer-{suffix}",
|
||
name=f"BP-{suffix}",
|
||
ip=f"10.67.{(int(suffix[:2], 16) % 200) + 1}.2",
|
||
ume_ne_id=f"ume-bp-{suffix}",
|
||
)
|
||
self.db.add(peer)
|
||
self.db.commit()
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fab.id,
|
||
b_node_id=peer.id,
|
||
a_port="Gi1/0",
|
||
b_port="Gi1/1",
|
||
source="ume",
|
||
)
|
||
self.db.commit()
|
||
edge_id = str(edge.id)
|
||
fab_id = str(fab.id)
|
||
ume_id = str(fab.ume_ne_id)
|
||
|
||
ne_service.delete_managed_ne(self.db, ne.id)
|
||
self.db.expire_all()
|
||
kept = self.db.get(TopoFabricNode, fab_id)
|
||
self.assertIsNotNone(kept)
|
||
assert kept is not None
|
||
self.assertFalse(str(kept.managed_ne_id or "").strip())
|
||
self.assertEqual(str(kept.ume_ne_id or "").strip(), ume_id)
|
||
self.assertIsNotNone(self.db.get(TopoFabricEdge, edge_id))
|
||
|
||
detach_fabric_from_ume(self.db, [ume_id])
|
||
self.db.commit()
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fab_id))
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
|
||
def test_ume_only_detach_purges_orphan(self) -> None:
|
||
from netx_api.topology_inventory_lifecycle import detach_fabric_from_ume
|
||
|
||
suffix = uuid4().hex[:8]
|
||
ume_id = f"ume-only-{suffix}"
|
||
fab = TopoFabricNode(
|
||
id=f"uo-{suffix}",
|
||
name=f"UO-{suffix}",
|
||
ip="10.68.0.1",
|
||
ume_ne_id=ume_id,
|
||
)
|
||
peer = TopoFabricNode(
|
||
id=f"uo-peer-{suffix}",
|
||
name=f"UOP-{suffix}",
|
||
ip="10.68.0.2",
|
||
ume_ne_id=f"ume-uop-{suffix}",
|
||
)
|
||
self.db.add(fab)
|
||
self.db.add(peer)
|
||
self.db.commit()
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fab.id,
|
||
b_node_id=peer.id,
|
||
a_port="Eth1",
|
||
b_port="Eth2",
|
||
source="ume",
|
||
)
|
||
self.db.commit()
|
||
edge_id = str(edge.id)
|
||
fab_id = str(fab.id)
|
||
|
||
out = detach_fabric_from_ume(self.db, [ume_id])
|
||
self.db.commit()
|
||
self.db.expire_all()
|
||
self.assertEqual(out["purged_orphans"], 1)
|
||
self.assertGreaterEqual(out["edges_deleted"], 1)
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fab_id))
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
self.assertIsNotNone(self.db.get(TopoFabricNode, peer.id))
|
||
|
||
def test_fabric_reconcile_scheduler_once_sweeps_orphans(self) -> None:
|
||
from netx_api.fabric_reconcile_scheduler import run_fabric_reconcile_once
|
||
|
||
suffix = uuid4().hex[:8]
|
||
orphan_id = f"gc-{suffix}"
|
||
orphan = TopoFabricNode(
|
||
id=orphan_id,
|
||
name=f"GC-{suffix}",
|
||
ip="10.69.0.1",
|
||
)
|
||
self.db.add(orphan)
|
||
self.db.commit()
|
||
|
||
stats = run_fabric_reconcile_once()
|
||
self.assertGreaterEqual(int(stats.get("purged_orphans") or 0), 1)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, orphan_id))
|
||
|
||
def test_delete_fabric_node_only_orphans_and_placeholders(self) -> None:
|
||
from fastapi import HTTPException
|
||
|
||
from netx_api.topology_inventory_lifecycle import delete_fabric_nodes
|
||
|
||
suffix = uuid4().hex[:8]
|
||
real = ManagedNE(
|
||
id=f"real-{suffix}",
|
||
name=f"REAL-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||
source="",
|
||
)
|
||
ph = ManagedNE(
|
||
id=f"ph-{suffix}",
|
||
name=f"PH-{suffix}",
|
||
vendor="Other",
|
||
device_type="generic",
|
||
ip_address="",
|
||
source=LLDP_DISCOVERED_NE_SOURCE,
|
||
)
|
||
self.db.add(real)
|
||
self.db.add(ph)
|
||
self.db.commit()
|
||
fab_real = svc.ensure_fabric_node_for_managed(self.db, real)
|
||
fab_ph = svc.ensure_fabric_node_for_managed(self.db, ph)
|
||
orphan = TopoFabricNode(
|
||
id=f"orp-{suffix}",
|
||
name=f"ORP-{suffix}",
|
||
ip="10.77.0.9",
|
||
)
|
||
ume_only = TopoFabricNode(
|
||
id=f"ume-{suffix}",
|
||
name=f"UME-{suffix}",
|
||
ip="10.77.0.8",
|
||
ume_ne_id=f"ume-ne-{suffix}",
|
||
)
|
||
self.db.add(orphan)
|
||
self.db.add(ume_only)
|
||
self.db.commit()
|
||
|
||
with self.assertRaises(HTTPException) as ctx:
|
||
delete_fabric_nodes(self.db, [fab_real.id])
|
||
self.assertEqual(ctx.exception.status_code, 400)
|
||
|
||
with self.assertRaises(HTTPException) as ctx_ume:
|
||
delete_fabric_nodes(self.db, [ume_only.id])
|
||
self.assertEqual(ctx_ume.exception.status_code, 400)
|
||
|
||
out = delete_fabric_nodes(self.db, [fab_ph.id, orphan.id])
|
||
self.assertEqual(out["deleted"], 2)
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fab_ph.id))
|
||
self.assertIsNone(self.db.get(TopoFabricNode, orphan.id))
|
||
self.assertIsNotNone(self.db.get(TopoFabricNode, fab_real.id))
|
||
self.assertIsNotNone(self.db.get(TopoFabricNode, ume_only.id))
|
||
self.assertIsNotNone(self.db.get(ManagedNE, real.id))
|
||
self.assertIsNotNone(self.db.get(ManagedNE, ph.id))
|
||
|
||
def test_purge_placeholder_deletes_managed_and_edges(self) -> None:
|
||
from fastapi import HTTPException
|
||
|
||
from netx_api.topology_inventory_lifecycle import purge_placeholder_fabric_nodes
|
||
|
||
suffix = uuid4().hex[:8]
|
||
topo_ph = ManagedNE(
|
||
id=f"topo-{suffix}",
|
||
name=f"TOPO-{suffix}",
|
||
vendor="Other",
|
||
device_type="generic",
|
||
ip_address="",
|
||
source=TOPOLOGY_NE_SOURCE,
|
||
)
|
||
real = ManagedNE(
|
||
id=f"real2-{suffix}",
|
||
name=f"REAL2-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.78.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||
source="",
|
||
)
|
||
self.db.add(topo_ph)
|
||
self.db.add(real)
|
||
self.db.commit()
|
||
fab_ph = svc.ensure_fabric_node_for_managed(self.db, topo_ph)
|
||
fab_real = svc.ensure_fabric_node_for_managed(self.db, real)
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fab_ph.id,
|
||
b_node_id=fab_real.id,
|
||
a_port="Gi0/1",
|
||
b_port="Gi0/2",
|
||
source="manual",
|
||
)
|
||
self.db.commit()
|
||
edge_id = str(edge.id)
|
||
|
||
with self.assertRaises(HTTPException) as ctx:
|
||
purge_placeholder_fabric_nodes(self.db, [fab_real.id])
|
||
self.assertEqual(ctx.exception.status_code, 400)
|
||
|
||
out = purge_placeholder_fabric_nodes(self.db, [fab_ph.id])
|
||
self.assertEqual(out["deleted"], 1)
|
||
self.assertEqual(out["managed_deleted"], 1)
|
||
self.assertGreaterEqual(out["edges_deleted"], 1)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricNode, fab_ph.id))
|
||
self.assertIsNone(self.db.get(ManagedNE, topo_ph.id))
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
self.assertIsNotNone(self.db.get(TopoFabricNode, fab_real.id))
|
||
self.assertIsNotNone(self.db.get(ManagedNE, real.id))
|
||
|
||
def test_filter_bulk_add_layout_and_remove(self) -> None:
|
||
from netx_api.topology_schemas import ViewMutationOut, ViewNodesRemove
|
||
|
||
suffix = uuid4().hex[:8]
|
||
region = self._region(f"Bulk-{suffix}")
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"BulkV-{suffix}", folder_id=region),
|
||
)
|
||
nodes = []
|
||
for i in range(5):
|
||
n = TopoFabricNode(
|
||
id=f"bf-{suffix}-{i}",
|
||
name=f"BJ-SW-{suffix}-{i}",
|
||
ip=f"10.88.{i}.1",
|
||
vendor="Cisco",
|
||
role="access",
|
||
)
|
||
self.db.add(n)
|
||
nodes.append(n)
|
||
other = TopoFabricNode(
|
||
id=f"bf-other-{suffix}",
|
||
name=f"SH-SW-{suffix}",
|
||
ip="10.89.0.1",
|
||
vendor="Huawei",
|
||
role="core",
|
||
)
|
||
self.db.add(other)
|
||
self.db.commit()
|
||
|
||
summary = svc.add_nodes_to_view(
|
||
self.db,
|
||
view.id,
|
||
ViewNodesAdd(keyword="BJ-SW-", limit=3, offset=0, return_graph=False),
|
||
)
|
||
self.assertIsInstance(summary, ViewMutationOut)
|
||
assert isinstance(summary, ViewMutationOut)
|
||
self.assertEqual(summary.added, 3)
|
||
self.assertEqual(summary.matched, 5)
|
||
self.assertEqual(summary.next_offset, 3)
|
||
self.assertTrue(summary.truncated)
|
||
|
||
more = svc.add_nodes_to_view(
|
||
self.db,
|
||
view.id,
|
||
ViewNodesAdd(keyword="BJ-SW-", limit=10, offset=3, return_graph=False),
|
||
)
|
||
assert isinstance(more, ViewMutationOut)
|
||
self.assertEqual(more.added, 2)
|
||
self.assertIsNone(more.next_offset)
|
||
|
||
laid = svc.patch_view_positions(
|
||
self.db,
|
||
view.id,
|
||
ViewPositionsPatch(layout="grid", keyword="BJ-SW-", origin_x=10, origin_y=20, return_graph=False),
|
||
)
|
||
assert isinstance(laid, ViewMutationOut)
|
||
self.assertEqual(laid.updated, 5)
|
||
|
||
removed = svc.remove_view_nodes(
|
||
self.db,
|
||
view.id,
|
||
body=ViewNodesRemove(keyword=f"BJ-SW-{suffix}-1", return_graph=False),
|
||
)
|
||
assert isinstance(removed, ViewMutationOut)
|
||
self.assertGreaterEqual(removed.removed, 1)
|
||
self.assertLess(removed.view_node_count, 5)
|
||
|
||
def test_delete_fabric_edge_removes_from_view_graph(self) -> None:
|
||
suffix = uuid4().hex[:8]
|
||
ne_a = ManagedNE(
|
||
id=f"dea-{suffix}",
|
||
name=f"DEA-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.1",
|
||
source="manual",
|
||
)
|
||
ne_b = ManagedNE(
|
||
id=f"deb-{suffix}",
|
||
name=f"DEB-{suffix}",
|
||
vendor="Cisco",
|
||
device_type="cisco_ios",
|
||
ip_address=f"10.77.{(int(suffix[:2], 16) % 200) + 1}.2",
|
||
source="manual",
|
||
)
|
||
self.db.add(ne_a)
|
||
self.db.add(ne_b)
|
||
self.db.commit()
|
||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||
self.db.commit()
|
||
edge, _ = svc.upsert_fabric_edge(
|
||
self.db,
|
||
a_node_id=fa.id,
|
||
b_node_id=fb.id,
|
||
a_port="Gi0/0",
|
||
b_port="Gi0/1",
|
||
source="manual",
|
||
)
|
||
self.db.commit()
|
||
region_id = self._region(f"DelEdge-{suffix}")
|
||
view = svc.create_view(
|
||
self.db,
|
||
TopologyViewCreate(name=f"DE-{suffix}", folder_id=region_id),
|
||
)
|
||
svc.add_nodes_to_view(
|
||
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id])
|
||
)
|
||
graph = svc.get_view_graph(self.db, view.id)
|
||
self.assertTrue(any(e.id == edge.id for e in graph.edges))
|
||
self.assertTrue(any(n.managed_source == "manual" for n in graph.nodes))
|
||
|
||
edge_id = edge.id
|
||
out = svc.delete_fabric_edges(self.db, [edge_id])
|
||
self.assertEqual(out["deleted"], 1)
|
||
self.db.expire_all()
|
||
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||
graph2 = svc.get_view_graph(self.db, view.id)
|
||
self.assertFalse(any(e.id == edge_id for e in graph2.edges))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|