""" Tests for VLAN sync feature. Tests cover: - LibreNMS VLAN API methods - VLAN mode detection logic - VLAN comparison logic - Port VLAN data parsing """ from unittest.mock import MagicMock, patch # Import the autouse fixture from helpers pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] # ============================================ # TEST DATA FIXTURES # ============================================ # Sample LibreNMS VLAN response (from /resources/vlans endpoint) # Note: This endpoint includes vlan_id and device_id, unlike /devices/{id}/vlans MOCK_DEVICE_VLANS = { "status": "ok", "vlans": [ { "vlan_id": 101, "device_id": 123, "vlan_vlan": 1, "vlan_name": "default", "vlan_type": "ethernet", "vlan_state": 1, "vlan_domain": 1, }, { "vlan_id": 102, "device_id": 123, "vlan_vlan": 50, "vlan_name": "ORG_DATA", "vlan_type": "ethernet", "vlan_state": 1, "vlan_domain": 1, }, { "vlan_id": 103, "device_id": 123, "vlan_vlan": 60, "vlan_name": "ORG_VOICE", "vlan_type": "ethernet", "vlan_state": 1, "vlan_domain": 1, }, ], "count": 3, } # Sample port VLAN info response (bulk call) MOCK_PORT_VLAN_INFO = { "status": "ok", "ports": [ {"port_id": 114184, "ifName": "Gi1/0/40", "ifVlan": "50", "ifTrunk": None}, {"port_id": 114326, "ifName": "Gi3/0/48", "ifVlan": "1", "ifTrunk": "dot1Q"}, {"port_id": 114327, "ifName": "Gi3/1/1", "ifVlan": "1", "ifTrunk": None}, {"port_id": 114145, "ifName": "Gi1/0/1", "ifVlan": "", "ifTrunk": None}, # No VLAN ], } # Sample port with vlans detail response (for trunk port) MOCK_PORT_VLAN_DETAILS_TRUNK = { "status": "ok", "port": [ { "port_id": 227011, "ifName": "Te1/1/1", "ifVlan": "90", "ifTrunk": "dot1Q", "vlans": [ {"vlan": 90, "untagged": 1, "state": "unknown", "port_vlan_id": 195164}, {"vlan": 50, "untagged": 0, "state": "forwarding", "port_vlan_id": 2165422}, ], } ], } # Sample port with vlans detail response (for access port) MOCK_PORT_VLAN_DETAILS_ACCESS = { "status": "ok", "port": [ { "port_id": 729403, "ifName": "Gi0/2", "ifVlan": "50", "ifTrunk": None, "vlans": [ {"vlan": 50, "untagged": 1, "state": "forwarding", "port_vlan_id": 3234550}, ], } ], } def create_mock_device(): """Create a mock NetBox device.""" device = MagicMock() device.pk = 123 device.name = "test-switch" device._meta.model_name = "device" device.site = MagicMock() device.site.pk = 1 device.site.name = "Test Site" return device def create_mock_interface(name, mode=None, untagged_vlan=None, tagged_vlans=None): """Create a mock NetBox interface.""" interface = MagicMock() interface.pk = hash(name) interface.name = name interface.mode = mode interface.untagged_vlan = untagged_vlan interface.tagged_vlans = MagicMock() interface.tagged_vlans.all.return_value = tagged_vlans or [] return interface def create_mock_vlan(vid, name, group=None): """Create a mock NetBox VLAN.""" vlan = MagicMock() vlan.pk = vid * 100 vlan.vid = vid vlan.name = name vlan.group = group return vlan # ============================================ # API METHOD TESTS # ============================================ class TestVLANAPIClient: """Tests for LibreNMS VLAN API methods.""" @patch("requests.get") def test_get_device_vlans_success(self, mock_get, mock_librenms_config): """Test successful VLAN fetch from /resources/vlans endpoint.""" mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = MOCK_DEVICE_VLANS from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") success, data = api.get_device_vlans(123) assert success is True assert len(data) == 3 assert data[1]["vlan_vlan"] == 50 assert data[1]["vlan_name"] == "ORG_DATA" # Verify vlan_id is present from /resources/vlans endpoint assert data[1]["vlan_id"] == 102 @patch("requests.get") def test_get_device_vlans_filters_by_device_id(self, mock_get, mock_librenms_config): """Test that VLANs are filtered by device_id.""" # Response includes VLANs from multiple devices mock_response_data = { "status": "ok", "vlans": [ {"vlan_id": 101, "device_id": 123, "vlan_vlan": 1, "vlan_name": "default"}, {"vlan_id": 201, "device_id": 456, "vlan_vlan": 1, "vlan_name": "default"}, # Different device {"vlan_id": 102, "device_id": 123, "vlan_vlan": 50, "vlan_name": "DATA"}, ], } mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = mock_response_data from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") success, data = api.get_device_vlans(123) assert success is True assert len(data) == 2 # Only device 123's VLANs assert all(str(v["device_id"]) == "123" for v in data) @patch("requests.get") def test_get_device_vlans_error(self, mock_get, mock_librenms_config): """Test VLAN fetch with error.""" from requests.exceptions import HTTPError mock_response = MagicMock() mock_response.status_code = 404 mock_response.raise_for_status.side_effect = HTTPError(response=mock_response) mock_get.return_value = mock_response from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") success, data = api.get_device_vlans(999) assert success is False assert "not found" in data.lower() @patch("requests.get") def test_get_port_vlan_details_trunk(self, mock_get, mock_librenms_config): """Test fetching trunk port VLAN details.""" mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = MOCK_PORT_VLAN_DETAILS_TRUNK from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") success, data = api.get_port_vlan_details(227011) assert success is True assert data["ifTrunk"] == "dot1Q" assert len(data["vlans"]) == 2 @patch("requests.get") def test_get_port_vlan_details_not_found(self, mock_get, mock_librenms_config): """Test fetching port details when port not found.""" mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {"status": "ok", "port": []} from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") success, data = api.get_port_vlan_details(999999) assert success is False assert "not found" in data.lower() # ============================================ # MODE DETECTION TESTS # ============================================ class TestVLANModeDetection: """Tests for 802.1Q mode detection logic.""" def test_parse_port_vlan_data_access_port(self, mock_librenms_config): """Access port: ifVlan set, ifTrunk null.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = {"port_id": 1, "ifName": "Gi1/0/1", "ifVlan": "50", "ifTrunk": None} result = api.parse_port_vlan_data(port_data) assert result["mode"] == "access" assert result["untagged_vlan"] == 50 assert result["tagged_vlans"] == [] def test_parse_port_vlan_data_trunk_port(self, mock_librenms_config): """Trunk port: ifTrunk = dot1Q.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = { "port_id": 2, "ifName": "Te1/1/1", "ifVlan": "90", "ifTrunk": "dot1Q", "vlans": [ {"vlan": 90, "untagged": 1}, {"vlan": 50, "untagged": 0}, {"vlan": 60, "untagged": 0}, ], } result = api.parse_port_vlan_data(port_data) assert result["mode"] == "tagged" assert result["untagged_vlan"] == 90 assert result["tagged_vlans"] == [50, 60] def test_parse_port_vlan_data_no_vlan(self, mock_librenms_config): """No VLAN: ifVlan empty.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = {"port_id": 3, "ifName": "Gi1/0/48", "ifVlan": "", "ifTrunk": None} result = api.parse_port_vlan_data(port_data) assert result["mode"] is None assert result["untagged_vlan"] is None assert result["tagged_vlans"] == [] # ============================================ # VLAN COMPARISON TESTS # ============================================ class TestVLANComparison: """Tests for VLAN comparison logic.""" def test_compare_vlans_exists_in_netbox(self): """Test VLAN exists in NetBox VLAN group.""" netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")} librenms_vlan = {"vlan_vlan": 50, "vlan_name": "ORG_DATA"} exists = librenms_vlan["vlan_vlan"] in netbox_vlans assert exists is True def test_compare_vlans_missing_from_netbox(self): """Test VLAN missing from NetBox.""" netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")} librenms_vlan = {"vlan_vlan": 60, "vlan_name": "ORG_VOICE"} exists = librenms_vlan["vlan_vlan"] in netbox_vlans assert exists is False def test_compare_vlans_name_matches(self): """Test VLAN name comparison when matching.""" netbox_vlan = create_mock_vlan(50, "ORG_DATA") librenms_name = "ORG_DATA" name_matches = netbox_vlan.name == librenms_name assert name_matches is True def test_compare_vlans_name_differs(self): """Test VLAN name comparison when different.""" netbox_vlan = create_mock_vlan(50, "DATA_VLAN") librenms_name = "ORG_DATA" name_matches = netbox_vlan.name == librenms_name assert name_matches is False # ============================================ # PORT VLAN PARSING TESTS # ============================================ class TestPortVLANParsing: """Tests for parsing port VLAN data.""" def test_parse_trunk_port_vlans(self): """Parse trunk port into untagged and tagged lists.""" vlans_data = MOCK_PORT_VLAN_DETAILS_TRUNK["port"][0]["vlans"] untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] assert untagged == [90] assert tagged == [50] def test_parse_access_port_vlans(self): """Parse access port - single untagged VLAN.""" vlans_data = MOCK_PORT_VLAN_DETAILS_ACCESS["port"][0]["vlans"] untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] assert untagged == [50] assert tagged == [] def test_parse_port_with_multiple_tagged(self): """Parse trunk port with multiple tagged VLANs.""" vlans_data = [ {"vlan": 1, "untagged": 1}, {"vlan": 10, "untagged": 0}, {"vlan": 20, "untagged": 0}, {"vlan": 30, "untagged": 0}, ] untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] assert untagged == [1] assert len(tagged) == 3 assert set(tagged) == {10, 20, 30} # ============================================ # SYNC ACTION TESTS # ============================================ class TestSyncVLANActions: """Tests for VLAN sync action logic.""" def test_mode_mapping_access(self): """Test mapping LibreNMS access mode to NetBox.""" librenms_mode = "access" expected_netbox_mode = "access" mode_map = {"access": "access", "tagged": "tagged"} result = mode_map.get(librenms_mode) assert result == expected_netbox_mode def test_mode_mapping_tagged(self): """Test mapping LibreNMS tagged mode to NetBox.""" librenms_mode = "tagged" expected_netbox_mode = "tagged" mode_map = {"access": "access", "tagged": "tagged"} result = mode_map.get(librenms_mode) assert result == expected_netbox_mode def test_vlan_state_mapping_active(self): """Test mapping active VLAN state.""" vlan_state = 1 status = "active" if vlan_state == 1 else "reserved" assert status == "active" def test_vlan_state_mapping_inactive(self): """Test mapping inactive VLAN state.""" vlan_state = 0 status = "active" if vlan_state == 1 else "reserved" assert status == "reserved" # ============================================ # VLAN SYNC CSS CLASS UTILITY # ============================================ class TestGetVlanSyncCssClass: """Tests for the shared get_vlan_sync_css_class utility.""" def test_not_in_netbox(self): """VLAN not in NetBox should return text-danger.""" from netbox_librenms_plugin.utils import get_vlan_sync_css_class assert get_vlan_sync_css_class(exists_in_netbox=False) == "text-danger" def test_not_in_netbox_name_match_irrelevant(self): """Name match flag should be irrelevant when VLAN doesn't exist.""" from netbox_librenms_plugin.utils import get_vlan_sync_css_class assert get_vlan_sync_css_class(exists_in_netbox=False, name_matches=True) == "text-danger" def test_exists_name_matches(self): """VLAN exists with matching name should return text-success.""" from netbox_librenms_plugin.utils import get_vlan_sync_css_class assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=True) == "text-success" def test_exists_name_mismatch(self): """VLAN exists but name differs should return text-warning.""" from netbox_librenms_plugin.utils import get_vlan_sync_css_class assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=False) == "text-warning" def test_default_name_matches_is_true(self): """Default name_matches should be True (success when exists).""" from netbox_librenms_plugin.utils import get_vlan_sync_css_class assert get_vlan_sync_css_class(exists_in_netbox=True) == "text-success" class TestVlanEntryDictGuardInSync: """Verify isinstance(vlan_entry, dict) guard works in parse_port_vlan_data.""" def test_mixed_vlans_data_only_dicts_parsed(self, mock_librenms_config): """vlans array with non-dict entries: only dict entries produce VIDs.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI api = LibreNMSAPI(server_key="default") port_data = { "port_id": 1, "ifName": "GigabitEthernet0/0", "ifDescr": "GigabitEthernet0/0", "ifTrunk": "dot1Q", "ifVlan": None, "vlans": [{"vlan": 10, "untagged": 1}, "bad_entry", {"vlan": 20}], } result = api.parse_port_vlan_data(port_data) assert result["untagged_vlan"] == 10 assert result["tagged_vlans"] == [20]