first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,854 @@
"""
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