Files
netbox-librenms-plugin/netbox_librenms_plugin/tests/test_coverage_mixins.py
Vlastislav Svatek 673e67106e
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
first commit
2026-06-05 10:39:05 +02:00

1023 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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