Added VM tests and tag tests

This commit is contained in:
TheNetworkGuy
2026-02-19 12:38:29 +00:00
parent 02a5617bc8
commit a4d5fda5e3
2 changed files with 662 additions and 11 deletions
+378 -11
View File
@@ -1,6 +1,7 @@
"""Tests for the core sync module.""" """Tests for the core sync module."""
import unittest import unittest
from typing import ClassVar
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ConnectionError as RequestsConnectionError
@@ -81,7 +82,8 @@ class MockNetboxDevice:
# Setup device type with proper structure # Setup device type with proper structure
if device_type is None: if device_type is None:
self.device_type = MagicMock() self.device_type = MagicMock()
self.device_type.custom_fields = {"zabbix_template": "TestTemplate"} self.device_type.custom_fields = {
"zabbix_template": "TestTemplate"}
self.device_type.manufacturer = MagicMock() self.device_type.manufacturer = MagicMock()
self.device_type.manufacturer.name = "TestManufacturer" self.device_type.manufacturer.name = "TestManufacturer"
self.device_type.display = "Test Device Type" self.device_type.display = "Test Device Type"
@@ -108,7 +110,11 @@ class MockNetboxDevice:
class MockNetboxVM: class MockNetboxVM:
"""Mock NetBox virtual machine object.""" """Mock NetBox virtual machine object.
Mirrors the real NetBox API response structure so the full VirtualMachine
pipeline runs without mocking the class itself.
"""
def __init__( def __init__(
self, self,
@@ -119,15 +125,77 @@ class MockNetboxVM:
config_context=None, config_context=None,
site=None, site=None,
primary_ip=None, primary_ip=None,
role=None,
cluster=None,
tenant=None,
platform=None,
tags=None,
): ):
self.id = vm_id self.id = vm_id
self.name = name self.name = name
self.status = MagicMock() self.status = MagicMock()
self.status.label = status_label self.status.label = status_label
self.status.value = status_label.lower()
self.custom_fields = {"zabbix_hostid": zabbix_hostid} self.custom_fields = {"zabbix_hostid": zabbix_hostid}
self.config_context = config_context or {} # Default config_context includes a template so the VM is not skipped
self.site = site self.config_context = (
self.primary_ip = primary_ip config_context
if config_context is not None
else {"zabbix": {"templates": ["TestTemplate"]}}
)
self.tenant = tenant
self.platform = platform
self.serial = ""
self.description = ""
self.comments = ""
self.vcpus = None
self.memory = None
self.disk = None
self.virtual_chassis = None
self.tags = tags or []
self.oob_ip = None
# Setup site
if site is None:
self.site = MagicMock()
self.site.name = "TestSite"
self.site.slug = "testsite"
self.site.region = None
self.site.group = None
else:
self.site = site
# Setup primary IP
if primary_ip is None:
self.primary_ip = MagicMock()
self.primary_ip.address = "192.168.1.1/24"
else:
self.primary_ip = primary_ip
self.primary_ip4 = self.primary_ip
self.primary_ip6 = None
# Setup role
if role is None:
mock_role = MagicMock()
mock_role.name = "Switch"
mock_role.slug = "switch"
self.role = mock_role
else:
self.role = role
# Setup cluster
if cluster is None:
mock_cluster = MagicMock()
mock_cluster.name = "TestCluster"
mock_cluster_type = MagicMock()
mock_cluster_type.name = "TestClusterType"
mock_cluster.type = mock_cluster_type
self.cluster = mock_cluster
else:
self.cluster = cluster
def save(self):
"""Mock save method."""
class TestSyncNetboxConnection(unittest.TestCase): class TestSyncNetboxConnection(unittest.TestCase):
@@ -305,7 +373,8 @@ class TestSyncDeviceProcessing(unittest.TestCase):
mock_zabbix = MagicMock() mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = version mock_zabbix.version = version
mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.hostgroup.get.return_value = [
{"groupid": "1", "name": "TestGroup"}]
mock_zabbix.template.get.return_value = [ mock_zabbix.template.get.return_value = [
{"templateid": "1", "name": "TestTemplate"} {"templateid": "1", "name": "TestTemplate"}
] ]
@@ -431,7 +500,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase):
mock_zabbix.version = "6.0" mock_zabbix.version = "6.0"
mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = [] mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] mock_zabbix.proxy.get.return_value = [
{"proxyid": "1", "host": "proxy1"}]
syncer = Sync() syncer = Sync()
syncer.connect( syncer.connect(
@@ -458,7 +528,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase):
mock_zabbix.version = "7.0" mock_zabbix.version = "7.0"
mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.hostgroup.get.return_value = []
mock_zabbix.template.get.return_value = [] mock_zabbix.template.get.return_value = []
mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxy.get.return_value = [
{"proxyid": "1", "name": "proxy1"}]
mock_zabbix.proxygroup.get.return_value = [] mock_zabbix.proxygroup.get.return_value = []
syncer = Sync() syncer = Sync()
@@ -653,7 +724,8 @@ class TestDeviceHandeling(unittest.TestCase):
mock_zabbix = MagicMock() mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = version mock_zabbix.version = version
mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.hostgroup.get.return_value = [
{"groupid": "1", "name": "TestGroup"}]
mock_zabbix.template.get.return_value = [ mock_zabbix.template.get.return_value = [
{"templateid": "1", "name": "TestTemplate"} {"templateid": "1", "name": "TestTemplate"}
] ]
@@ -754,7 +826,8 @@ class TestDeviceHandeling(unittest.TestCase):
# Verify host was created with the config context template, not the custom field one # Verify host was created with the config context template, not the custom field one
mock_zabbix.host.create.assert_called_once() mock_zabbix.host.create.assert_called_once()
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) self.assertEqual(create_call_kwargs["templates"], [
{"templateid": "2"}])
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi") @patch("netbox_zabbix_sync.modules.core.nbapi")
@@ -802,7 +875,8 @@ class TestDeviceHandeling(unittest.TestCase):
# Config context overrides the custom field - only "ContextTemplate" should be used # Config context overrides the custom field - only "ContextTemplate" should be used
mock_zabbix.host.create.assert_called_once() mock_zabbix.host.create.assert_called_once()
create_call_kwargs = mock_zabbix.host.create.call_args.kwargs create_call_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) self.assertEqual(create_call_kwargs["templates"], [
{"templateid": "2"}])
# Verify the custom field template was NOT used # Verify the custom field template was NOT used
self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"]) self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"])
@@ -1122,3 +1196,296 @@ class TestDeviceStatusHandling(unittest.TestCase):
syncer.start() syncer.start()
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1") mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1")
class TestVMStatusHandling(unittest.TestCase):
"""
Mirrors TestDeviceStatusHandling for VirtualMachine objects.
Validates the VM sync loop in core.py using real VirtualMachine instances
(not mocked) for the same 8 status scenarios.
"""
# Hostgroup produced by vm_hostgroup_format "site/role" with default MockNetboxVM values.
EXPECTED_HOSTGROUP = "TestSite/Switch"
def _setup_netbox_mock(self, mock_api, vms=None):
"""Helper to setup a working NetBox mock."""
mock_netbox = MagicMock()
mock_api.return_value = mock_netbox
mock_netbox.version = "3.5"
mock_netbox.extras.custom_fields.filter.return_value = []
mock_netbox.dcim.devices.filter.return_value = []
mock_netbox.virtualization.virtual_machines.filter.return_value = vms or []
mock_netbox.dcim.site_groups.all.return_value = []
mock_netbox.dcim.regions.all.return_value = []
mock_netbox.extras.journal_entries = MagicMock()
return mock_netbox
def _setup_zabbix_mock(self, mock_zabbix_api, version=7.0):
"""Helper to setup a working Zabbix mock."""
mock_zabbix = MagicMock()
mock_zabbix_api.return_value = mock_zabbix
mock_zabbix.version = version
mock_zabbix.hostgroup.get.return_value = [
{"groupid": "1", "name": self.EXPECTED_HOSTGROUP}
]
mock_zabbix.hostgroup.create.return_value = {"groupids": ["2"]}
mock_zabbix.template.get.return_value = [
{"templateid": "1", "name": "TestTemplate"}
]
mock_zabbix.proxy.get.return_value = []
mock_zabbix.proxygroup.get.return_value = []
mock_zabbix.logout = MagicMock()
mock_zabbix.host.get.return_value = []
mock_zabbix.host.create.return_value = {"hostids": ["1"]}
mock_zabbix.host.update.return_value = {"hostids": ["42"]}
mock_zabbix.host.delete.return_value = [42]
return mock_zabbix
def _make_zabbix_host(self, hostname="test-vm", status="0"):
"""Build a minimal Zabbix host response for consistency_check."""
return [
{
"hostid": "42",
"host": hostname,
"name": hostname,
"parentTemplates": [{"templateid": "1"}],
"hostgroups": [{"groupid": "1"}],
"groups": [{"groupid": "1"}],
"status": status,
# Single empty-dict interface: len==1 avoids SyncInventoryError,
# empty keys mean no spurious interface-update calls.
"interfaces": [{}],
"inventory_mode": "-1",
"inventory": {},
"macros": [],
"tags": [],
"proxy_hostid": "0",
"proxyid": "0",
"proxy_groupid": "0",
}
]
# Simple Sync config that enables VM sync with a flat hostgroup format
_SYNC_CFG: ClassVar[dict] = {"sync_vms": True,
"vm_hostgroup_format": "site/role"}
# ------------------------------------------------------------------
# Scenario 1: Active VM, not yet in Zabbix → created enabled (status=0)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_vm_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api):
"""Active VM not yet synced to Zabbix should be created with status enabled (0)."""
vm = MockNetboxVM(
name="test-vm", status_label="Active", zabbix_hostid=None)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_called_once()
create_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_kwargs["host"], "test-vm")
self.assertEqual(create_kwargs["status"], 0)
# ------------------------------------------------------------------
# Scenario 2: Active VM, already in Zabbix → consistency check, no update
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Active VM already in Zabbix with matching status should require no updates."""
vm = MockNetboxVM(
name="test-vm", status_label="Active", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.update.assert_not_called()
# ------------------------------------------------------------------
# Scenario 3: Staged VM, not yet in Zabbix → created disabled (status=1)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_staged_vm_not_in_zabbix_is_created_disabled(
self, mock_api, mock_zabbix_api
):
"""Staged VM not yet in Zabbix should be created with status disabled (1)."""
vm = MockNetboxVM(
name="test-vm", status_label="Staged", zabbix_hostid=None)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_called_once()
create_kwargs = mock_zabbix.host.create.call_args.kwargs
self.assertEqual(create_kwargs["status"], 1)
# ------------------------------------------------------------------
# Scenario 4: Staged VM, already in Zabbix as disabled → no update needed
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_staged_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api):
"""Staged VM already in Zabbix as disabled should pass consistency check with no updates."""
vm = MockNetboxVM(
name="test-vm", status_label="Staged", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.update.assert_not_called()
# ------------------------------------------------------------------
# Scenario 5: Decommissioning VM, not in Zabbix → skipped (no create, no delete)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_decommissioning_vm_not_in_zabbix_is_skipped(
self, mock_api, mock_zabbix_api
):
"""Decommissioning VM with no Zabbix ID should be skipped entirely."""
vm = MockNetboxVM(
name="test-vm", status_label="Decommissioning", zabbix_hostid=None
)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.create.assert_not_called()
mock_zabbix.host.delete.assert_not_called()
# ------------------------------------------------------------------
# Scenario 6: Decommissioning VM, already in Zabbix → cleanup (host deleted)
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_decommissioning_vm_in_zabbix_is_deleted(self, mock_api, mock_zabbix_api):
"""Decommissioning VM with a Zabbix ID should be deleted from Zabbix."""
vm = MockNetboxVM(
name="test-vm", status_label="Decommissioning", zabbix_hostid=42
)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = [{"hostid": "42"}]
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.delete.assert_called_once_with(42)
# ------------------------------------------------------------------
# Scenario 7: Active VM, Zabbix host is disabled → re-enable via consistency check
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_active_vm_disabled_in_zabbix_is_enabled(self, mock_api, mock_zabbix_api):
"""Active VM whose Zabbix host is disabled should be re-enabled by consistency check."""
vm = MockNetboxVM(
name="test-vm", status_label="Active", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1")
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="0")
# ------------------------------------------------------------------
# Scenario 8: Failed VM, Zabbix host is enabled → disable via consistency check
# ------------------------------------------------------------------
@patch("netbox_zabbix_sync.modules.core.ZabbixAPI")
@patch("netbox_zabbix_sync.modules.core.nbapi")
def test_failed_vm_enabled_in_zabbix_is_disabled(self, mock_api, mock_zabbix_api):
"""Failed VM whose Zabbix host is enabled should be disabled by consistency check."""
vm = MockNetboxVM(
name="test-vm", status_label="Failed", zabbix_hostid=42)
self._setup_netbox_mock(mock_api, vms=[vm])
mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api)
mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0")
syncer = Sync(self._SYNC_CFG)
syncer.connect(
"http://netbox.local",
"nb_token",
"http://zabbix.local",
"user",
"pass",
None,
)
syncer.start()
mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1")
+284
View File
@@ -0,0 +1,284 @@
"""Tests for the ZabbixTags class in the tags module."""
import unittest
from unittest.mock import MagicMock
from netbox_zabbix_sync.modules.tags import ZabbixTags
class DummyNBForTags:
"""Minimal NetBox object that supports field_mapper's dict-style access."""
def __init__(self, name="test-host", config_context=None, tags=None, site=None):
self.name = name
self.config_context = config_context or {}
self.tags = tags or []
# Stored as a plain dict so field_mapper can traverse "site/name"
self.site = site if site is not None else {"name": "TestSite"}
def __getitem__(self, key):
return getattr(self, key)
class TestZabbixTagsInit(unittest.TestCase):
"""Tests for ZabbixTags initialisation."""
def test_sync_true_when_tag_sync_enabled(self):
"""sync flag should be True when tag_sync=True."""
nb = DummyNBForTags()
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=MagicMock())
self.assertTrue(tags.sync)
def test_sync_false_when_tag_sync_disabled(self):
"""sync flag should be False when tag_sync=False (default)."""
nb = DummyNBForTags()
tags = ZabbixTags(nb, tag_map={}, logger=MagicMock())
self.assertFalse(tags.sync)
def test_repr_and_str_return_host_name(self):
nb = DummyNBForTags(name="my-host")
tags = ZabbixTags(nb, tag_map={}, host="my-host", logger=MagicMock())
self.assertEqual(repr(tags), "my-host")
self.assertEqual(str(tags), "my-host")
class TestRenderTag(unittest.TestCase):
"""Tests for ZabbixTags.render_tag()."""
def setUp(self):
nb = DummyNBForTags()
self.logger = MagicMock()
self.tags = ZabbixTags(
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
)
def test_valid_tag_lowercased(self):
"""Valid name+value with tag_lower=True should produce lowercase keys."""
result = self.tags.render_tag("Site", "Production")
self.assertEqual(result, {"tag": "site", "value": "production"})
def test_valid_tag_not_lowercased(self):
"""tag_lower=False should preserve original case."""
nb = DummyNBForTags()
tags = ZabbixTags(
nb, tag_map={}, tag_sync=True, tag_lower=False, logger=self.logger
)
result = tags.render_tag("Site", "Production")
self.assertEqual(result, {"tag": "Site", "value": "Production"})
def test_invalid_name_none_returns_false(self):
"""None as tag name should return False."""
result = self.tags.render_tag(None, "somevalue")
self.assertFalse(result)
def test_invalid_name_too_long_returns_false(self):
"""Name exceeding 256 characters should return False."""
long_name = "x" * 257
result = self.tags.render_tag(long_name, "somevalue")
self.assertFalse(result)
def test_invalid_value_none_returns_false(self):
"""None as tag value should return False."""
result = self.tags.render_tag("site", None)
self.assertFalse(result)
def test_invalid_value_empty_string_returns_false(self):
"""Empty string as tag value should return False."""
result = self.tags.render_tag("site", "")
self.assertFalse(result)
def test_invalid_value_too_long_returns_false(self):
"""Value exceeding 256 characters should return False."""
long_value = "x" * 257
result = self.tags.render_tag("site", long_value)
self.assertFalse(result)
class TestGenerateFromTagMap(unittest.TestCase):
"""Tests for the field_mapper-driven tag generation path."""
def setUp(self):
self.logger = MagicMock()
def test_generate_tag_from_field_map(self):
"""Tags derived from tag_map fields are lowercased and returned correctly."""
nb = DummyNBForTags(name="router01")
# "site/name" → nb["site"]["name"] → "TestSite", mapped to tag name "site"
tag_map = {"site/name": "site"}
tags = ZabbixTags(
nb,
tag_map=tag_map,
tag_sync=True,
tag_lower=True,
logger=self.logger,
)
result = tags.generate()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["tag"], "site")
self.assertEqual(result[0]["value"], "testsite")
def test_generate_empty_field_map_produces_no_tags(self):
"""An empty tag_map with no context or NB tags should return an empty list."""
nb = DummyNBForTags()
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
result = tags.generate()
self.assertEqual(result, [])
def test_generate_deduplicates_tags(self):
"""Duplicate tags produced by the map should be deduplicated."""
# Two map entries that resolve to the same tag/value pair
nb = DummyNBForTags(name="router01")
tag_map = {"site/name": "site", "site/name": "site"} # noqa: F601
tags = ZabbixTags(
nb,
tag_map=tag_map,
tag_sync=True,
tag_lower=True,
logger=self.logger,
)
result = tags.generate()
self.assertEqual(len(result), 1)
class TestGenerateFromConfigContext(unittest.TestCase):
"""Tests for the config_context-driven tag generation path."""
def setUp(self):
self.logger = MagicMock()
def test_generates_tags_from_config_context(self):
"""Tags listed in config_context['zabbix']['tags'] are added correctly."""
nb = DummyNBForTags(
config_context={
"zabbix": {
"tags": [
{"environment": "production"},
{"location": "DC1"},
]
}
}
)
tags = ZabbixTags(
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
)
result = tags.generate()
self.assertEqual(len(result), 2)
tag_names = [t["tag"] for t in result]
self.assertIn("environment", tag_names)
self.assertIn("location", tag_names)
def test_skips_config_context_tags_with_invalid_values(self):
"""Config context tags with None value should be silently dropped."""
nb = DummyNBForTags(
config_context={
"zabbix": {
"tags": [
{"environment": None}, # invalid value
{"location": "DC1"},
]
}
}
)
tags = ZabbixTags(
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
)
result = tags.generate()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["tag"], "location")
def test_ignores_zabbix_tags_key_missing(self):
"""Missing 'tags' key inside config_context['zabbix'] produces no tags."""
nb = DummyNBForTags(config_context={"zabbix": {"templates": ["T1"]}})
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
result = tags.generate()
self.assertEqual(result, [])
def test_ignores_config_context_tags_not_a_list(self):
"""Non-list value for config_context['zabbix']['tags'] produces no tags."""
nb = DummyNBForTags(config_context={"zabbix": {"tags": "not-a-list"}})
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
result = tags.generate()
self.assertEqual(result, [])
class TestGenerateFromNetboxTags(unittest.TestCase):
"""Tests for the NetBox device tags forwarding path."""
def setUp(self):
self.logger = MagicMock()
# Simulate a list of NetBox tag objects (as dicts, matching real API shape)
self.nb_tags = [
{"name": "ping", "slug": "ping", "display": "ping"},
{"name": "snmp", "slug": "snmp", "display": "snmp"},
]
def test_generates_tags_from_netbox_tags_using_name(self):
"""NetBox device tags are forwarded using tag_name label and tag_value='name'."""
nb = DummyNBForTags(tags=self.nb_tags)
tags = ZabbixTags(
nb,
tag_map={},
tag_sync=True,
tag_lower=True,
tag_name="NetBox",
tag_value="name",
logger=self.logger,
)
result = tags.generate()
self.assertEqual(len(result), 2)
for t in result:
self.assertEqual(t["tag"], "netbox")
values = {t["value"] for t in result}
self.assertIn("ping", values)
self.assertIn("snmp", values)
def test_generates_tags_from_netbox_tags_using_slug(self):
"""tag_value='slug' should use the slug field from each NetBox tag."""
nb = DummyNBForTags(tags=self.nb_tags)
tags = ZabbixTags(
nb,
tag_map={},
tag_sync=True,
tag_lower=False,
tag_name="NetBox",
tag_value="slug",
logger=self.logger,
)
result = tags.generate()
values = {t["value"] for t in result}
self.assertIn("ping", values)
self.assertIn("snmp", values)
def test_generates_tags_from_netbox_tags_default_value_field(self):
"""When tag_value is not a recognised field name, falls back to 'name'."""
nb = DummyNBForTags(tags=self.nb_tags)
tags = ZabbixTags(
nb,
tag_map={},
tag_sync=True,
tag_lower=True,
tag_name="NetBox",
tag_value="invalid_field", # not display/name/slug → fall back to "name"
logger=self.logger,
)
result = tags.generate()
values = {t["value"] for t in result}
self.assertIn("ping", values)
def test_skips_netbox_tags_when_tag_name_not_set(self):
"""NetBox tag forwarding is skipped when tag_name is not configured."""
nb = DummyNBForTags(tags=self.nb_tags)
tags = ZabbixTags(
nb,
tag_map={},
tag_sync=True,
tag_lower=True,
tag_name=None,
logger=self.logger,
)
result = tags.generate()
self.assertEqual(result, [])
if __name__ == "__main__":
unittest.main()