""" Integration tests for Virtual Chassis detection using the mock LibreNMS HTTP server. These tests verify that detect_virtual_chassis_from_inventory(), get_virtual_chassis_data(), and prefetch_vc_data_for_devices() work correctly end-to-end through real HTTP calls to a local mock server — no mocking of the detection logic itself. Run: python -m pytest netbox_librenms_plugin/tests/test_integration_virtual_chassis.py -v """ import pytest from unittest.mock import patch from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server @pytest.fixture def mock_server(): with librenms_mock_server() as server: yield server def _make_api(url, token="test-token", server_key="test"): """Create a LibreNMSAPI instance pointed at the mock server.""" from netbox_librenms_plugin.librenms_api import LibreNMSAPI servers_config = { server_key: { "librenms_url": url, "api_token": token, "cache_timeout": 300, "verify_ssl": False, } } with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg: mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None api = LibreNMSAPI(server_key=server_key) return api def _chassis(index, serial, model="WS-C3750X", name="", descr="", position=None, contained_in=None): """Build a minimal ENTITY-MIB chassis entry.""" item = { "entPhysicalIndex": index, "entPhysicalClass": "chassis", "entPhysicalSerialNum": serial, "entPhysicalModelName": model, "entPhysicalName": name or f"Chassis-{index}", "entPhysicalDescr": descr or f"Chassis {index}", } if position is not None: item["entPhysicalParentRelPos"] = position if contained_in is not None: item["entPhysicalContainedIn"] = contained_in return item def _stack_root(index=1): """Build a 'stack' class root entry (e.g., Cisco StackWise).""" return { "entPhysicalIndex": index, "entPhysicalClass": "stack", "entPhysicalSerialNum": "", "entPhysicalModelName": "", "entPhysicalName": "StackSub-0/0", "entPhysicalDescr": "Cisco StackWise", "entPhysicalContainedIn": 0, } class TestDetectVCCiscoStack: """Cisco StackWise topology: root has stack-class entry; children are chassis members.""" def test_three_member_stack(self, mock_server): """3 chassis members under a stack root → is_stack=True, member_count=3.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 10 mock_server.device_info_response(device_id=device_id, hostname="sw-stack", serial="MASTER") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-A", position=1), _chassis(200, "SN-B", position=2), _chassis(300, "SN-C", position=3), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None assert result["is_stack"] is True assert result["member_count"] == 3 serials = [m["serial"] for m in result["members"]] assert serials == ["SN-A", "SN-B", "SN-C"] def test_members_sorted_by_position(self, mock_server): """Members returned in position order regardless of API order.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 11 mock_server.device_info_response(device_id=device_id, hostname="sw-stack-2") root_items = [_stack_root(index=5)] # Deliberately out of order: 3, 1, 2 member_items = [ _chassis(301, "SN-3", position=3), _chassis(101, "SN-1", position=1), _chassis(201, "SN-2", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {5: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None positions = [m["position"] for m in result["members"]] assert positions == [1, 2, 3] def test_position_zero_falls_back_to_idx_plus_one(self, mock_server): """position=0 in entPhysicalParentRelPos → fallback to idx+1 (never 0).""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 12 mock_server.device_info_response(device_id=device_id, hostname="sw-stack-3") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-X", position=0), # 0 → fallback to idx+1=1 _chassis(200, "SN-Y", position=0), # 0 → fallback to idx+1=2 ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None positions = [m["position"] for m in result["members"]] # Both had position=0, so they fall back to idx+1: positions [1, 2] assert all(p >= 1 for p in positions) def test_member_fields_extracted_correctly(self, mock_server): """serial, model, name, description all extracted from chassis entries.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 13 mock_server.device_info_response(device_id=device_id, hostname="sw-stack-4") root_items = [_stack_root(index=1)] member_items = [ { "entPhysicalIndex": 100, "entPhysicalClass": "chassis", "entPhysicalSerialNum": "SERIAL-ABC", "entPhysicalModelName": "WS-C3750X-48P", "entPhysicalName": "Slot 1", "entPhysicalDescr": "48-port PoE switch", "entPhysicalParentRelPos": 1, }, { "entPhysicalIndex": 200, "entPhysicalClass": "chassis", "entPhysicalSerialNum": "SERIAL-DEF", "entPhysicalModelName": "WS-C3750X-24T", "entPhysicalName": "Slot 2", "entPhysicalDescr": "24-port switch", "entPhysicalParentRelPos": 2, }, ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None m1, m2 = result["members"] assert m1["serial"] == "SERIAL-ABC" assert m1["model"] == "WS-C3750X-48P" assert m1["name"] == "Slot 1" assert m1["description"] == "48-port PoE switch" assert m2["serial"] == "SERIAL-DEF" def test_suggested_name_uses_master_sysname(self, mock_server): """suggested_name generated from master device sysName.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 14 mock_server.device_info_response(device_id=device_id, hostname="sw-master", serial="MASTER01") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None # Members should have non-empty suggested names for member in result["members"]: assert "suggested_name" in member assert member["suggested_name"] # non-empty class TestDetectVCJuniperStyle: """Juniper-style: root has chassis-class entry; children are chassis members.""" def test_two_member_vc(self, mock_server): """2 chassis members under a chassis root → is_stack=True, member_count=2.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 20 mock_server.device_info_response(device_id=device_id, hostname="vc-switch") # Root: a chassis entry (not stack) root_items = [ { "entPhysicalIndex": 10, "entPhysicalClass": "chassis", "entPhysicalSerialNum": "", "entPhysicalModelName": "", "entPhysicalName": "Virtual Chassis", "entPhysicalDescr": "EX4300 Virtual Chassis", "entPhysicalContainedIn": 0, } ] member_items = [ _chassis(100, "JN-SN-1", position=0), # Juniper uses position=0,1 (1-based after fallback) _chassis(200, "JN-SN-2", position=1), ] mock_server.vc_inventory_callable(device_id, root_items, {10: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None assert result["is_stack"] is True assert result["member_count"] == 2 class TestDetectVCStackPreferredOverChassis: """When root has both stack and chassis entries, stack index takes priority.""" def test_stack_index_used_not_chassis(self, mock_server): from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 30 mock_server.device_info_response(device_id=device_id, hostname="sw-mixed") # Root has BOTH stack (index=5) and chassis (index=6) root_items = [ { "entPhysicalIndex": 5, "entPhysicalClass": "stack", "entPhysicalName": "Stack-0", "entPhysicalSerialNum": "", "entPhysicalModelName": "", "entPhysicalDescr": "", "entPhysicalContainedIn": 0, }, { "entPhysicalIndex": 6, "entPhysicalClass": "chassis", "entPhysicalName": "Chassis-0", "entPhysicalSerialNum": "", "entPhysicalModelName": "", "entPhysicalDescr": "", "entPhysicalContainedIn": 0, }, ] # Stack index=5 has 2 members, chassis index=6 has 0 children = { 5: [_chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2)], 6: [], # chassis has no children } mock_server.vc_inventory_callable(device_id, root_items, children) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) # Must have detected 2 members (via stack index), not 0 (via chassis index) assert result is not None assert result["member_count"] == 2 class TestDetectVCSingleDevice: """Non-stack device: only 1 chassis child → returns None.""" def test_single_chassis_child_returns_none(self, mock_server): from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 40 mock_server.device_info_response(device_id=device_id, hostname="single-sw") root_items = [_stack_root(index=1)] # Only 1 chassis child → not a VC member_items = [_chassis(100, "SN-ONLY", position=1)] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is None def test_no_stack_or_chassis_root_returns_none(self, mock_server): """Root has only non-stack/chassis entries → returns None.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 41 mock_server.device_info_response(device_id=device_id, hostname="plain-router") root_items = [ { "entPhysicalIndex": 1, "entPhysicalClass": "module", "entPhysicalName": "Main Module", "entPhysicalSerialNum": "SN1", "entPhysicalModelName": "ASR1001-X", "entPhysicalDescr": "ASR1001-X", "entPhysicalContainedIn": 0, } ] # Register root-only, no children needed mock_server.vc_inventory_callable(device_id, root_items, {}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is None class TestDetectVCEdgeCases: """API errors and empty responses.""" def test_empty_root_inventory_returns_none(self, mock_server): from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 50 mock_server.device_info_response(device_id=device_id, hostname="empty-sw") mock_server.vc_inventory_callable(device_id, [], {}) # empty root with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is None def test_api_error_on_root_returns_none(self, mock_server): """500 error on root inventory → returns None.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 51 mock_server.device_info_response(device_id=device_id, hostname="error-sw") # Register 500 for inventory calls mock_server.register(f"/api/v0/inventory/{device_id}", {"status": "error"}, status=500) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is None def test_empty_serial_included_in_members(self, mock_server): """Members with empty entPhysicalSerialNum are included, not skipped.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 52 mock_server.device_info_response(device_id=device_id, hostname="nosn-sw") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "", position=1), # empty serial _chassis(200, "SN-B", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) assert result is not None assert result["member_count"] == 2 assert result["members"][0]["serial"] == "" # empty is preserved def test_device_info_failure_still_detects_vc(self, mock_server): """get_device_info() returning False → detection still works, suggested_name uses fallback.""" from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory api = _make_api(mock_server.url) device_id = 53 # No device_info registered → 404 root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = detect_virtual_chassis_from_inventory(api, device_id) # Should still detect VC even without device info assert result is not None assert result["member_count"] == 2 # Without master name, suggested_name falls back to "Member-{position}" for member in result["members"]: assert member["suggested_name"].startswith("Member-") class TestGetVCDataHTTP: """get_virtual_chassis_data() integrating with mock HTTP server and patched cache.""" def test_cache_miss_fetches_via_http(self, mock_server): """Cache miss triggers detect_virtual_chassis_from_inventory via HTTP.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 60 mock_server.device_info_response(device_id=device_id, hostname="cached-sw") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.return_value = None # cache miss with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = get_virtual_chassis_data(api, device_id) # Should have called cache.set to store result assert mock_cache.set.called assert result is not None assert result["is_stack"] is True assert result["member_count"] == 2 def test_cache_hit_returns_without_http(self, mock_server): """Cache hit returns immediately without making any HTTP calls.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 61 # Include detection_error to match what _clone_virtual_chassis_data adds cached_data = {"is_stack": True, "member_count": 3, "members": [], "detection_error": None} with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.return_value = cached_data result = get_virtual_chassis_data(api, device_id) # cache.set should NOT be called (no new fetch) assert not mock_cache.set.called assert result["is_stack"] is True assert result["member_count"] == 3 def test_force_refresh_fetches_even_if_cached(self, mock_server): """force_refresh=True bypasses cache and fetches from HTTP.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 62 mock_server.device_info_response(device_id=device_id, hostname="refresh-sw") root_items = [_stack_root(index=1)] member_items = [ _chassis(100, "SN-A", position=1), _chassis(200, "SN-B", position=2), ] mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) # Even with cached data, force_refresh should hit the API old_cached = {"is_stack": True, "member_count": 1, "members": [{"serial": "OLD"}], "detection_error": None} with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.return_value = old_cached with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = get_virtual_chassis_data(api, device_id, force_refresh=True) # Should have fetched fresh data, not used old_cached assert result is not None assert result["member_count"] == 2 # new data, not old def test_non_vc_device_returns_empty_dict(self, mock_server): """Single device (not VC) → detect returns None → get_virtual_chassis_data returns empty.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 63 mock_server.device_info_response(device_id=device_id, hostname="single-sw") root_items = [_stack_root(index=1)] member_items = [_chassis(100, "SN-ONLY", position=1)] # only 1 → not VC mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.return_value = None with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = get_virtual_chassis_data(api, device_id) assert result is not None assert result.get("is_stack") is False class TestPrefetchVCHTTP: """prefetch_vc_data_for_devices() fetches multiple devices in batch.""" def test_prefetch_multiple_vc_devices(self, mock_server): """Three VC devices → cache populated for all three.""" from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices api = _make_api(mock_server.url) for dev_id, hostname in [(70, "sw-70"), (71, "sw-71"), (72, "sw-72")]: mock_server.device_info_response(device_id=dev_id, hostname=hostname) root_items = [_stack_root(index=1)] member_items = [ _chassis(100, f"SN-{dev_id}-1", position=1), _chassis(200, f"SN-{dev_id}-2", position=2), ] mock_server.vc_inventory_callable(dev_id, root_items, {1: member_items}) cache_store = {} def mock_cache_set(key, val, timeout=None): cache_store[key] = val def mock_cache_get(key): return cache_store.get(key) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.side_effect = mock_cache_get mock_cache.set.side_effect = mock_cache_set with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): prefetch_vc_data_for_devices(api, [70, 71, 72]) # Cache should have entries for all 3 VC devices assert len(cache_store) >= 3 def test_prefetch_mix_vc_and_single(self, mock_server): """Mix of VC and single devices → VC is cached, single is processed without error.""" from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices api = _make_api(mock_server.url) # Device 80 = 2-member VC mock_server.device_info_response(device_id=80, hostname="sw-stack") root_items_80 = [_stack_root(index=1)] member_items_80 = [_chassis(100, "SN-80-1", position=1), _chassis(200, "SN-80-2", position=2)] mock_server.vc_inventory_callable(80, root_items_80, {1: member_items_80}) # Device 81 = single (no VC) mock_server.device_info_response(device_id=81, hostname="sw-single") root_items_81 = [_stack_root(index=1)] member_items_81 = [_chassis(100, "SN-81", position=1)] # only 1 → not VC mock_server.vc_inventory_callable(81, root_items_81, {1: member_items_81}) cache_store = {} def mock_cache_set(key, val, timeout=None): cache_store[key] = val def mock_cache_get(key): return cache_store.get(key) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.side_effect = mock_cache_get mock_cache.set.side_effect = mock_cache_set with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): prefetch_vc_data_for_devices(api, [80, 81]) # Both the VC device (80) and the non-VC device (81) should be cached. # Non-VC devices get an empty_virtual_chassis_data() cached so prefetch # suppresses repeated API hits on subsequent renders. assert len(cache_store) == 2 class TestNegativeVCCaching: """Negative results (non-stack, API errors) must be cached to suppress repeated hits.""" def test_non_vc_device_result_is_cached(self, mock_server): """Single device (not a stack) → detect returns None → empty result cached.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 200 mock_server.device_info_response(device_id=device_id, hostname="single-sw") root_items = [_stack_root(index=1)] member_items = [_chassis(100, "SN-ONLY", position=1)] # 1 chassis only → not VC mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) cache_store = {} def mock_cache_set(key, val, timeout=None): cache_store[key] = val def mock_cache_get(key): return cache_store.get(key) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.side_effect = mock_cache_get mock_cache.set.side_effect = mock_cache_set with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = get_virtual_chassis_data(api, device_id) assert result is not None assert result.get("is_stack") is False assert result.get("member_count") == 0 # The empty result must have been written to cache so a second call is a hit. assert len(cache_store) == 1 cached = list(cache_store.values())[0] assert cached.get("is_stack") is False def test_api_error_result_is_cached(self, mock_server): """API 500 on inventory → detect returns None → empty result still cached.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 201 # Register a 500 for the root inventory call mock_server.routes[f"/api/v0/inventory/{device_id}"] = (500, {"status": "error", "message": "internal"}) cache_store = {} def mock_cache_set(key, val, timeout=None): cache_store[key] = val def mock_cache_get(key): return cache_store.get(key) with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.side_effect = mock_cache_get mock_cache.set.side_effect = mock_cache_set with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): result = get_virtual_chassis_data(api, device_id) assert result is not None assert result.get("is_stack") is False # Even API failures get cached to suppress repeated hits until TTL expires. assert len(cache_store) == 1 def test_force_refresh_bypasses_negative_cache(self, mock_server): """force_refresh=True re-fetches even when a negative result is cached.""" from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data api = _make_api(mock_server.url) device_id = 202 mock_server.device_info_response(device_id=device_id, hostname="single-sw-202") root_items = [_stack_root(index=1)] member_items = [_chassis(100, "SN-202", position=1)] # 1 chassis → not VC mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) # Pre-populate cache with an empty (negative) result. empty_cached = {"is_stack": False, "member_count": 0, "members": [], "detection_error": None} call_count = {"n": 0} def mock_cache_get(key): return empty_cached # always returns cached negative cache_set_calls = [] def mock_cache_set(key, val, timeout=None): cache_set_calls.append((key, val)) call_count["n"] += 1 with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: mock_cache.get.side_effect = mock_cache_get mock_cache.set.side_effect = mock_cache_set with patch( "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", return_value="{master}-m{position}", ): # Normal call — should use cache, NOT call set again result_cached = get_virtual_chassis_data(api, device_id) assert call_count["n"] == 0 # no new set; cache hit returned # force_refresh=True — must bypass cache and re-fetch + re-cache result_fresh = get_virtual_chassis_data(api, device_id, force_refresh=True) assert call_count["n"] == 1 # set called once for the re-fetch assert result_cached.get("is_stack") is False assert result_fresh.get("is_stack") is False class TestVCPortFetch: """Port fetching for VC master: port names with VC member suffixes.""" def test_ports_with_vc_suffixes_returned_as_is(self, mock_server): """Port names like Gi1/0/1 and Gi2/0/1 preserved from API response.""" api = _make_api(mock_server.url) vc_ports = [ { "port_id": 101, "ifName": "GigabitEthernet1/0/1", "ifDescr": "GigabitEthernet1/0/1", "ifType": "ethernetCsmacd", "ifSpeed": 1_000_000_000, "ifAdminStatus": "up", "ifAlias": "uplink-m1", "ifPhysAddress": "aa:bb:cc:dd:ee:01", "ifMtu": 1500, "ifVlan": 1, "ifTrunk": 0, }, { "port_id": 201, "ifName": "GigabitEthernet2/0/1", "ifDescr": "GigabitEthernet2/0/1", "ifType": "ethernetCsmacd", "ifSpeed": 1_000_000_000, "ifAdminStatus": "up", "ifAlias": "uplink-m2", "ifPhysAddress": "aa:bb:cc:dd:ee:02", "ifMtu": 1500, "ifVlan": 1, "ifTrunk": 0, }, { "port_id": 301, "ifName": "GigabitEthernet1/0/2", "ifDescr": "GigabitEthernet1/0/2", "ifType": "ethernetCsmacd", "ifSpeed": 1_000_000_000, "ifAdminStatus": "down", "ifAlias": "", "ifPhysAddress": "aa:bb:cc:dd:ee:03", "ifMtu": 1500, "ifVlan": 10, "ifTrunk": 0, }, ] mock_server.ports_response(device_id=90, ports=vc_ports) ok, data = api.get_ports(90) assert ok is True names = [p["ifName"] for p in data["ports"]] assert "GigabitEthernet1/0/1" in names assert "GigabitEthernet2/0/1" in names assert "GigabitEthernet1/0/2" in names def test_all_port_fields_preserved(self, mock_server): """ifName, ifDescr, ifAlias, ifSpeed all preserved from LibreNMS response.""" api = _make_api(mock_server.url) ports_data = [ { "port_id": 111, "ifName": "GigabitEthernet1/0/1", "ifDescr": "GigabitEthernet1/0/1", "ifType": "ethernetCsmacd", "ifSpeed": 1_000_000_000, "ifAdminStatus": "up", "ifAlias": "server-link", "ifPhysAddress": "aa:bb:cc:00:00:01", "ifMtu": 9000, "ifVlan": 100, "ifTrunk": 1, }, ] mock_server.ports_response(device_id=91, ports=ports_data) ok, data = api.get_ports(91) assert ok is True port = data["ports"][0] assert port["ifName"] == "GigabitEthernet1/0/1" assert port["ifAlias"] == "server-link" assert port["ifSpeed"] == 1_000_000_000 assert port["ifMtu"] == 9000