1023 lines
35 KiB
Python
1023 lines
35 KiB
Python
"""
|
||
Coverage tests for views/mixins.py missing lines.
|
||
|
||
Targets:
|
||
- LibreNMSAPIMixin.get_context_data (lines 277-282): AttributeError fallback path
|
||
- VlanAssignmentMixin.get_vlan_groups_for_device (lines 368-387): region/sitegroup/location/rack branches
|
||
- VlanAssignmentMixin._build_vlan_lookup_maps (lines 406-442)
|
||
- VlanAssignmentMixin._select_most_specific_group (lines 472, 487-490, 500-503, 507-510, 523)
|
||
- VlanAssignmentMixin._get_vlan_groups_for_scope (lines 564-576)
|
||
- VlanAssignmentMixin._find_vlan_in_group (lines 599-600): fallback to any VLAN
|
||
- VlanAssignmentMixin._update_interface_vlan_assignment (lines 634, 643, 653, 666)
|
||
"""
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
|
||
# =============================================================================
|
||
# LibreNMSAPIMixin.get_context_data
|
||
# =============================================================================
|
||
|
||
|
||
class TestLibreNMSAPIMixinGetContextData:
|
||
"""Tests for LibreNMSAPIMixin.get_context_data (lines 275-282)."""
|
||
|
||
def test_get_context_data_super_succeeds(self):
|
||
"""When super().get_context_data() works, it merges with server info."""
|
||
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
|
||
|
||
class FakeBase:
|
||
def get_context_data(self, **kwargs):
|
||
return {"from_super": True, **kwargs}
|
||
|
||
class ConcreteView(LibreNMSAPIMixin, FakeBase):
|
||
pass
|
||
|
||
view = ConcreteView()
|
||
view._librenms_api = MagicMock()
|
||
view._librenms_api.server_key = "default"
|
||
|
||
with patch.object(view, "get_server_info", return_value={"display_name": "Default"}):
|
||
ctx = view.get_context_data(extra="value")
|
||
|
||
assert ctx["from_super"] is True
|
||
assert ctx["extra"] == "value"
|
||
assert "librenms_server_info" in ctx
|
||
assert ctx["librenms_server_info"] == {"display_name": "Default"}
|
||
|
||
def test_get_context_data_attribute_error_falls_back_to_kwargs(self):
|
||
"""When super().get_context_data() raises AttributeError, kwargs used as context."""
|
||
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
|
||
|
||
# object.__new__ ensures no base class has get_context_data,
|
||
# so super() will raise AttributeError inside the method.
|
||
mixin = object.__new__(LibreNMSAPIMixin)
|
||
mixin._librenms_api = MagicMock()
|
||
mixin._librenms_api.server_key = "default"
|
||
|
||
with patch.object(mixin, "get_server_info", return_value={"url": "http://example.com"}):
|
||
ctx = mixin.get_context_data(foo="bar", num=42)
|
||
|
||
assert ctx["foo"] == "bar"
|
||
assert ctx["num"] == 42
|
||
assert "librenms_server_info" in ctx
|
||
assert ctx["librenms_server_info"]["url"] == "http://example.com"
|
||
|
||
def test_get_context_data_empty_kwargs_still_adds_server_info(self):
|
||
"""With no kwargs and AttributeError fallback, server info is still added."""
|
||
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
|
||
|
||
mixin = object.__new__(LibreNMSAPIMixin)
|
||
mixin._librenms_api = MagicMock()
|
||
mixin._librenms_api.server_key = "default"
|
||
|
||
server_info = {"display_name": "Default Server", "is_legacy": True}
|
||
with patch.object(mixin, "get_server_info", return_value=server_info):
|
||
ctx = mixin.get_context_data()
|
||
|
||
assert ctx == {"librenms_server_info": server_info}
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin.get_vlan_groups_for_device – inner branches
|
||
# =============================================================================
|
||
|
||
|
||
class TestGetVlanGroupsForDeviceInnerBranches:
|
||
"""Cover lines 368-387: region, site-group, location, rack branches."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def test_site_with_region_triggers_region_scope_query(self):
|
||
"""When device.site has a region, region-scoped VLAN groups are queried."""
|
||
mixin = self._make_mixin()
|
||
|
||
region = MagicMock()
|
||
region.parent = None
|
||
|
||
site = MagicMock()
|
||
site.pk = 1
|
||
site.region = region
|
||
site.group = None
|
||
|
||
device = MagicMock()
|
||
device.site = site
|
||
device.location = None
|
||
device.rack = None
|
||
|
||
scope_calls = []
|
||
|
||
def fake_scope(model_cls, objects):
|
||
scope_calls.append(model_cls)
|
||
return []
|
||
|
||
with (
|
||
patch("dcim.models.Site") as MockSite,
|
||
patch("dcim.models.Region") as MockRegion,
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Rack"),
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
patch.object(mixin, "_get_vlan_groups_for_scope", side_effect=fake_scope),
|
||
patch.object(mixin, "_get_ancestors", return_value=[region]),
|
||
):
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin.get_vlan_groups_for_device(device)
|
||
|
||
# Both Site and Region model classes should have been passed to _get_vlan_groups_for_scope
|
||
assert MockSite in scope_calls, "Site should be queried for VLAN groups"
|
||
assert MockRegion in scope_calls, "Region should be queried for VLAN groups"
|
||
|
||
def test_site_with_group_triggers_site_group_scope_query(self):
|
||
"""When device.site has a group, site-group-scoped VLAN groups are queried."""
|
||
mixin = self._make_mixin()
|
||
|
||
site_group = MagicMock()
|
||
site_group.parent = None
|
||
|
||
site = MagicMock()
|
||
site.pk = 5
|
||
site.region = None
|
||
site.group = site_group
|
||
|
||
device = MagicMock()
|
||
device.site = site
|
||
device.location = None
|
||
device.rack = None
|
||
|
||
scope_calls = []
|
||
|
||
def fake_scope(model_cls, objects):
|
||
scope_calls.append((model_cls, list(objects)))
|
||
return []
|
||
|
||
with (
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.Region"),
|
||
patch("dcim.models.SiteGroup") as MockSiteGroup,
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Rack"),
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
patch.object(mixin, "_get_vlan_groups_for_scope", side_effect=fake_scope),
|
||
patch.object(mixin, "_get_ancestors", return_value=[site_group]),
|
||
):
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin.get_vlan_groups_for_device(device)
|
||
|
||
# SiteGroup ancestors should have been processed
|
||
assert len(scope_calls) >= 1
|
||
# Verify the SiteGroup model class was passed to _get_vlan_groups_for_scope
|
||
assert any(c[0] is MockSiteGroup for c in scope_calls)
|
||
|
||
def test_device_with_location_triggers_location_scope_query(self):
|
||
"""When device.location is set, location-scoped VLAN groups are queried."""
|
||
mixin = self._make_mixin()
|
||
|
||
location = MagicMock()
|
||
location.parent = None
|
||
|
||
device = MagicMock()
|
||
device.site = None
|
||
device.location = location
|
||
device.rack = None
|
||
|
||
scope_calls = []
|
||
|
||
def fake_scope(model_cls, objects):
|
||
scope_calls.append((model_cls, list(objects)))
|
||
return []
|
||
|
||
with (
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.Region"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Location") as MockLocation,
|
||
patch("dcim.models.Rack"),
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
patch.object(mixin, "_get_vlan_groups_for_scope", side_effect=fake_scope),
|
||
patch.object(mixin, "_get_ancestors", return_value=[location]),
|
||
):
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin.get_vlan_groups_for_device(device)
|
||
|
||
assert len(scope_calls) >= 1
|
||
# Verify the Location model class was passed to _get_vlan_groups_for_scope
|
||
assert any(c[0] is MockLocation for c in scope_calls)
|
||
|
||
def test_device_with_rack_triggers_rack_scope_query(self):
|
||
"""When device.rack is set, rack-scoped VLAN groups are queried."""
|
||
mixin = self._make_mixin()
|
||
|
||
rack = MagicMock()
|
||
rack.pk = 7
|
||
|
||
device = MagicMock()
|
||
device.site = None
|
||
device.location = None
|
||
device.rack = rack
|
||
|
||
scope_calls = []
|
||
|
||
def fake_scope(model_cls, objects):
|
||
scope_calls.append((model_cls, list(objects)))
|
||
return []
|
||
|
||
with (
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.Region"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Rack") as MockRack,
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
patch.object(mixin, "_get_vlan_groups_for_scope", side_effect=fake_scope),
|
||
):
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin.get_vlan_groups_for_device(device)
|
||
|
||
# Rack must appear in the objects for one of the calls
|
||
rack_calls = [objects for (_cls, objects) in scope_calls if rack in objects]
|
||
assert len(rack_calls) >= 1
|
||
# Verify the Rack model class was passed to _get_vlan_groups_for_scope
|
||
assert any(c[0] is MockRack for c in scope_calls)
|
||
|
||
def test_all_scope_branches_combined(self):
|
||
"""Device with site+region+sitegroup+location+rack hits all scope branches."""
|
||
mixin = self._make_mixin()
|
||
|
||
region = MagicMock()
|
||
region.parent = None
|
||
|
||
site_group = MagicMock()
|
||
site_group.parent = None
|
||
|
||
location = MagicMock()
|
||
location.parent = None
|
||
|
||
rack = MagicMock()
|
||
rack.pk = 3
|
||
|
||
site = MagicMock()
|
||
site.pk = 1
|
||
site.region = region
|
||
site.group = site_group
|
||
|
||
device = MagicMock()
|
||
device.site = site
|
||
device.location = location
|
||
device.rack = rack
|
||
|
||
scope_calls_by_class = []
|
||
|
||
def fake_scope(model_cls, objects):
|
||
scope_calls_by_class.append(model_cls)
|
||
return []
|
||
|
||
site_group_ancestor = MagicMock()
|
||
site_group_ancestor.parent = None
|
||
location_ancestor = MagicMock()
|
||
location_ancestor.parent = None
|
||
|
||
def fake_ancestors(obj):
|
||
# Return distinct ancestors per branch so site-group and location paths are exercised
|
||
if obj is site_group:
|
||
return [site_group_ancestor]
|
||
if obj is location:
|
||
return [location_ancestor]
|
||
return [region]
|
||
|
||
with (
|
||
patch("dcim.models.Site") as MockSite,
|
||
patch("dcim.models.Region") as MockRegion,
|
||
patch("dcim.models.SiteGroup") as MockSiteGroup,
|
||
patch("dcim.models.Location") as MockLocation,
|
||
patch("dcim.models.Rack") as MockRack,
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
patch.object(mixin, "_get_vlan_groups_for_scope", side_effect=fake_scope),
|
||
patch.object(mixin, "_get_ancestors", side_effect=fake_ancestors),
|
||
):
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin.get_vlan_groups_for_device(device)
|
||
|
||
# All 5 scope types must have been queried
|
||
assert MockSite in scope_calls_by_class, "Site branch not hit"
|
||
assert MockRegion in scope_calls_by_class, "Region branch not hit"
|
||
assert MockSiteGroup in scope_calls_by_class, "SiteGroup branch not hit"
|
||
assert MockLocation in scope_calls_by_class, "Location branch not hit"
|
||
assert MockRack in scope_calls_by_class, "Rack branch not hit"
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin._build_vlan_lookup_maps
|
||
# =============================================================================
|
||
|
||
|
||
class TestBuildVlanLookupMaps:
|
||
"""Tests for VlanAssignmentMixin._build_vlan_lookup_maps (lines 406-442)."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def test_empty_groups_returns_empty_maps(self):
|
||
"""No groups and no global VLANs produces empty maps."""
|
||
mixin = self._make_mixin()
|
||
|
||
with patch("ipam.models.VLAN") as MockVLAN:
|
||
MockVLAN.objects.filter.return_value.select_related.return_value = []
|
||
maps = mixin._build_vlan_lookup_maps([])
|
||
|
||
assert maps["vid_to_groups"] == {}
|
||
assert maps["vid_group_to_vlan"] == {}
|
||
assert maps["vid_to_vlans"] == {}
|
||
assert maps["vid_name_to_vlan"] == {}
|
||
|
||
def test_group_vlan_indexed_in_all_maps(self):
|
||
"""A VLAN within a group is added to all four lookup structures."""
|
||
mixin = self._make_mixin()
|
||
|
||
group = MagicMock()
|
||
group.pk = 10
|
||
|
||
vlan = MagicMock()
|
||
vlan.vid = 100
|
||
vlan.group = group
|
||
vlan.name = "CORP-DATA"
|
||
|
||
with patch("ipam.models.VLAN") as MockVLAN:
|
||
# First call = group VLANs (needs .select_related()), second call = global VLANs
|
||
first_qs = MagicMock()
|
||
first_qs.select_related.return_value = [vlan]
|
||
MockVLAN.objects.filter.side_effect = [first_qs, []]
|
||
maps = mixin._build_vlan_lookup_maps([group])
|
||
|
||
assert 100 in maps["vid_to_groups"]
|
||
assert group in maps["vid_to_groups"][100]
|
||
assert maps["vid_group_to_vlan"][(100, 10)] is vlan
|
||
assert vlan in maps["vid_to_vlans"][100]
|
||
assert maps["vid_name_to_vlan"][(100, "CORP-DATA")] is vlan
|
||
|
||
def test_global_vlan_indexed_with_none_group(self):
|
||
"""A global VLAN (no group) uses None as group key."""
|
||
mixin = self._make_mixin()
|
||
|
||
vlan = MagicMock()
|
||
vlan.vid = 200
|
||
vlan.group = None
|
||
vlan.name = "MGMT"
|
||
|
||
with patch("ipam.models.VLAN") as MockVLAN:
|
||
first_qs = MagicMock()
|
||
first_qs.select_related.return_value = []
|
||
MockVLAN.objects.filter.side_effect = [first_qs, [vlan]]
|
||
maps = mixin._build_vlan_lookup_maps([])
|
||
|
||
assert maps["vid_group_to_vlan"][(200, None)] is vlan
|
||
assert vlan in maps["vid_to_vlans"][200]
|
||
# Global VLANs should not appear in vid_to_groups
|
||
assert 200 not in maps["vid_to_groups"]
|
||
|
||
def test_multiple_groups_same_vid_both_tracked(self):
|
||
"""Same VID in two groups: both groups appear in vid_to_groups."""
|
||
mixin = self._make_mixin()
|
||
|
||
group_a = MagicMock()
|
||
group_a.pk = 1
|
||
group_b = MagicMock()
|
||
group_b.pk = 2
|
||
|
||
vlan_a = MagicMock()
|
||
vlan_a.vid = 50
|
||
vlan_a.group = group_a
|
||
vlan_a.name = "VLAN50-A"
|
||
|
||
vlan_b = MagicMock()
|
||
vlan_b.vid = 50
|
||
vlan_b.group = group_b
|
||
vlan_b.name = "VLAN50-B"
|
||
|
||
with patch("ipam.models.VLAN") as MockVLAN:
|
||
first_qs = MagicMock()
|
||
first_qs.select_related.return_value = [vlan_a, vlan_b]
|
||
MockVLAN.objects.filter.side_effect = [first_qs, []]
|
||
maps = mixin._build_vlan_lookup_maps([group_a, group_b])
|
||
|
||
assert group_a in maps["vid_to_groups"][50]
|
||
assert group_b in maps["vid_to_groups"][50]
|
||
assert maps["vid_group_to_vlan"][(50, 1)] is vlan_a
|
||
assert maps["vid_group_to_vlan"][(50, 2)] is vlan_b
|
||
|
||
def test_filter_called_with_group_pks(self):
|
||
"""_build_vlan_lookup_maps queries VLAN with the correct group PKs."""
|
||
mixin = self._make_mixin()
|
||
|
||
group1 = MagicMock()
|
||
group1.pk = 11
|
||
group2 = MagicMock()
|
||
group2.pk = 22
|
||
|
||
with patch("ipam.models.VLAN") as MockVLAN:
|
||
MockVLAN.objects.filter.return_value.select_related.return_value = []
|
||
mixin._build_vlan_lookup_maps([group1, group2])
|
||
|
||
# First filter call should include the group PKs
|
||
first_call = MockVLAN.objects.filter.call_args_list[0]
|
||
assert "group__pk__in" in first_call[1]
|
||
assert set(first_call[1]["group__pk__in"]) == {11, 22}
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin._select_most_specific_group – uncovered priority paths
|
||
# =============================================================================
|
||
|
||
|
||
class TestSelectMostSpecificGroupPriorityPaths:
|
||
"""Tests for _select_most_specific_group priority calculation paths (lines 472-539)."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def test_returns_none_when_groups_empty(self):
|
||
"""Returns None immediately when groups list is empty (line 472)."""
|
||
mixin = self._make_mixin()
|
||
device = MagicMock()
|
||
result = mixin._select_most_specific_group([], device)
|
||
assert result is None
|
||
|
||
def test_returns_none_when_device_is_none(self):
|
||
"""Returns None immediately when device is None (line 472)."""
|
||
mixin = self._make_mixin()
|
||
group = MagicMock()
|
||
result = mixin._select_most_specific_group([group], None)
|
||
assert result is None
|
||
|
||
def test_rack_priority_path_executed(self):
|
||
"""Rack group beats site and global groups (highest priority)."""
|
||
mixin = self._make_mixin()
|
||
|
||
rack = MagicMock()
|
||
rack.pk = 5
|
||
|
||
site = MagicMock()
|
||
site.pk = 1
|
||
site.group = None
|
||
site.region = None
|
||
|
||
rack_ct = MagicMock()
|
||
rack_ct.pk = 10
|
||
site_ct = MagicMock()
|
||
site_ct.pk = 11
|
||
|
||
rack_group = MagicMock()
|
||
rack_group.scope_type = rack_ct
|
||
rack_group.scope_id = 5
|
||
|
||
site_group_grp = MagicMock()
|
||
site_group_grp.scope_type = site_ct
|
||
site_group_grp.scope_id = 1
|
||
|
||
global_group = MagicMock()
|
||
global_group.scope_type = None
|
||
|
||
device = MagicMock()
|
||
device.rack = rack
|
||
device.location = None
|
||
device.site = site
|
||
|
||
def ct_for_model(model):
|
||
import dcim.models as dm
|
||
|
||
if model is dm.Rack:
|
||
return rack_ct
|
||
if model is dm.Site:
|
||
return site_ct
|
||
return MagicMock(pk=99)
|
||
|
||
with (
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
):
|
||
MockCT.objects.get_for_model.side_effect = ct_for_model
|
||
result = mixin._select_most_specific_group([site_group_grp, global_group, rack_group], device)
|
||
|
||
# rack_group must win over site and global groups
|
||
assert result is rack_group
|
||
|
||
def test_location_priority_path_executed(self):
|
||
"""Device with location executes location priority path (lines 487-490)."""
|
||
mixin = self._make_mixin()
|
||
|
||
parent_loc = MagicMock()
|
||
parent_loc.pk = 20
|
||
parent_loc.parent = None
|
||
|
||
child_loc = MagicMock()
|
||
child_loc.pk = 21
|
||
child_loc.parent = parent_loc
|
||
|
||
loc_ct = MagicMock()
|
||
loc_ct.pk = 2
|
||
|
||
child_group = MagicMock()
|
||
child_group.scope_type = loc_ct
|
||
child_group.scope_id = 21
|
||
|
||
parent_group = MagicMock()
|
||
parent_group.scope_type = loc_ct
|
||
parent_group.scope_id = 20
|
||
|
||
device = MagicMock()
|
||
device.rack = None
|
||
device.location = child_loc
|
||
device.site = None
|
||
|
||
with (
|
||
patch("dcim.models.Rack"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Region"),
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch.object(mixin, "_get_ancestors", return_value=[child_loc, parent_loc]),
|
||
):
|
||
MockCT.objects.get_for_model.return_value = loc_ct
|
||
result = mixin._select_most_specific_group([child_group, parent_group], device)
|
||
|
||
# Child location (first in ancestry) has lower priority number = more specific
|
||
assert result is child_group
|
||
|
||
def test_site_priority_path_executed(self):
|
||
"""Device with site (no rack/location) executes site priority path (lines 500-503)."""
|
||
mixin = self._make_mixin()
|
||
|
||
site = MagicMock()
|
||
site.pk = 7
|
||
site.region = None
|
||
site.group = None
|
||
|
||
site_ct = MagicMock()
|
||
site_ct.pk = 3
|
||
|
||
site_group = MagicMock()
|
||
site_group.scope_type = site_ct
|
||
site_group.scope_id = 7
|
||
|
||
device = MagicMock()
|
||
device.rack = None
|
||
device.location = None
|
||
device.site = site
|
||
|
||
with (
|
||
patch("dcim.models.Rack"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Region"),
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
):
|
||
MockCT.objects.get_for_model.return_value = site_ct
|
||
result = mixin._select_most_specific_group([site_group], device)
|
||
|
||
assert result is site_group
|
||
|
||
def test_region_priority_path_executed(self):
|
||
"""Device with site.region executes region hierarchy path (lines 507-510)."""
|
||
mixin = self._make_mixin()
|
||
|
||
region = MagicMock()
|
||
region.pk = 15
|
||
region.parent = None
|
||
|
||
site = MagicMock()
|
||
site.pk = 8
|
||
site.region = region
|
||
site.group = None
|
||
|
||
region_ct = MagicMock()
|
||
region_ct.pk = 4
|
||
|
||
site_ct = MagicMock()
|
||
site_ct.pk = 3
|
||
|
||
region_group = MagicMock()
|
||
region_group.scope_type = region_ct
|
||
region_group.scope_id = 15
|
||
|
||
device = MagicMock()
|
||
device.rack = None
|
||
device.location = None
|
||
device.site = site
|
||
|
||
with (
|
||
patch("dcim.models.Rack") as MockRack,
|
||
patch("dcim.models.Location") as MockLocation,
|
||
patch("dcim.models.Site") as MockSite,
|
||
patch("dcim.models.SiteGroup") as MockSiteGroup,
|
||
patch("dcim.models.Region") as MockRegion,
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch.object(mixin, "_get_ancestors", return_value=[region]),
|
||
):
|
||
ct_map = {
|
||
id(MockRack): MagicMock(pk=99),
|
||
id(MockLocation): MagicMock(pk=2),
|
||
id(MockSite): site_ct,
|
||
id(MockSiteGroup): MagicMock(pk=5),
|
||
id(MockRegion): region_ct,
|
||
}
|
||
MockCT.objects.get_for_model.side_effect = lambda m: ct_map[id(m)]
|
||
result = mixin._select_most_specific_group([region_group], device)
|
||
|
||
assert result is region_group
|
||
|
||
def test_global_scope_group_lowest_priority(self):
|
||
"""Global scope group (scope_type=None) gets global_priority (line 523)."""
|
||
mixin = self._make_mixin()
|
||
|
||
global_group = MagicMock()
|
||
global_group.scope_type = None # global
|
||
|
||
device = MagicMock()
|
||
device.rack = None
|
||
device.location = None
|
||
device.site = None
|
||
|
||
with (
|
||
patch("dcim.models.Rack"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Region"),
|
||
patch("django.contrib.contenttypes.models.ContentType"),
|
||
):
|
||
result = mixin._select_most_specific_group([global_group], device)
|
||
|
||
assert result is global_group
|
||
|
||
def test_site_group_priority_path_executed(self):
|
||
"""Device with site.group executes site-group hierarchy path."""
|
||
mixin = self._make_mixin()
|
||
|
||
sg = MagicMock()
|
||
sg.pk = 30
|
||
sg.parent = None
|
||
|
||
site = MagicMock()
|
||
site.pk = 9
|
||
site.region = None
|
||
site.group = sg
|
||
|
||
sg_ct = MagicMock()
|
||
sg_ct.pk = 5
|
||
|
||
site_ct = MagicMock()
|
||
site_ct.pk = 3
|
||
|
||
sg_group = MagicMock()
|
||
sg_group.scope_type = sg_ct
|
||
sg_group.scope_id = 30
|
||
|
||
# Competing global group (less specific)
|
||
global_group = MagicMock()
|
||
global_group.scope_type = None
|
||
|
||
device = MagicMock()
|
||
device.rack = None
|
||
device.location = None
|
||
device.site = site
|
||
|
||
def mock_get_for_model(model_cls):
|
||
name = str(getattr(model_cls, "__name__", model_cls))
|
||
if "SiteGroup" in name:
|
||
return sg_ct
|
||
return site_ct
|
||
|
||
with (
|
||
patch("dcim.models.Rack"),
|
||
patch("dcim.models.Location"),
|
||
patch("dcim.models.Site"),
|
||
patch("dcim.models.SiteGroup"),
|
||
patch("dcim.models.Region"),
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch.object(mixin, "_get_ancestors", return_value=[sg]),
|
||
):
|
||
MockCT.objects.get_for_model.side_effect = mock_get_for_model
|
||
result = mixin._select_most_specific_group([global_group, sg_group], device)
|
||
|
||
# site-group-scoped group wins over global group
|
||
assert result is sg_group
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin._get_vlan_groups_for_scope
|
||
# =============================================================================
|
||
|
||
|
||
class TestGetVlanGroupsForScope:
|
||
"""Tests for VlanAssignmentMixin._get_vlan_groups_for_scope (lines 564-576)."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def test_empty_objects_returns_none_queryset(self):
|
||
"""Empty objects list → VLANGroup.objects.none() (line 568)."""
|
||
mixin = self._make_mixin()
|
||
|
||
with (
|
||
patch("django.contrib.contenttypes.models.ContentType"),
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
):
|
||
MockVLANGroup.objects.none.return_value = []
|
||
mixin._get_vlan_groups_for_scope(MagicMock(), [])
|
||
|
||
MockVLANGroup.objects.none.assert_called_once()
|
||
|
||
def test_all_none_pks_returns_none_queryset(self):
|
||
"""Objects with only None PKs → VLANGroup.objects.none() (line 574)."""
|
||
mixin = self._make_mixin()
|
||
|
||
obj = MagicMock()
|
||
obj.pk = None
|
||
|
||
with (
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
):
|
||
MockCT.objects.get_for_model.return_value = MagicMock()
|
||
MockVLANGroup.objects.none.return_value = []
|
||
mixin._get_vlan_groups_for_scope(MagicMock(), [obj])
|
||
|
||
MockVLANGroup.objects.none.assert_called_once()
|
||
|
||
def test_valid_objects_queries_vlan_groups(self):
|
||
"""Valid objects list queries VLANGroup with correct scope args (line 576)."""
|
||
mixin = self._make_mixin()
|
||
|
||
obj = MagicMock()
|
||
obj.pk = 10
|
||
|
||
ct = MagicMock()
|
||
ct.pk = 99
|
||
expected = [MagicMock()]
|
||
|
||
with (
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
):
|
||
MockCT.objects.get_for_model.return_value = ct
|
||
MockVLANGroup.objects.filter.return_value = expected
|
||
result = mixin._get_vlan_groups_for_scope(MagicMock(), [obj])
|
||
|
||
MockVLANGroup.objects.filter.assert_called_once_with(scope_type=ct, scope_id__in=[10])
|
||
assert result is expected
|
||
|
||
def test_mixed_none_and_valid_pks_excludes_none(self):
|
||
"""Objects with mixed None/valid PKs: only valid PKs used in filter."""
|
||
mixin = self._make_mixin()
|
||
|
||
obj_none = MagicMock()
|
||
obj_none.pk = None
|
||
obj_valid = MagicMock()
|
||
obj_valid.pk = 5
|
||
|
||
ct = MagicMock()
|
||
|
||
with (
|
||
patch("django.contrib.contenttypes.models.ContentType") as MockCT,
|
||
patch("ipam.models.VLANGroup") as MockVLANGroup,
|
||
):
|
||
MockCT.objects.get_for_model.return_value = ct
|
||
MockVLANGroup.objects.filter.return_value = []
|
||
mixin._get_vlan_groups_for_scope(MagicMock(), [obj_none, obj_valid])
|
||
|
||
call_kwargs = MockVLANGroup.objects.filter.call_args[1]
|
||
assert call_kwargs["scope_id__in"] == [5]
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin._find_vlan_in_group – fallback to any VLAN
|
||
# =============================================================================
|
||
|
||
|
||
class TestFindVlanInGroupFallback:
|
||
"""Tests for _find_vlan_in_group fallback path (lines 607-609)."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def test_fallback_to_first_vlan_when_no_group_or_global_match(self):
|
||
"""When no group or global VLAN exists for a VID, first from vid_to_vlans is returned."""
|
||
mixin = self._make_mixin()
|
||
|
||
any_vlan = MagicMock()
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {}, # no group or global match
|
||
"vid_to_vlans": {100: [any_vlan]},
|
||
}
|
||
|
||
result = mixin._find_vlan_in_group(100, None, lookup_maps)
|
||
|
||
assert result is any_vlan
|
||
|
||
def test_returns_none_when_vid_not_in_vid_to_vlans(self):
|
||
"""Returns None when VID has no entries at all."""
|
||
mixin = self._make_mixin()
|
||
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {},
|
||
"vid_to_vlans": {},
|
||
}
|
||
|
||
result = mixin._find_vlan_in_group(999, None, lookup_maps)
|
||
assert result is None
|
||
|
||
def test_invalid_group_id_skips_group_lookup_and_falls_back(self):
|
||
"""Non-integer vlan_group_id raises ValueError → falls back to global/any."""
|
||
mixin = self._make_mixin()
|
||
|
||
global_vlan = MagicMock()
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {(100, None): global_vlan},
|
||
"vid_to_vlans": {100: [global_vlan]},
|
||
}
|
||
|
||
result = mixin._find_vlan_in_group(100, "not-a-number", lookup_maps)
|
||
|
||
assert result is global_vlan
|
||
|
||
|
||
# =============================================================================
|
||
# VlanAssignmentMixin._update_interface_vlan_assignment – uncovered branches
|
||
# =============================================================================
|
||
|
||
|
||
class TestUpdateInterfaceVlanAssignmentBranches:
|
||
"""Cover lines 634 (access), 643 (empty), 653 (untagged set), 666 (clear untagged)."""
|
||
|
||
def _make_mixin(self):
|
||
from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin
|
||
|
||
return object.__new__(VlanAssignmentMixin)
|
||
|
||
def _make_interface(self):
|
||
iface = MagicMock()
|
||
iface.tagged_vlans = MagicMock()
|
||
return iface
|
||
|
||
def test_access_mode_set_for_untagged_only_no_tagged(self):
|
||
"""Sets interface.mode = 'access' when only untagged VID present (line 634)."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
vlan = MagicMock()
|
||
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {(100, None): vlan},
|
||
"vid_to_vlans": {100: [vlan]},
|
||
}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": 100, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
assert iface.mode == "access"
|
||
assert result["mode_set"] == "access"
|
||
|
||
def test_empty_mode_set_when_no_vlans_at_all(self):
|
||
"""Sets interface.mode = '' when no untagged or tagged VLANs (line 643)."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
|
||
lookup_maps = {"vid_group_to_vlan": {}, "vid_to_vlans": {}}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": None, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
assert iface.mode == ""
|
||
assert result["mode_set"] == ""
|
||
|
||
def test_untagged_vlan_assigned_to_interface_when_found(self):
|
||
"""interface.untagged_vlan is set to the resolved VLAN object (line 653)."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
vlan = MagicMock()
|
||
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {(200, None): vlan},
|
||
"vid_to_vlans": {200: [vlan]},
|
||
}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": 200, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
assert iface.untagged_vlan is vlan
|
||
assert result["untagged_set"] is vlan
|
||
iface.save.assert_called()
|
||
|
||
def test_untagged_vlan_set_none_when_no_untagged_vid(self):
|
||
"""interface.untagged_vlan = None when untagged_vid is None (line 666)."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
|
||
lookup_maps = {"vid_group_to_vlan": {}, "vid_to_vlans": {}}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": None, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
assert iface.untagged_vlan is None
|
||
assert result["untagged_set"] is None
|
||
iface.save.assert_called()
|
||
|
||
def test_tagged_vlans_cleared_when_no_tagged_vids(self):
|
||
"""tagged_vlans.clear() called when tagged_vlans list is empty."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
|
||
lookup_maps = {"vid_group_to_vlan": {}, "vid_to_vlans": {}}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": None, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
iface.tagged_vlans.clear.assert_called_once()
|
||
assert result["tagged_set"] == []
|
||
|
||
def test_backward_compat_single_group_id_string(self):
|
||
"""Non-dict vlan_group_map (legacy single group ID) is handled correctly."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
vlan = MagicMock()
|
||
|
||
lookup_maps = {
|
||
"vid_group_to_vlan": {(100, 5): vlan},
|
||
"vid_to_vlans": {100: [vlan]},
|
||
}
|
||
|
||
# Pass a string (backward compat for single group ID)
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": 100, "tagged_vlans": []},
|
||
"5", # non-dict, single group id
|
||
lookup_maps,
|
||
)
|
||
|
||
assert result["untagged_set"] is vlan
|
||
|
||
def test_missing_untagged_vlan_added_to_missing_list(self):
|
||
"""If untagged VID not found, it's in missing_vlans and untagged_vlan stays None."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
|
||
lookup_maps = {"vid_group_to_vlan": {}, "vid_to_vlans": {}}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": 999, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
assert 999 in result["missing_vlans"]
|
||
assert iface.untagged_vlan is None
|
||
|
||
def test_return_dict_has_all_keys(self):
|
||
"""Return dict always contains mode_set, untagged_set, tagged_set, missing_vlans."""
|
||
mixin = self._make_mixin()
|
||
iface = self._make_interface()
|
||
|
||
lookup_maps = {"vid_group_to_vlan": {}, "vid_to_vlans": {}}
|
||
|
||
result = mixin._update_interface_vlan_assignment(
|
||
iface,
|
||
{"untagged_vlan": None, "tagged_vlans": []},
|
||
{},
|
||
lookup_maps,
|
||
)
|
||
|
||
for key in ("mode_set", "untagged_set", "tagged_set", "missing_vlans"):
|
||
assert key in result
|