""" Tests for interface VLAN sync functionality (Phase 2). Tests cover: - VlanAssignmentMixin methods - Port VLAN enrichment - VLAN sync action """ from unittest.mock import MagicMock, patch # Import the autouse fixture from helpers pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] class TestVlanAssignmentMixin: """Tests for VlanAssignmentMixin methods.""" def test_get_vlan_groups_for_device_includes_site_scoped(self, mock_librenms_config): """Test that VLAN groups scoped to device's site are included.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() # Create mock device with site mock_device = MagicMock() mock_device.site = MagicMock() mock_device.site.pk = 1 mock_device.site.region = None mock_device.site.group = None mock_device.location = None mock_device.rack = None # Mock the VLAN group query mock_site_group = MagicMock() mock_site_group.name = "Site VLANs" mock_site_group.pk = 10 with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope: mock_get_scope.return_value = [mock_site_group] with patch("ipam.models.VLANGroup") as mock_vlan_group_class: mock_vlan_group_class.objects.filter.return_value = [] mixin.get_vlan_groups_for_device(mock_device) # Verify site scope was queried assert mock_get_scope.called def test_get_vlan_groups_for_device_includes_global(self, mock_librenms_config): """Test that global VLAN groups (no scope) are included.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() # Create mock device with no location context mock_device = MagicMock() mock_device.site = None mock_device.location = None mock_device.rack = None with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope: mock_get_scope.return_value = [] with patch("ipam.models.VLANGroup") as mock_vlan_group_class: mock_global_group = MagicMock() mock_global_group.name = "Global VLANs" mock_global_group.pk = 20 mock_vlan_group_class.objects.filter.return_value = [mock_global_group] mixin.get_vlan_groups_for_device(mock_device) # Verify global scope was queried mock_vlan_group_class.objects.filter.assert_called_with(scope_type__isnull=True) def test_select_most_specific_group_prefers_rack(self, mock_librenms_config): """Test that rack-scoped groups are preferred over site-scoped.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() # Create mock device with rack mock_device = MagicMock() mock_device.rack = MagicMock() mock_device.rack.pk = 1 mock_device.site = MagicMock() mock_device.site.pk = 2 mock_device.site.region = None mock_device.site.group = None mock_device.location = None # Create mock groups with different scopes mock_rack_group = MagicMock() mock_rack_group.scope_type = MagicMock() mock_rack_group.scope_type.pk = 100 # Rack content type mock_rack_group.scope_id = 1 mock_site_group = MagicMock() mock_site_group.scope_type = MagicMock() mock_site_group.scope_type.pk = 101 # Site content type mock_site_group.scope_id = 2 with patch("django.contrib.contenttypes.models.ContentType") as mock_ct: # Mock ContentType lookups mock_ct.objects.get_for_model.side_effect = lambda model: MagicMock(pk=100 if "Rack" in str(model) else 101) result = mixin._select_most_specific_group([mock_rack_group, mock_site_group], mock_device) # Rack-scoped should be preferred assert result == mock_rack_group def test_select_most_specific_group_returns_none_for_ambiguous(self, mock_librenms_config): """Test that None is returned when multiple groups have same priority.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() # Create mock device mock_device = MagicMock() mock_device.site = MagicMock() mock_device.site.pk = 1 mock_device.site.region = None mock_device.site.group = None mock_device.rack = None mock_device.location = None # Create two groups with same scope (both site-scoped to same site) mock_group1 = MagicMock() mock_group1.scope_type = MagicMock() mock_group1.scope_type.pk = 101 mock_group1.scope_id = 1 mock_group2 = MagicMock() mock_group2.scope_type = MagicMock() mock_group2.scope_type.pk = 101 mock_group2.scope_id = 1 with patch("django.contrib.contenttypes.models.ContentType") as mock_ct: mock_ct.objects.get_for_model.return_value = MagicMock(pk=101) result = mixin._select_most_specific_group([mock_group1, mock_group2], mock_device) # Ambiguous - should return None assert result is None def test_get_ancestors_returns_hierarchy(self, mock_librenms_config): """Test that _get_ancestors returns full parent chain.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() # Create mock location hierarchy mock_grandparent = MagicMock() mock_grandparent.parent = None mock_parent = MagicMock() mock_parent.parent = mock_grandparent mock_location = MagicMock() mock_location.parent = mock_parent ancestors = mixin._get_ancestors(mock_location) assert len(ancestors) == 3 assert ancestors[0] == mock_location assert ancestors[1] == mock_parent assert ancestors[2] == mock_grandparent def test_find_vlan_in_group_prefers_specified_group(self, mock_librenms_config): """Test that _find_vlan_in_group prefers the specified group.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_vlan_in_group = MagicMock() mock_vlan_global = MagicMock() lookup_maps = { "vid_group_to_vlan": { (100, 5): mock_vlan_in_group, (100, None): mock_vlan_global, }, "vid_to_vlans": { 100: [mock_vlan_in_group, mock_vlan_global], }, } result = mixin._find_vlan_in_group(100, 5, lookup_maps) assert result == mock_vlan_in_group def test_find_vlan_in_group_falls_back_to_global(self, mock_librenms_config): """Test that _find_vlan_in_group falls back to global VLAN.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_vlan_global = MagicMock() lookup_maps = { "vid_group_to_vlan": { (100, None): mock_vlan_global, }, "vid_to_vlans": { 100: [mock_vlan_global], }, } # Request group 5 which doesn't have VLAN 100 result = mixin._find_vlan_in_group(100, 5, lookup_maps) assert result == mock_vlan_global def test_find_vlan_in_group_returns_none_if_not_found(self, mock_librenms_config): """Test that _find_vlan_in_group returns None if VLAN not found.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() lookup_maps = { "vid_group_to_vlan": {}, "vid_to_vlans": {}, } result = mixin._find_vlan_in_group(999, None, lookup_maps) assert result is None class TestPortVlanEnrichment: """Tests for port VLAN data enrichment.""" pytest_plugins = ["tests.test_librenms_api_helpers"] @patch("requests.get") def test_parse_port_vlan_data_access_port(self, mock_get, mock_librenms_config): """Test parsing access port VLAN data.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = { "port_id": 1234, "ifName": "Gi1/0/1", "ifDescr": "GigabitEthernet1/0/1", "ifVlan": "100", "ifTrunk": None, } result = api.parse_port_vlan_data(port_data, "ifName") assert result["port_id"] == 1234 assert result["interface_name"] == "Gi1/0/1" assert result["mode"] == "access" assert result["untagged_vlan"] == 100 assert result["tagged_vlans"] == [] @patch("requests.get") def test_parse_port_vlan_data_trunk_port(self, mock_get, mock_librenms_config): """Test parsing trunk port VLAN data.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = { "port_id": 5678, "ifName": "Te1/1/1", "ifDescr": "TenGigabitEthernet1/1/1", "ifVlan": "90", "ifTrunk": "dot1Q", "vlans": [ {"vlan": 90, "untagged": 1, "state": "unknown"}, {"vlan": 50, "untagged": 0, "state": "forwarding"}, {"vlan": 60, "untagged": 0, "state": "forwarding"}, ], } result = api.parse_port_vlan_data(port_data, "ifName") assert result["port_id"] == 5678 assert result["interface_name"] == "Te1/1/1" assert result["mode"] == "tagged" assert result["untagged_vlan"] == 90 assert sorted(result["tagged_vlans"]) == [50, 60] @patch("requests.get") def test_parse_port_vlan_data_uses_interface_name_field(self, mock_get, mock_librenms_config): """Test that parse_port_vlan_data respects interface_name_field parameter.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = { "port_id": 1234, "ifName": "Gi1/0/1", "ifDescr": "GigabitEthernet1/0/1", "ifVlan": "100", "ifTrunk": None, } result = api.parse_port_vlan_data(port_data, "ifDescr") assert result["interface_name"] == "GigabitEthernet1/0/1" class TestInterfaceVlanSync: """Tests for interface VLAN sync action.""" pytest_plugins = ["tests.test_librenms_api_helpers"] def test_update_interface_vlan_assignment_access_mode(self, mock_librenms_config): """Test that access mode is set correctly for untagged-only ports.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_interface = MagicMock() mock_interface.tagged_vlans = MagicMock() mock_vlan = MagicMock() mock_vlan.vid = 100 lookup_maps = { "vid_group_to_vlan": {(100, None): mock_vlan}, "vid_to_vlans": {100: [mock_vlan]}, } vlan_data = { "untagged_vlan": 100, "tagged_vlans": [], } mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) assert mock_interface.mode == "access" assert mock_interface.untagged_vlan == mock_vlan mock_interface.tagged_vlans.clear.assert_called_once() def test_update_interface_vlan_assignment_tagged_mode(self, mock_librenms_config): """Test that tagged mode is set for trunk ports.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_interface = MagicMock() mock_interface.tagged_vlans = MagicMock() mock_vlan_100 = MagicMock() mock_vlan_100.vid = 100 mock_vlan_200 = MagicMock() mock_vlan_200.vid = 200 mock_vlan_300 = MagicMock() mock_vlan_300.vid = 300 lookup_maps = { "vid_group_to_vlan": { (100, None): mock_vlan_100, (200, None): mock_vlan_200, (300, None): mock_vlan_300, }, "vid_to_vlans": { 100: [mock_vlan_100], 200: [mock_vlan_200], 300: [mock_vlan_300], }, } vlan_data = { "untagged_vlan": 100, "tagged_vlans": [200, 300], } mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) assert mock_interface.mode == "tagged" assert mock_interface.untagged_vlan == mock_vlan_100 mock_interface.tagged_vlans.set.assert_called_once_with([mock_vlan_200, mock_vlan_300]) def test_update_interface_vlan_assignment_missing_vlans(self, mock_librenms_config): """Test that missing VLANs are tracked in result.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_interface = MagicMock() mock_interface.tagged_vlans = MagicMock() # Empty lookup maps - no VLANs exist in NetBox lookup_maps = { "vid_group_to_vlan": {}, "vid_to_vlans": {}, } vlan_data = { "untagged_vlan": 100, "tagged_vlans": [200, 300], } result = mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) assert result["missing_vlans"] == [100, 200, 300] assert mock_interface.untagged_vlan is None mock_interface.tagged_vlans.set.assert_called_once_with([]) def test_update_interface_vlan_assignment_respects_group_selection(self, mock_librenms_config): """Test that VLAN group selection is respected.""" from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin mixin = VlanAssignmentMixin() mock_interface = MagicMock() mock_interface.tagged_vlans = MagicMock() mock_vlan_group1 = MagicMock() mock_vlan_group1.vid = 100 mock_vlan_global = MagicMock() mock_vlan_global.vid = 100 lookup_maps = { "vid_group_to_vlan": { (100, 5): mock_vlan_group1, (100, None): mock_vlan_global, }, "vid_to_vlans": { 100: [mock_vlan_group1, mock_vlan_global], }, } vlan_data = { "untagged_vlan": 100, "tagged_vlans": [], } # Request VLAN from group 5 mixin._update_interface_vlan_assignment(mock_interface, vlan_data, 5, lookup_maps) # Should use group-specific VLAN assert mock_interface.untagged_vlan == mock_vlan_group1 class TestInterfaceCssClassGroupMatching: """ Tests for group-aware VLAN CSS class functions in utils.py. Verifies that VLAN group mismatch (same VID but different group) produces orange (text-warning) instead of green (text-success). """ # -- get_untagged_vlan_css_class -- def test_untagged_vid_match_group_match_returns_green(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=True) == "text-success" def test_untagged_vid_match_group_mismatch_returns_orange(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=False) == "text-warning" def test_untagged_vid_differs_group_irrelevant(self, mock_librenms_config): """Different VIDs -> text-warning regardless of group_matches.""" from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 100, True, [], group_matches=True) == "text-warning" def test_untagged_not_in_netbox_ignores_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 60, False, [], group_matches=True) == "text-danger" def test_untagged_missing_vlan_ignores_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 60, True, [60], group_matches=True) == "text-danger" def test_untagged_no_netbox_vlan_returns_red(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, None, True, [], group_matches=True) == "text-danger" def test_untagged_default_group_matches_is_true(self, mock_librenms_config): """Without group_matches param, defaults to True (backward compat).""" from netbox_librenms_plugin.utils import get_untagged_vlan_css_class assert get_untagged_vlan_css_class(60, 60, True, []) == "text-success" # -- get_tagged_vlan_css_class -- def test_tagged_vid_present_group_match_returns_green(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=True) == "text-success" def test_tagged_vid_present_group_mismatch_returns_orange(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=False) == "text-warning" def test_tagged_vid_absent_group_irrelevant(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {100}, True, [], group_matches=True) == "text-danger" def test_tagged_not_in_netbox_ignores_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {60}, False, [], group_matches=True) == "text-danger" def test_tagged_missing_vlan_ignores_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {60}, True, [60], group_matches=True) == "text-danger" def test_tagged_default_group_matches_is_true(self, mock_librenms_config): """Without group_matches param, defaults to True (backward compat).""" from netbox_librenms_plugin.utils import get_tagged_vlan_css_class assert get_tagged_vlan_css_class(60, {60}, True, []) == "text-success" # -- check_vlan_group_matches -- def test_check_group_matches_untagged_same_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("U", 60, 5, 5, {}, 60, set()) is True def test_check_group_matches_untagged_different_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("U", 60, 10, 5, {}, 60, set()) is False def test_check_group_matches_untagged_vid_differs(self, mock_librenms_config): """When VIDs don't match, group comparison is irrelevant -> True.""" from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("U", 60, 10, 5, {}, 100, set()) is True def test_check_group_matches_tagged_same_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("T", 60, 5, None, {60: 5}, None, {60}) is True def test_check_group_matches_tagged_different_group(self, mock_librenms_config): from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("T", 60, 10, None, {60: 5}, None, {60}) is False def test_check_group_matches_tagged_vid_absent(self, mock_librenms_config): """When VID is not tagged in NetBox, group comparison irrelevant -> True.""" from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("T", 60, 10, None, {}, None, set()) is True def test_check_group_matches_global_to_global(self, mock_librenms_config): """Both NetBox VLAN and selected have no group (global) -> match.""" from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("U", 60, None, None, {}, 60, set()) is True def test_check_group_matches_global_vs_group(self, mock_librenms_config): """NetBox VLAN is global, selected is a specific group -> mismatch.""" from netbox_librenms_plugin.utils import check_vlan_group_matches assert check_vlan_group_matches("U", 60, 5, None, {}, 60, set()) is False