""" Tests for netbox_librenms_plugin.import_utils.vm_operations module. Covers create_vm_from_librenms and bulk_import_vms. All DB interactions are mocked — no @pytest.mark.django_db used. """ import pytest from unittest.mock import MagicMock, patch class TestCreateVmFromLibrenms: """Tests for create_vm_from_librenms function.""" def test_success_with_computed_name(self): """VM is created using pre-computed _computed_name when present.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = { "device_id": 1, "hostname": "vm01.example.com", "_computed_name": "vm01-computed", } mock_cluster = MagicMock() mock_platform = MagicMock() validation = { "can_import": True, "cluster": {"cluster": mock_cluster}, "platform": {"platform": mock_platform}, } mock_vm = MagicMock() mock_vm.name = "vm01-computed" mock_vm.pk = 10 with ( patch("django.db.transaction.atomic"), patch("virtualization.models.VirtualMachine") as mock_vm_class, ): mock_vm_class.objects.create.return_value = mock_vm result = create_vm_from_librenms(libre_device, validation) assert result == mock_vm call_kwargs = mock_vm_class.objects.create.call_args[1] assert call_kwargs["name"] == "vm01-computed" assert call_kwargs["cluster"] == mock_cluster assert call_kwargs["platform"] == mock_platform def test_fallback_to_determine_device_name_when_no_computed_name(self): """Falls back to _determine_device_name when _computed_name is absent.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 2, "hostname": "vm02.example.com"} validation = { "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, } mock_vm = MagicMock() mock_vm.name = "vm02-determined" mock_vm.pk = 11 with ( patch( "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", return_value="vm02-determined", ) as mock_det, patch("django.db.transaction.atomic"), patch("virtualization.models.VirtualMachine") as mock_vm_class, ): mock_vm_class.objects.create.return_value = mock_vm result = create_vm_from_librenms(libre_device, validation) mock_det.assert_called_once() call_kwargs = mock_vm_class.objects.create.call_args[1] assert call_kwargs["name"] == "vm02-determined" assert result == mock_vm def test_can_import_false_raises_value_error(self): """Raises ValueError immediately when validation['can_import'] is False.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 3, "hostname": "vm03"} validation = { "can_import": False, "issues": ["No cluster assigned", "Missing role"], } with pytest.raises(ValueError, match="VM cannot be imported"): create_vm_from_librenms(libre_device, validation) def test_server_key_stored_in_custom_field(self): """librenms_id custom field uses the provided server_key via set_librenms_device_id.""" from unittest.mock import patch from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 5, "hostname": "vm05", "_computed_name": "vm05"} validation = { "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, } mock_vm = MagicMock() mock_vm.name = "vm05" mock_vm.pk = 50 mock_vm.custom_field_data = {} with patch("virtualization.models.VirtualMachine") as mock_vm_class: with patch("django.db.transaction.atomic"): with patch("netbox_librenms_plugin.utils.set_librenms_device_id") as mock_setter: mock_vm_class.objects.create.return_value = mock_vm create_vm_from_librenms(libre_device, validation, server_key="secondary") mock_setter.assert_called_once_with(mock_vm, 5, "secondary") mock_vm.save.assert_called_once() def test_role_is_passed_to_create(self): """Optional role parameter is forwarded to VirtualMachine.objects.create.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 6, "hostname": "vm06", "_computed_name": "vm06"} mock_role = MagicMock() validation = { "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, } mock_vm = MagicMock() mock_vm.name = "vm06" mock_vm.pk = 60 with ( patch("django.db.transaction.atomic"), patch("virtualization.models.VirtualMachine") as mock_vm_class, ): mock_vm_class.objects.create.return_value = mock_vm create_vm_from_librenms(libre_device, validation, role=mock_role) call_kwargs = mock_vm_class.objects.create.call_args[1] assert call_kwargs["role"] == mock_role def test_platform_none_when_not_in_validation(self): """Platform is None when validation['platform'] has no 'platform' key.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 7, "hostname": "vm07", "_computed_name": "vm07"} validation = { "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {}, # no 'platform' key — .get() returns None } mock_vm = MagicMock() mock_vm.name = "vm07" mock_vm.pk = 70 with ( patch("django.db.transaction.atomic"), patch("virtualization.models.VirtualMachine") as mock_vm_class, ): mock_vm_class.objects.create.return_value = mock_vm create_vm_from_librenms(libre_device, validation) call_kwargs = mock_vm_class.objects.create.call_args[1] assert call_kwargs["platform"] is None def test_import_comment_contains_device_id(self): """The comments field contains a reference to LibreNMS and device_id.""" from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms libre_device = {"device_id": 8, "hostname": "vm08", "_computed_name": "vm08"} validation = { "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, } mock_vm = MagicMock() mock_vm.name = "vm08" mock_vm.pk = 80 with ( patch("django.db.transaction.atomic"), patch("virtualization.models.VirtualMachine") as mock_vm_class, ): mock_vm_class.objects.create.return_value = mock_vm create_vm_from_librenms(libre_device, validation) call_kwargs = mock_vm_class.objects.create.call_args[1] assert "LibreNMS" in call_kwargs["comments"] assert str(libre_device["device_id"]) in call_kwargs["comments"] class TestBulkImportVms: """Tests for bulk_import_vms function.""" def test_empty_vm_imports_returns_empty_result(self): """Empty vm_imports dict returns empty success/failed/skipped lists.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"): result = bulk_import_vms({}, mock_api, user=MagicMock()) assert result == {"success": [], "failed": [], "skipped": []} def test_permission_denied_propagates(self): """PermissionDenied from require_permissions propagates to the caller.""" from django.core.exceptions import PermissionDenied from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" with patch( "netbox_librenms_plugin.import_utils.vm_operations.require_permissions", side_effect=PermissionDenied("No permission"), ): with pytest.raises(PermissionDenied): bulk_import_vms({1: {}}, mock_api, user=MagicMock()) def test_device_not_found_added_to_failed(self): """When fetch_device_with_cache returns None, device is appended to failed.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=None, ), ): result = bulk_import_vms({99: {}}, mock_api, user=MagicMock()) assert len(result["failed"]) == 1 assert result["failed"][0]["device_id"] == 99 assert "not found" in result["failed"][0]["error"].lower() def test_existing_device_added_to_skipped(self): """When validation reports existing_device, device is appended to skipped.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_existing = MagicMock() mock_existing.name = "existing-vm" libre_device = {"device_id": 10, "hostname": "existing-vm"} mock_validation = { "existing_device": mock_existing, "can_import": False, "issues": [], } with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ), ): result = bulk_import_vms({10: {}}, mock_api, user=MagicMock()) assert len(result["skipped"]) == 1 assert result["skipped"][0]["device_id"] == 10 assert "existing-vm" in result["skipped"][0]["reason"] def test_success_path_vm_created(self): """Happy path: VM is created and appended to success list.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" libre_device = {"device_id": 20, "hostname": "new-vm"} mock_validation = { "existing_device": None, "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, "issues": [], } mock_vm = MagicMock() mock_vm.name = "new-vm" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ), patch( "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", return_value="new-vm", ), patch( "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", return_value=mock_vm, ), patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"), patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"), patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), ): result = bulk_import_vms({20: {}}, mock_api, user=MagicMock()) assert len(result["success"]) == 1 assert result["success"][0]["device_id"] == 20 assert result["success"][0]["device"] == mock_vm assert len(result["failed"]) == 0 assert len(result["skipped"]) == 0 def test_cluster_assignment_applied(self): """apply_cluster_to_validation is called when cluster_id is provided and found.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_cluster = MagicMock() libre_device = {"device_id": 30, "hostname": "clustered-vm"} mock_validation = { "existing_device": None, "can_import": True, "cluster": {"cluster": mock_cluster}, "platform": {"platform": None}, "issues": [], } mock_vm = MagicMock() mock_vm.name = "clustered-vm" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ), patch( "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", return_value="clustered-vm", ), patch( "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", return_value=mock_vm, ), patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls, patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster, patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), ): mock_cluster_cls.objects.filter.return_value.first.return_value = mock_cluster bulk_import_vms({30: {"cluster_id": 5}}, mock_api, user=MagicMock()) mock_apply_cluster.assert_called_once_with(mock_validation, mock_cluster) def test_role_assignment_applied(self): """apply_role_to_validation is called when role_id is provided and found.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_role = MagicMock() libre_device = {"device_id": 40, "hostname": "role-vm"} mock_validation = { "existing_device": None, "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, "issues": [], } mock_vm = MagicMock() mock_vm.name = "role-vm" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ), patch( "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", return_value="role-vm", ), patch( "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", return_value=mock_vm, ), patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"), patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole") as mock_role_cls, patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"), patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation") as mock_apply_role, ): mock_role_cls.objects.filter.return_value.first.return_value = mock_role bulk_import_vms({40: {"device_role_id": 3}}, mock_api, user=MagicMock()) mock_apply_role.assert_called_once_with(mock_validation, mock_role, is_vm=True) def test_exception_in_inner_loop_added_to_failed(self): """Exception during VM processing is caught and added to failed list.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", side_effect=RuntimeError("Connection error"), ), ): result = bulk_import_vms({50: {}}, mock_api, user=MagicMock()) assert len(result["failed"]) == 1 assert result["failed"][0]["device_id"] == 50 assert "Connection error" in result["failed"][0]["error"] def test_job_cancellation_breaks_loop(self): """Loop exits early when _is_job_cancelled returns True at idx=1 check.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_job = MagicMock() mock_job.logger = MagicMock() # 5 VMs; _is_job_cancelled returns False for first check (idx=1), True for second (idx=5) cancel_calls = [0] def _cancelled(job): cancel_calls[0] += 1 return cancel_calls[0] >= 2 vm_imports = {i: {} for i in range(1, 6)} with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch("netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled", side_effect=_cancelled), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=None, # VMs 1-4 → failed; VM-5 never reached ), ): result = bulk_import_vms(vm_imports, mock_api, job=mock_job) # VMs 1-4 added to failed; 5th cancelled before processing assert len(result["failed"]) == 4 def test_job_cancellation_with_errored_status(self): """Loop also exits when _is_job_cancelled returns True (rq_job.is_failed).""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_job = MagicMock() mock_job.logger = MagicMock() cancel_calls = [0] def _cancelled(job): cancel_calls[0] += 1 return cancel_calls[0] >= 2 vm_imports = {i: {} for i in range(1, 6)} with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch("netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled", side_effect=_cancelled), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=None, ), ): result = bulk_import_vms(vm_imports, mock_api, job=mock_job) assert len(result["failed"]) == 4 def test_user_extracted_from_job_when_not_provided(self): """User is extracted from job.job.user when the user param is None.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_user = MagicMock() mock_job = MagicMock() mock_job.job.user = mock_user mock_job.logger = MagicMock() mock_api = MagicMock() mock_api.server_key = "default" with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions") as mock_require: bulk_import_vms({}, mock_api, job=mock_job, user=None) mock_require.assert_called_once_with(mock_user, ["virtualization.add_virtualmachine"], "import VMs") def test_sync_options_use_sysname_and_strip_domain_forwarded(self): """sync_options use_sysname/strip_domain are passed to validate_device_for_import.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" libre_device = {"device_id": 60, "hostname": "opts-vm"} # existing_device set → triggers skipped path (avoids more mocking) mock_validation = { "existing_device": MagicMock(name="opts-vm"), "can_import": False, "issues": [], } with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ) as mock_validate, ): bulk_import_vms( {60: {}}, mock_api, sync_options={"use_sysname": False, "strip_domain": True}, user=MagicMock(), ) mock_validate.assert_called_once() call_kwargs = mock_validate.call_args[1] assert call_kwargs["use_sysname"] is False assert call_kwargs["strip_domain"] is True def test_no_cluster_id_skips_cluster_lookup(self): """Cluster lookup is skipped when cluster_id is absent from vm_mappings.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" libre_device = {"device_id": 70, "hostname": "no-cluster-vm"} mock_validation = { "existing_device": None, "can_import": True, "cluster": {"cluster": MagicMock()}, "platform": {"platform": None}, "issues": [], } mock_vm = MagicMock() mock_vm.name = "no-cluster-vm" with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=libre_device, ), patch( "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", return_value=mock_validation, ), patch( "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", return_value="no-cluster-vm", ), patch( "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", return_value=mock_vm, ), patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls, patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster, patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), ): # No cluster_id in vm_mappings bulk_import_vms({70: {}}, mock_api, user=MagicMock()) mock_cluster_cls.objects.filter.assert_not_called() mock_apply_cluster.assert_not_called() def test_is_job_cancelled_false_processes_all_vms(self): """_is_job_cancelled returning False lets loop process all VMs.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" mock_job = MagicMock() mock_job.logger = MagicMock() vm_imports = {i: {} for i in range(1, 3)} with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), # Simulate Redis unavailable → _is_job_cancelled returns False (not cancelled) patch( "netbox_librenms_plugin.import_utils.vm_operations._is_job_cancelled", return_value=False, ) as mock_is_job_cancelled, patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=None, ), ): result = bulk_import_vms(vm_imports, mock_api, job=mock_job) # The cancellation helper must be consulted at least once (idx==1 checkpoint); # protects against accidental removal of the check. mock_is_job_cancelled.assert_called_once_with(mock_job) # Both VMs should be attempted (not cancelled) → both failed (fetch returned None) assert len(result["failed"]) == 2 def test_job_log_info_when_not_cancelled_at_checkpoint(self): """log.info is called at a non-cancelling 5-iteration checkpoint.""" from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms mock_api = MagicMock() mock_api.server_key = "default" # Status is "running" at first checkpoint (idx=5), "failed" at second (idx=10) statuses = iter(["running", "running", "failed"]) mock_job = MagicMock() mock_job.logger = MagicMock() mock_job.job.status = "running" def _refresh(): try: mock_job.job.status = next(statuses) except StopIteration: mock_job.job.status = "failed" mock_job.job.refresh_from_db.side_effect = _refresh # 10 VMs: checkpoint at idx=5 (running → log.info) and idx=10 (failed → break) vm_imports = {i: {} for i in range(1, 11)} with ( patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), patch( "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", return_value=None, ), ): bulk_import_vms(vm_imports, mock_api, job=mock_job) # log.info called at idx=5 checkpoint mock_job.logger.info.assert_called()