From 43067c39c8232d1b729fec084bc28e2647d65b48 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 10:28:43 -0500 Subject: [PATCH] Add initial cable path tests for profiles --- netbox/dcim/cable_profiles.py | 2 +- netbox/dcim/tests/test_cablepaths.py | 87 +----- netbox/dcim/tests/test_cablepaths2.py | 371 ++++++++++++++++++++++++++ netbox/dcim/tests/utils.py | 88 ++++++ 4 files changed, 464 insertions(+), 84 deletions(-) create mode 100644 netbox/dcim/tests/test_cablepaths2.py create mode 100644 netbox/dcim/tests/utils.py diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 353b64626..8433d29b9 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -32,7 +32,7 @@ class BaseCableProfile: 'Maximum B side connections for profile {profile}: {max}' ).format( profile=cable.get_profile_display(), - max=self.a_max_connections, + max=self.b_max_connections, ) }) if self.symmetrical and len(cable.a_terminations) != len(cable.b_terminations): diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 399478e70..874b68340 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1,100 +1,21 @@ -from django.test import TestCase - from circuits.models import * from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.svg import CableTraceSVG -from dcim.utils import object_to_path_node +from dcim.tests.utils import CablePathTestCase from utilities.exceptions import AbortRequest -class CablePathTestCase(TestCase): +class LegacyCablePathTests(CablePathTestCase): """ - Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered - as follows: + Test NetBox's ability to trace and retrace CablePaths in response to data model changes, without cable profiles. + Tests are numbered as follows: 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects 4XX: Test to exclude specific cable topologies """ - @classmethod - def setUpTestData(cls): - - # Create a single device that will hold all components - cls.site = Site.objects.create(name='Site', slug='site') - - manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') - role = DeviceRole.objects.create(name='Device Role', slug='device-role') - cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device') - - cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') - - provider = Provider.objects.create(name='Provider', slug='provider') - circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') - cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - - def _get_cablepath(self, nodes, **kwargs): - """ - Return a given cable path - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - - :return: The matching CablePath (if any) - """ - path = [] - for step in nodes: - if type(step) in (list, tuple): - path.append([object_to_path_node(node) for node in step]) - else: - path.append([object_to_path_node(step)]) - return CablePath.objects.filter(path=path, **kwargs).first() - - def assertPathExists(self, nodes, **kwargs): - """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the - first matching CablePath, if found. - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - """ - cablepath = self._get_cablepath(nodes, **kwargs) - self.assertIsNotNone(cablepath, msg='CablePath not found') - - return cablepath - - def assertPathDoesNotExist(self, nodes, **kwargs): - """ - Assert that a specific CablePath does *not* exist. - - :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - """ - cablepath = self._get_cablepath(nodes, **kwargs) - self.assertIsNone(cablepath, msg='Unexpected CablePath found') - - def assertPathIsSet(self, origin, cablepath, msg=None): - """ - Assert that a specific CablePath instance is set as the path on the origin. - - :param origin: The originating path endpoint - :param cablepath: The CablePath instance originating from this endpoint - :param msg: Custom failure message (optional) - """ - if msg is None: - msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" - self.assertEqual(origin._path_id, cablepath.pk, msg=msg) - - def assertPathIsNotSet(self, origin, msg=None): - """ - Assert that a specific CablePath instance is set as the path on the origin. - - :param origin: The originating path endpoint - :param msg: Custom failure message (optional) - """ - if msg is None: - msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" - self.assertIsNone(origin._path_id, msg=msg) - def test_101_interface_to_interface(self): """ [IF1] --C1-- [IF2] diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py new file mode 100644 index 000000000..eb8994349 --- /dev/null +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -0,0 +1,371 @@ +from dcim.choices import CableProfileChoices +from dcim.models import * +from dcim.svg import CableTraceSVG +from dcim.tests.utils import CablePathTestCase + + +class CablePathTests(CablePathTestCase): + """ + Test the creation of CablePaths for Cables with different profiles applied. + + Tests are numbered as follows: + 1XX: Test direct connections using each profile + """ + + def test_101_cable_profile_straight_single(self): + """ + [IF1] --C1-- [IF2] + + Cable profile: Straight single + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_SINGLE, + a_terminations=[interfaces[0]], + b_terminations=[interfaces[1]], + ) + cable1.clean() + cable1.save() + + path1 = self.assertPathExists( + (interfaces[0], cable1, interfaces[1]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interfaces[0].refresh_from_db() + interfaces[1].refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertEqual(interfaces[0].cable_position, 1) + self.assertEqual(interfaces[1].cable_position, 1) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_102_cable_profile_straight_multi(self): + """ + [IF1] --C1-- [IF3] + [IF2] [IF4] + + Cable profile: Straight multi + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[interfaces[2], interfaces[3]], + ) + cable1.clean() + cable1.save() + + path1 = self.assertPathExists( + (interfaces[0], cable1, interfaces[2]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, interfaces[3]), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + (interfaces[3], cable1, interfaces[1]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertEqual(interfaces[0].cable_position, 1) + self.assertEqual(interfaces[1].cable_position, 2) + self.assertEqual(interfaces[2].cable_position, 1) + self.assertEqual(interfaces[3].cable_position, 2) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_103_cable_profile_a_to_many(self): + """ + [IF1] --C1-- [IF2] + [IF3] + [IF4] + + Cable profile: A to many + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.A_TO_MANY, + a_terminations=[interfaces[0]], + b_terminations=[interfaces[1], interfaces[2], interfaces[3]], + ) + cable1.clean() + cable1.save() + + # A-to-B path leads to all interfaces + path1 = self.assertPathExists( + (interfaces[0], cable1, [interfaces[1], interfaces[2], interfaces[3]]), + is_complete=True, + is_active=True + ) + # B-to-A paths are incomplete because A side has null position + path2 = self.assertPathExists( + (interfaces[1], cable1, []), + is_complete=False, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, []), + is_complete=False, + is_active=True + ) + path4 = self.assertPathExists( + (interfaces[3], cable1, []), + is_complete=False, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertIsNone(interfaces[0].cable_position) + self.assertEqual(interfaces[1].cable_position, 1) + self.assertEqual(interfaces[2].cable_position, 2) + self.assertEqual(interfaces[3].cable_position, 3) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_104_cable_profile_b_to_many(self): + """ + [IF1] --C1-- [IF4] + [IF2] + [IF3] + + Cable profile: B to many + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.B_TO_MANY, + a_terminations=[interfaces[0], interfaces[1], interfaces[2]], + b_terminations=[interfaces[3]], + ) + cable1.clean() + cable1.save() + + # A-to-B paths are incomplete because A side has null position + path1 = self.assertPathExists( + (interfaces[0], cable1, []), + is_complete=False, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, []), + is_complete=False, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, []), + is_complete=False, + is_active=True + ) + # B-to-A path leads to all interfaces + path4 = self.assertPathExists( + (interfaces[3], cable1, [interfaces[0], interfaces[1], interfaces[2]]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertEqual(interfaces[0].cable_position, 1) + self.assertEqual(interfaces[1].cable_position, 2) + self.assertEqual(interfaces[2].cable_position, 3) + self.assertIsNone(interfaces[3].cable_position) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + + def test_105_cable_profile_2x2_mpo(self): + """ + [IF1:1] --C1-- [IF3:1] + [IF1:2] [IF3:2] + [IF1:3] [IF3:3] + [IF1:4] [IF3:4] + [IF2:1] [IF4:1] + [IF2:2] [IF4:2] + [IF2:3] [IF4:3] + [IF2:4] [IF4:4] + + Cable profile: Shuffle (2x2 MPO) + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1:1'), + Interface.objects.create(device=self.device, name='Interface 1:2'), + Interface.objects.create(device=self.device, name='Interface 1:3'), + Interface.objects.create(device=self.device, name='Interface 1:4'), + Interface.objects.create(device=self.device, name='Interface 2:1'), + Interface.objects.create(device=self.device, name='Interface 2:2'), + Interface.objects.create(device=self.device, name='Interface 2:3'), + Interface.objects.create(device=self.device, name='Interface 2:4'), + Interface.objects.create(device=self.device, name='Interface 3:1'), + Interface.objects.create(device=self.device, name='Interface 3:2'), + Interface.objects.create(device=self.device, name='Interface 3:3'), + Interface.objects.create(device=self.device, name='Interface 3:4'), + Interface.objects.create(device=self.device, name='Interface 4:1'), + Interface.objects.create(device=self.device, name='Interface 4:2'), + Interface.objects.create(device=self.device, name='Interface 4:3'), + Interface.objects.create(device=self.device, name='Interface 4:4'), + ] + + # Create cable 1 + cable1 = Cable( + profile=CableProfileChoices.SHUFFLE_2X2_MPO, + a_terminations=interfaces[0:8], + b_terminations=interfaces[8:16], + ) + cable1.clean() + cable1.save() + + paths = [ + # A-to-B paths + self.assertPathExists( + (interfaces[0], cable1, interfaces[8]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[1], cable1, interfaces[9]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[2], cable1, interfaces[12]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[3], cable1, interfaces[13]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[4], cable1, interfaces[10]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[5], cable1, interfaces[11]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[6], cable1, interfaces[14]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[7], cable1, interfaces[15]), is_complete=True, is_active=True + ), + # B-to-A paths + self.assertPathExists( + (interfaces[8], cable1, interfaces[0]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[9], cable1, interfaces[1]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[10], cable1, interfaces[4]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[11], cable1, interfaces[5]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[12], cable1, interfaces[2]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[13], cable1, interfaces[3]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[14], cable1, interfaces[6]), is_complete=True, is_active=True + ), + self.assertPathExists( + (interfaces[15], cable1, interfaces[7]), is_complete=True, is_active=True + ), + ] + self.assertEqual(CablePath.objects.count(), len(paths)) + + for i, (interface, path) in enumerate(zip(interfaces, paths)): + interface.refresh_from_db() + self.assertPathIsSet(interface, path) + self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') + self.assertEqual(interface.cable_position, (i % 8) + 1) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) diff --git a/netbox/dcim/tests/utils.py b/netbox/dcim/tests/utils.py new file mode 100644 index 000000000..575034201 --- /dev/null +++ b/netbox/dcim/tests/utils.py @@ -0,0 +1,88 @@ +from django.test import TestCase + +from circuits.models import * +from dcim.models import * +from dcim.utils import object_to_path_node + +__all__ = ( + 'CablePathTestCase', +) + + +class CablePathTestCase(TestCase): + """ + Base class for test cases for cable paths. + """ + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + provider = Provider.objects.create(name='Provider', slug='provider') + circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') + + # Create reusable test objects + cls.site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device') + cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel') + cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') + + def _get_cablepath(self, nodes, **kwargs): + """ + Return a given cable path + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + + :return: The matching CablePath (if any) + """ + path = [] + for step in nodes: + if type(step) in (list, tuple): + path.append([object_to_path_node(node) for node in step]) + else: + path.append([object_to_path_node(step)]) + return CablePath.objects.filter(path=path, **kwargs).first() + + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNotNone(cablepath, msg='CablePath not found') + + return cablepath + + def assertPathDoesNotExist(self, nodes, **kwargs): + """ + Assert that a specific CablePath does *not* exist. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNone(cablepath, msg='Unexpected CablePath found') + + def assertPathIsSet(self, origin, cablepath, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param cablepath: The CablePath instance originating from this endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}" + self.assertEqual(origin._path_id, cablepath.pk, msg=msg) + + def assertPathIsNotSet(self, origin, msg=None): + """ + Assert that a specific CablePath instance is set as the path on the origin. + + :param origin: The originating path endpoint + :param msg: Custom failure message (optional) + """ + if msg is None: + msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!" + self.assertIsNone(origin._path_id, msg=msg)