""" Tests for netbox_librenms_plugin.utils module. Phase 2 tests covering device type matching, site matching, platform matching, and conversion helper functions. """ import json from unittest.mock import MagicMock, patch # ============================================================================= # TestDeviceTypeMatching - 5 tests # ============================================================================= class TestDeviceTypeMatching: """Test device type matching logic.""" @patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True) @patch("dcim.models.DeviceType") def test_match_device_type_exact_match_by_part_number(self, mock_device_type, mock_dtm): """Exact part_number string should match.""" mock_dtm.DoesNotExist = Exception mock_dtm.objects.get.side_effect = Exception mock_dt = MagicMock(id=1, model="C9300-48P") mock_device_type.objects.get.return_value = mock_dt from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("C9300-48P") assert result["matched"] is True assert result["device_type"] == mock_dt assert result["match_type"] == "exact" @patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True) @patch("dcim.models.DeviceType") def test_match_device_type_exact_match_by_model(self, mock_device_type, mock_dtm): """Exact model string should match when part_number fails.""" mock_dtm.DoesNotExist = Exception mock_dtm.objects.get.side_effect = Exception mock_dt = MagicMock(id=1, model="WS-C3750X-48P") # Part number lookup fails, model lookup succeeds mock_device_type.DoesNotExist = Exception mock_device_type.objects.get.side_effect = [ mock_device_type.DoesNotExist, # part_number lookup fails mock_dt, # model lookup succeeds ] from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("WS-C3750X-48P") assert result["matched"] is True assert result["device_type"] == mock_dt assert result["match_type"] == "exact" @patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True) @patch("dcim.models.DeviceType") def test_match_device_type_not_found(self, mock_device_type, mock_dtm): """Returns not-found dict when no match found.""" mock_dtm.DoesNotExist = Exception mock_dtm.objects.get.side_effect = Exception mock_device_type.DoesNotExist = Exception mock_device_type.objects.get.side_effect = mock_device_type.DoesNotExist from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("NonexistentHardware") assert result["matched"] is False assert result["device_type"] is None assert result["match_type"] is None def test_match_device_type_empty_hardware(self): """Empty string returns None.""" from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("") assert result["matched"] is False assert result["device_type"] is None def test_match_device_type_dash_hardware(self): """Dash placeholder returns None.""" from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("-") assert result["matched"] is False assert result["device_type"] is None @patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True) @patch("dcim.models.DeviceType") def test_match_device_type_ambiguous_part_number_returns_none(self, mock_device_type, mock_dtm): """MultipleObjectsReturned on part_number should return None, not pick .first().""" DoesNotExist = type("DoesNotExist", (Exception,), {}) MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) mock_dtm.DoesNotExist = DoesNotExist mock_dtm.MultipleObjectsReturned = type("DTMMult", (Exception,), {}) mock_dtm.objects.get.side_effect = DoesNotExist # DTM not found mock_device_type.DoesNotExist = DoesNotExist mock_device_type.MultipleObjectsReturned = MultipleObjectsReturned mock_device_type.objects.get.side_effect = MultipleObjectsReturned from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("DUPLICATE-PARTNUM") assert result is None @patch("netbox_librenms_plugin.models.DeviceTypeMapping", create=True) @patch("dcim.models.DeviceType") def test_match_device_type_ambiguous_model_returns_none(self, mock_device_type, mock_dtm): """MultipleObjectsReturned on model should return None, not pick .first().""" DoesNotExist = type("DoesNotExist", (Exception,), {}) MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) mock_dtm.DoesNotExist = DoesNotExist mock_dtm.MultipleObjectsReturned = type("DTMMult", (Exception,), {}) mock_dtm.objects.get.side_effect = DoesNotExist # DTM not found mock_device_type.DoesNotExist = DoesNotExist mock_device_type.MultipleObjectsReturned = MultipleObjectsReturned # part_number raises DoesNotExist, model raises MultipleObjectsReturned mock_device_type.objects.get.side_effect = [ DoesNotExist("not found"), MultipleObjectsReturned("ambiguous"), ] from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type result = match_librenms_hardware_to_device_type("DUPLICATE-MODEL") assert result is None # ============================================================================= # TestSiteMatching - 4 tests # ============================================================================= class TestSiteMatching: """Test site matching logic.""" @patch("dcim.models.Site") def test_find_site_for_location_exact_match(self, mock_site_model): """Location name matched to site.""" mock_site = MagicMock(id=1, name="DC1") mock_site_model.objects.get.return_value = mock_site from netbox_librenms_plugin.utils import find_matching_site result = find_matching_site("DC1") assert result["found"] is True assert result["site"] == mock_site assert result["match_type"] == "exact" assert result["confidence"] == 1.0 @patch("dcim.models.Site") def test_find_site_for_location_not_found(self, mock_site_model): """Returns None when no match.""" mock_site_model.DoesNotExist = Exception mock_site_model.objects.get.side_effect = mock_site_model.DoesNotExist from netbox_librenms_plugin.utils import find_matching_site result = find_matching_site("Unknown Location") assert result["found"] is False assert result["site"] is None assert result["confidence"] == 0.0 def test_find_site_for_location_empty(self): """Empty location returns None.""" from netbox_librenms_plugin.utils import find_matching_site result = find_matching_site("") assert result["found"] is False assert result["site"] is None def test_find_site_for_location_dash(self): """Dash placeholder returns None.""" from netbox_librenms_plugin.utils import find_matching_site result = find_matching_site("-") assert result["found"] is False assert result["site"] is None # ============================================================================= # TestPlatformMatching - 4 tests # ============================================================================= class TestPlatformMatching: """Test platform matching logic.""" @patch("dcim.models.Platform") def test_find_platform_for_os_exact_match(self, mock_platform_model): """OS string matched to platform.""" mock_platform = MagicMock(id=1, name="ios") mock_platform_model.objects.get.return_value = mock_platform from netbox_librenms_plugin.utils import find_matching_platform result = find_matching_platform("ios") assert result["found"] is True assert result["platform"] == mock_platform assert result["match_type"] == "exact" @patch("dcim.models.Platform") def test_find_platform_for_os_not_found(self, mock_platform_model): """Returns None when no match.""" mock_platform_model.DoesNotExist = Exception mock_platform_model.objects.get.side_effect = mock_platform_model.DoesNotExist from netbox_librenms_plugin.utils import find_matching_platform result = find_matching_platform("unknown_os") assert result["found"] is False assert result["platform"] is None def test_find_platform_for_os_empty(self): """Empty OS returns None.""" from netbox_librenms_plugin.utils import find_matching_platform result = find_matching_platform("") assert result["found"] is False assert result["platform"] is None def test_find_platform_for_os_dash(self): """Dash placeholder returns None.""" from netbox_librenms_plugin.utils import find_matching_platform result = find_matching_platform("-") assert result["found"] is False assert result["platform"] is None # ============================================================================= # TestConversionHelpers - 4 tests # ============================================================================= class TestConversionHelpers: """Test data conversion helper functions.""" def test_convert_speed_to_kbps_basic(self): """Convert bps to kbps.""" from netbox_librenms_plugin.utils import convert_speed_to_kbps # 1 Gbps = 1,000,000,000 bps = 1,000,000 kbps result = convert_speed_to_kbps(1000000000) assert result == 1000000 def test_convert_speed_to_kbps_megabit(self): """Convert megabit speed to kbps.""" from netbox_librenms_plugin.utils import convert_speed_to_kbps # 100 Mbps = 100,000,000 bps = 100,000 kbps result = convert_speed_to_kbps(100000000) assert result == 100000 def test_convert_speed_to_kbps_zero(self): """Zero handled correctly.""" from netbox_librenms_plugin.utils import convert_speed_to_kbps result = convert_speed_to_kbps(0) assert result == 0 def test_convert_speed_to_kbps_none(self): """None returns None.""" from netbox_librenms_plugin.utils import convert_speed_to_kbps result = convert_speed_to_kbps(None) assert result is None def test_format_mac_address_valid(self): """Format valid MAC address.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address("aabbccddeeff") assert result == "AA:BB:CC:DD:EE:FF" def test_format_mac_address_with_colons(self): """Format MAC address that already has colons.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address("aa:bb:cc:dd:ee:ff") assert result == "AA:BB:CC:DD:EE:FF" def test_format_mac_address_with_dashes(self): """Format MAC address with dashes.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address("aa-bb-cc-dd-ee-ff") assert result == "AA:BB:CC:DD:EE:FF" def test_format_mac_address_invalid(self): """Returns error message for invalid MAC.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address("invalid") assert result == "Invalid MAC Address" def test_format_mac_address_empty(self): """Empty string returns empty string.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address("") assert result == "" def test_format_mac_address_none(self): """None returns empty string.""" from netbox_librenms_plugin.utils import format_mac_address result = format_mac_address(None) assert result == "" # ============================================================================= # TestVirtualChassisHelpers - 4 tests # ============================================================================= class TestVirtualChassisHelpers: """Test virtual chassis helper functions.""" def test_get_virtual_chassis_member_no_vc(self, mock_netbox_device): """Device without VC returns original device.""" from netbox_librenms_plugin.utils import get_virtual_chassis_member mock_netbox_device.virtual_chassis = None result = get_virtual_chassis_member(mock_netbox_device, "Ethernet1") assert result == mock_netbox_device def test_get_virtual_chassis_member_with_vc(self): """Device with VC returns correct member.""" from netbox_librenms_plugin.utils import get_virtual_chassis_member mock_device = MagicMock() mock_member = MagicMock(name="member-1") mock_device.virtual_chassis = MagicMock() mock_device.virtual_chassis.members.get.return_value = mock_member result = get_virtual_chassis_member(mock_device, "Ethernet1") # Should try to get VC member with position 1 mock_device.virtual_chassis.members.get.assert_called_once_with(vc_position=1) assert result == mock_member def test_get_virtual_chassis_member_invalid_port(self): """Invalid port name returns original device.""" from netbox_librenms_plugin.utils import get_virtual_chassis_member mock_device = MagicMock() mock_device.virtual_chassis = MagicMock() result = get_virtual_chassis_member(mock_device, "InvalidPort") assert result == mock_device def test_get_librenms_sync_device_no_vc(self, mock_netbox_device): """Device without VC returns itself.""" from netbox_librenms_plugin.utils import get_librenms_sync_device mock_netbox_device.virtual_chassis = None result = get_librenms_sync_device(mock_netbox_device) assert result == mock_netbox_device def test_get_librenms_sync_device_with_librenms_id(self): """VC member with librenms_id is returned.""" from netbox_librenms_plugin.utils import get_librenms_sync_device mock_device = MagicMock() mock_member_with_id = MagicMock() mock_member_with_id.cf = {"librenms_id": 123} mock_member_without_id = MagicMock() mock_member_without_id.cf = {} mock_device.virtual_chassis = MagicMock() mock_device.virtual_chassis.members.all.return_value = [ mock_member_without_id, mock_member_with_id, ] result = get_librenms_sync_device(mock_device) assert result == mock_member_with_id def test_get_librenms_sync_device_dict_preferred_over_legacy_bare_int(self): """ In a partially migrated VC, a member with per-server dict format is preferred over a member with legacy bare-int format.""" from netbox_librenms_plugin.utils import get_librenms_sync_device # Member A: legacy bare-int librenms_id (not yet migrated) member_a = MagicMock() member_a.cf = {"librenms_id": 42} # Member B: migrated per-server dict format member_b = MagicMock() member_b.cf = {"librenms_id": {"default": 42}} mock_device = MagicMock() mock_device.virtual_chassis = MagicMock() # member_a listed first — the function should still prefer member_b mock_device.virtual_chassis.members.all.return_value = [member_a, member_b] result = get_librenms_sync_device(mock_device, server_key="default") assert result == member_b def test_get_librenms_sync_device_legacy_fallback_when_no_dict(self): """When no member has a per-server dict, fall back to legacy bare-int.""" from netbox_librenms_plugin.utils import get_librenms_sync_device member_a = MagicMock() member_a.cf = {"librenms_id": 42} member_b = MagicMock() member_b.cf = {} mock_device = MagicMock() mock_device.virtual_chassis = MagicMock() mock_device.virtual_chassis.members.all.return_value = [member_b, member_a] result = get_librenms_sync_device(mock_device, server_key="default") assert result == member_a def test_get_librenms_sync_device_dict_for_different_server_falls_through(self): """Per-server dict with a different key does not match; legacy bare-int resolves instead.""" from netbox_librenms_plugin.utils import get_librenms_sync_device # Member A: legacy bare-int (universal fallback) member_a = MagicMock() member_a.cf = {"librenms_id": 42} # Member B: dict but only for "production", not "default" member_b = MagicMock() member_b.cf = {"librenms_id": {"production": 99}} mock_device = MagicMock() mock_device.virtual_chassis = MagicMock() mock_device.virtual_chassis.members.all.return_value = [member_a, member_b] result = get_librenms_sync_device(mock_device, server_key="default") assert result == member_a def test_zero_id_is_not_a_valid_librenms_id(self): """LibreNMS uses MySQL auto-increment IDs starting at 1; device_id=0 cannot exist. A member whose resolved ID is 0 must be skipped so a real ID is preferred.""" from netbox_librenms_plugin.utils import get_librenms_sync_device device = MagicMock() vc = MagicMock() device.virtual_chassis = vc vc.master = None member_zero = MagicMock() member_zero.primary_ip = None member_real = MagicMock() member_real.primary_ip = None def _id_side_effect(obj, server_key, **kwargs): if obj is member_zero: return 0 if obj is member_real: return 5 return None # member_zero comes first but has id=0; member_real has id=5 — real ID wins with patch("netbox_librenms_plugin.utils.get_librenms_device_id") as mock_get_id: mock_get_id.side_effect = _id_side_effect vc.members.all.return_value = [member_zero, member_real] result = get_librenms_sync_device(device, server_key="default") assert result is member_real # ============================================================================= # TestSafeDisabled - tests for _safe_disabled in bulk_import.py and filters.py # ============================================================================= class TestSafeDisabledBulkImport: """Tests for _safe_disabled in import_utils/bulk_import.py.""" def _call(self, val): from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled return _safe_disabled({"disabled": val}) def test_bool_true(self): assert self._call(True) == 1 def test_bool_false(self): assert self._call(False) == 0 def test_string_true_lowercase(self): assert self._call("true") == 1 def test_string_yes(self): assert self._call("yes") == 1 def test_string_on(self): assert self._call("on") == 1 def test_string_false_lowercase(self): assert self._call("false") == 0 def test_string_no(self): assert self._call("no") == 0 def test_string_off(self): assert self._call("off") == 0 def test_numeric_one(self): assert self._call(1) == 1 def test_numeric_zero(self): assert self._call(0) == 0 def test_none_defaults_to_zero(self): assert self._call(None) == 0 def test_missing_key_defaults_to_zero(self): from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled assert _safe_disabled({}) == 0 def test_string_true_uppercase(self): assert self._call("TRUE") == 1 def test_non_zero_int_is_disabled(self): assert self._call(2) == 1 def test_negative_int_is_disabled(self): assert self._call(-1) == 1 class TestSafeDisabledFilters: """Tests for _safe_disabled in import_utils/filters.py (same contract).""" def _call(self, val): from netbox_librenms_plugin.import_utils.filters import _safe_disabled return _safe_disabled({"disabled": val}) def test_bool_true(self): assert self._call(True) == 1 def test_bool_false(self): assert self._call(False) == 0 def test_string_true(self): assert self._call("true") == 1 def test_string_yes(self): assert self._call("yes") == 1 def test_string_on(self): assert self._call("on") == 1 def test_string_false(self): assert self._call("false") == 0 def test_string_off(self): assert self._call("off") == 0 def test_string_uppercase_true(self): assert self._call("TRUE") == 1 def test_string_no(self): assert self._call("no") == 0 def test_numeric_one(self): assert self._call(1) == 1 def test_none_defaults_to_zero(self): assert self._call(None) == 0 def test_non_zero_int_is_disabled(self): assert self._call(2) == 1 def test_negative_int_is_disabled(self): assert self._call(-1) == 1 def test_missing_key_defaults_to_zero(self): from netbox_librenms_plugin.import_utils.filters import _safe_disabled assert _safe_disabled({}) == 0 class TestPaginationHelpers: """Test pagination helper functions.""" @patch("netbox_librenms_plugin.utils.get_config") @patch("netbox_librenms_plugin.utils.netbox_get_paginate_count") def test_get_table_paginate_count_from_request(self, mock_netbox_paginate, mock_config): """Custom per_page from request is used.""" from netbox_librenms_plugin.utils import get_table_paginate_count mock_config.return_value.MAX_PAGE_SIZE = 1000 mock_request = MagicMock() mock_request.GET = {"table1_per_page": "50"} result = get_table_paginate_count(mock_request, "table1_") assert result == 50 @patch("netbox_librenms_plugin.utils.get_config") @patch("netbox_librenms_plugin.utils.netbox_get_paginate_count") def test_get_table_paginate_count_default(self, mock_netbox_paginate, mock_config): """Default pagination used when no override.""" from netbox_librenms_plugin.utils import get_table_paginate_count mock_netbox_paginate.return_value = 25 mock_request = MagicMock() mock_request.GET = {} result = get_table_paginate_count(mock_request, "table1_") assert result == 25 mock_netbox_paginate.assert_called_once() # ============================================================================= # TestInterfaceNameField - 3 tests # ============================================================================= class TestInterfaceNameField: """Test interface name field retrieval.""" @patch("netbox_librenms_plugin.utils.get_plugin_config") def test_get_interface_name_field_from_get(self, mock_plugin_config): """Override from GET request parameter.""" from netbox_librenms_plugin.utils import get_interface_name_field mock_request = MagicMock() mock_request.GET = {"interface_name_field": "ifDescr"} mock_request.POST = {} result = get_interface_name_field(mock_request) assert result == "ifDescr" @patch("netbox_librenms_plugin.utils.get_plugin_config") def test_get_interface_name_field_from_post(self, mock_plugin_config): """Override from POST request parameter.""" from netbox_librenms_plugin.utils import get_interface_name_field mock_request = MagicMock() mock_request.GET = {} mock_request.POST = {"interface_name_field": "ifName"} result = get_interface_name_field(mock_request) assert result == "ifName" @patch("netbox_librenms_plugin.utils.get_plugin_config") def test_get_interface_name_field_from_config(self, mock_plugin_config): """Falls back to plugin config.""" from netbox_librenms_plugin.utils import get_interface_name_field mock_plugin_config.return_value = "ifAlias" mock_request = MagicMock() mock_request.GET = {} mock_request.POST = {} mock_request.user.config.get.return_value = None result = get_interface_name_field(mock_request) assert result == "ifAlias" mock_plugin_config.assert_called_with("netbox_librenms_plugin", "interface_name_field") @patch("netbox_librenms_plugin.utils.get_plugin_config") def test_get_interface_name_field_from_user_pref(self, mock_plugin_config): """Falls back to user preference before plugin config.""" from netbox_librenms_plugin.utils import get_interface_name_field mock_request = MagicMock() mock_request.GET = {} mock_request.POST = {} mock_request.user.config.get.return_value = "ifName" result = get_interface_name_field(mock_request) assert result == "ifName" mock_plugin_config.assert_not_called() @patch("netbox_librenms_plugin.utils.get_plugin_config") def test_get_interface_name_field_persists_to_user_pref(self, mock_plugin_config): """Explicit GET param should be persisted to user preferences.""" from netbox_librenms_plugin.utils import get_interface_name_field mock_request = MagicMock() mock_request.GET = {"interface_name_field": "ifDescr"} mock_request.POST = {} result = get_interface_name_field(mock_request) assert result == "ifDescr" mock_request.user.config.set.assert_called_once_with( "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True ) # ============================================================================= # TestSaveUserPrefView - 6 tests # ============================================================================= class TestSaveUserPrefView: """Test SaveUserPrefView endpoint for JS-driven preference persistence.""" def _make_request(self, body, has_perm=True): """Create a mock POST request with JSON body.""" request = MagicMock() request.body = json.dumps(body).encode() request.user.has_perm.return_value = has_perm request.user.config = MagicMock() request.method = "POST" return request def test_save_valid_boolean_pref(self): """Saving a valid boolean preference returns ok.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView view = SaveUserPrefView() request = self._make_request({"key": "use_sysname", "value": True}) view.request = request response = view.post(request) assert response.status_code == 200 data = json.loads(response.content) assert data["status"] == "ok" request.user.config.set.assert_called_once_with("plugins.netbox_librenms_plugin.use_sysname", True, commit=True) def test_save_string_pref(self): """Saving interface_name_field string value works.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView view = SaveUserPrefView() request = self._make_request({"key": "interface_name_field", "value": "ifDescr"}) view.request = request response = view.post(request) assert response.status_code == 200 request.user.config.set.assert_called_once_with( "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True ) def test_reject_invalid_key(self): """Invalid preference key returns 400.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView view = SaveUserPrefView() request = self._make_request({"key": "malicious_key", "value": True}) view.request = request response = view.post(request) assert response.status_code == 400 data = json.loads(response.content) assert "Invalid preference key" in data["error"] request.user.config.set.assert_not_called() def test_reject_invalid_json(self): """Invalid JSON body returns 400.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView view = SaveUserPrefView() request = MagicMock() request.body = b"not valid json" view.request = request response = view.post(request) assert response.status_code == 400 data = json.loads(response.content) assert "Invalid JSON" in data["error"] def test_save_false_value(self): """Saving False for a toggle works correctly.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView view = SaveUserPrefView() request = self._make_request({"key": "strip_domain", "value": False}) view.request = request response = view.post(request) assert response.status_code == 200 request.user.config.set.assert_called_once_with( "plugins.netbox_librenms_plugin.strip_domain", False, commit=True ) def test_uses_permission_mixin(self): """SaveUserPrefView inherits from LibreNMSPermissionMixin.""" from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin assert issubclass(SaveUserPrefView, LibreNMSPermissionMixin)