ipfabric_netbox 4.2.2b2__py3-none-any.whl → 4.2.2b4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ipfabric_netbox might be problematic. Click here for more details.

@@ -0,0 +1,2166 @@
1
+ import random
2
+ from unittest.mock import patch
3
+ from uuid import uuid4
4
+
5
+ from core.choices import DataSourceStatusChoices
6
+ from core.choices import JobStatusChoices
7
+ from core.models import Job
8
+ from dcim.models import Device
9
+ from dcim.models import DeviceRole
10
+ from dcim.models import DeviceType
11
+ from dcim.models import Manufacturer
12
+ from dcim.models import Site
13
+ from django.contrib.contenttypes.models import ContentType
14
+ from django.core.cache import cache
15
+ from django.core.exceptions import ValidationError
16
+ from django.db.models import Model
17
+ from django.forms.models import model_to_dict
18
+ from django.test import override_settings
19
+ from django.utils import timezone
20
+ from netbox_branching.models import Branch
21
+ from netbox_branching.models import ChangeDiff
22
+ from utilities.testing import ModelTestCase
23
+ from utilities.testing import ViewTestCases
24
+
25
+ from ipfabric_netbox.choices import IPFabricSnapshotStatusModelChoices
26
+ from ipfabric_netbox.choices import IPFabricSourceTypeChoices
27
+ from ipfabric_netbox.forms import dcim_parameters
28
+ from ipfabric_netbox.forms import ipam_parameters
29
+ from ipfabric_netbox.forms import tableChoices
30
+ from ipfabric_netbox.jobs import merge_ipfabric_ingestion
31
+ from ipfabric_netbox.models import IPFabricData
32
+ from ipfabric_netbox.models import IPFabricIngestion
33
+ from ipfabric_netbox.models import IPFabricRelationshipField
34
+ from ipfabric_netbox.models import IPFabricSnapshot
35
+ from ipfabric_netbox.models import IPFabricSource
36
+ from ipfabric_netbox.models import IPFabricSync
37
+ from ipfabric_netbox.models import IPFabricTransformField
38
+ from ipfabric_netbox.models import IPFabricTransformMap
39
+ from ipfabric_netbox.models import IPFabricTransformMapGroup
40
+ from ipfabric_netbox.tables import DeviceIPFTable
41
+
42
+
43
+ class PluginPathMixin:
44
+ """Mixin to correct URL Paths for plugin test."""
45
+
46
+ maxDiff = 1000
47
+
48
+ model: Model # To avoid unresolved attribute warning
49
+
50
+ def _get_model_name(self):
51
+ return self.model._meta.model_name
52
+
53
+ def _get_base_url(self):
54
+ return f"plugins:ipfabric_netbox:{self._get_model_name()}_{{}}" # noqa: E231
55
+
56
+
57
+ class IPFabricSourceTestCase(
58
+ PluginPathMixin,
59
+ ViewTestCases.GetObjectViewTestCase,
60
+ ViewTestCases.GetObjectChangelogViewTestCase,
61
+ ViewTestCases.CreateObjectViewTestCase,
62
+ ViewTestCases.EditObjectViewTestCase,
63
+ ViewTestCases.DeleteObjectViewTestCase,
64
+ ViewTestCases.ListObjectsViewTestCase,
65
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
66
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
67
+ # ViewTestCases.BulkEditObjectsViewTestCase,
68
+ # ViewTestCases.BulkImportObjectsViewTestCase,
69
+ ):
70
+ model = IPFabricSource
71
+ user_permissions = ("ipfabric_netbox.sync_ipfabricsource",)
72
+
73
+ @classmethod
74
+ def setUpTestData(cls):
75
+ # Create three IPFabricSource instances for testing
76
+ sources = (
77
+ IPFabricSource(
78
+ name="IP Fabric Source 1",
79
+ type=IPFabricSourceTypeChoices.LOCAL,
80
+ url="https://ipfabric1.example.com",
81
+ status=DataSourceStatusChoices.NEW,
82
+ parameters={"auth": "token1", "verify": True, "timeout": 30},
83
+ last_synced=timezone.now(),
84
+ ),
85
+ IPFabricSource(
86
+ name="IP Fabric Source 2",
87
+ type=IPFabricSourceTypeChoices.REMOTE,
88
+ url="https://ipfabric2.example.com",
89
+ status=DataSourceStatusChoices.COMPLETED,
90
+ parameters={"auth": "token2", "verify": False, "timeout": 60},
91
+ last_synced=timezone.now(),
92
+ ),
93
+ IPFabricSource(
94
+ name="IP Fabric Source 3",
95
+ type=IPFabricSourceTypeChoices.LOCAL,
96
+ url="https://ipfabric3.example.com",
97
+ status=DataSourceStatusChoices.FAILED,
98
+ parameters={"auth": "token3", "verify": True, "timeout": 45},
99
+ ),
100
+ )
101
+ for source in sources:
102
+ source.save()
103
+ Job.objects.create(
104
+ job_id=uuid4(),
105
+ object_id=source.pk,
106
+ object_type=ContentType.objects.get_for_model(IPFabricSource),
107
+ name=f"Test Sync Job {source.pk}",
108
+ status=JobStatusChoices.STATUS_COMPLETED,
109
+ completed=timezone.now(),
110
+ created=timezone.now(),
111
+ )
112
+ IPFabricSnapshot.objects.create(
113
+ source=IPFabricSource.objects.first(),
114
+ name="Snapshot 1",
115
+ snapshot_id="$last",
116
+ data={
117
+ "version": "6.0.0",
118
+ "sites": ["Site A", "Site B", "Site C"],
119
+ "total_dev_count": 100,
120
+ "interface_count": 500,
121
+ "note": "Test snapshot 1",
122
+ },
123
+ date=timezone.now(),
124
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
125
+ )
126
+
127
+ cls.site = Site.objects.create(name="Test Site", slug="test-site")
128
+
129
+ cls.form_data = {
130
+ "name": "IP Fabric Source X",
131
+ "type": IPFabricSourceTypeChoices.LOCAL,
132
+ "url": "https://ipfabricx.example.com",
133
+ "auth": "tokenX",
134
+ "verify": True,
135
+ "timeout": 30,
136
+ "comments": "This is a test IP Fabric source",
137
+ }
138
+
139
+ cls.csv_data = (
140
+ "name,type,url,parameters",
141
+ 'IP Fabric Source 4,local,https://ipfabric4.example.com,"{""auth"": ""token4"", ""verify"": true}"',
142
+ 'IP Fabric Source 5,remote,https://ipfabric5.example.com,"{""auth"": ""token5"", ""verify"": false}"',
143
+ 'IP Fabric Source 6,local,https://ipfabric6.example.com,"{""auth"": ""token6"", ""verify"": true}"',
144
+ )
145
+
146
+ cls.csv_update_data = (
147
+ "id,name,url",
148
+ f"{sources[0].pk},IP Fabric Source 7,https://ipfabric7.example.com", # noqa: E231
149
+ f"{sources[1].pk},IP Fabric Source 8,https://ipfabric8.example.com", # noqa: E231
150
+ f"{sources[2].pk},IP Fabric Source 9,https://ipfabric9.example.com", # noqa: E231
151
+ )
152
+
153
+ cls.bulk_edit_data = {
154
+ "type": IPFabricSourceTypeChoices.REMOTE,
155
+ "comments": "Bulk updated comment",
156
+ }
157
+
158
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
159
+ def test_get_topology(self):
160
+ response = self.client.get(
161
+ self._get_queryset().first().get_absolute_url()
162
+ + f"topology/{self.site.pk}/"
163
+ )
164
+ self.assertHttpStatus(response, 200)
165
+ # Verify the response contains expected modal structure
166
+ self.assertContains(response, "modal-body")
167
+ # Check that the context contains the site object
168
+ self.assertIn("site", response.context)
169
+ self.assertEqual(response.context["site"], self.site.id)
170
+
171
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
172
+ @patch("ipfabric_netbox.utilities.ipfutils.IPFClient")
173
+ def test_get_topology_htmx(self, mock_ipfclient_class):
174
+ # Mock the IPFClient instance that gets created inside IPFabric.__init__
175
+ mock_ipfclient_instance = mock_ipfclient_class.return_value
176
+ mock_ipfclient_instance._client.headers = {"user-agent": "test-user-agent"}
177
+
178
+ # Mock snapshot data - this is what ipf.ipf.snapshots.get(snapshot) returns
179
+ mock_snapshot_data = {
180
+ "id": "$last",
181
+ "name": "Test Snapshot",
182
+ "status": "done",
183
+ "finish_status": "done",
184
+ "end": "2024-01-15T10:30:00Z",
185
+ "snapshot_id": "snapshot123",
186
+ "version": "6.0.0",
187
+ "sites": ["Test Site"],
188
+ "total_dev_count": 10,
189
+ "interface_count": 50,
190
+ }
191
+ mock_ipfclient_instance.snapshots.get.return_value = mock_snapshot_data
192
+
193
+ # Mock site data - this is what ipf.ipf.inventory.sites.all() returns
194
+ mock_sites_data = [
195
+ {
196
+ "siteName": "Test Site",
197
+ "siteKey": "site123",
198
+ "location": "Test Location",
199
+ "deviceCount": 5,
200
+ }
201
+ ]
202
+ mock_ipfclient_instance.inventory.sites.all.return_value = mock_sites_data
203
+
204
+ # Mock diagram methods to avoid actual diagram generation
205
+ mock_ipfclient_instance.diagram.share_link.return_value = (
206
+ "https://ipfabric.example.com/diagram/share/123"
207
+ )
208
+ mock_ipfclient_instance.diagram.svg.return_value = (
209
+ b'<svg><rect width="100" height="100" fill="blue"/></svg>'
210
+ )
211
+
212
+ response = self.client.get(
213
+ self._get_queryset().first().get_absolute_url()
214
+ + f"topology/{self.site.pk}/",
215
+ **{"HTTP_HX-Request": "true"},
216
+ query_params={
217
+ "source": IPFabricSource.objects.first().pk,
218
+ "snapshot": "$last",
219
+ },
220
+ )
221
+ self.assertHttpStatus(response, 200)
222
+
223
+ # Verify that the API calls were made with correct parameters
224
+ mock_ipfclient_instance.snapshots.get.assert_called_once_with("$last")
225
+ mock_ipfclient_instance.inventory.sites.all.assert_called_once_with(
226
+ filters={"siteName": ["eq", "Test Site"]}
227
+ )
228
+
229
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
230
+ @patch("ipfabric_netbox.utilities.ipfutils.IPFClient")
231
+ def test_get_topology_htmx_empty_snapshot_data(self, mock_ipfclient_class):
232
+ # Mock the IPFClient instance that gets created inside IPFabric.__init__
233
+ mock_ipfclient_instance = mock_ipfclient_class.return_value
234
+ mock_ipfclient_instance._client.headers = {"user-agent": "test-user-agent"}
235
+
236
+ # Mock empty snapshot data - this is what ipf.ipf.snapshots.get(snapshot) returns when snapshot doesn't exist
237
+ mock_ipfclient_instance.snapshots.get.return_value = None
238
+
239
+ response = self.client.get(
240
+ self._get_queryset().first().get_absolute_url()
241
+ + f"topology/{self.site.pk}/",
242
+ **{"HTTP_HX-Request": "true"},
243
+ query_params={
244
+ "source": IPFabricSource.objects.first().pk,
245
+ "snapshot": "$last",
246
+ },
247
+ )
248
+ self.assertHttpStatus(response, 200)
249
+
250
+ # Verify that the snapshot API was called but sites API was not called due to early exit
251
+ mock_ipfclient_instance.snapshots.get.assert_called_once_with("$last")
252
+ mock_ipfclient_instance.inventory.sites.all.assert_not_called()
253
+
254
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
255
+ @patch("ipfabric_netbox.utilities.ipfutils.IPFClient")
256
+ def test_get_topology_htmx_empty_sites_data(self, mock_ipfclient_class):
257
+ # Mock the IPFClient instance that gets created inside IPFabric.__init__
258
+ mock_ipfclient_instance = mock_ipfclient_class.return_value
259
+ mock_ipfclient_instance._client.headers = {"user-agent": "test-user-agent"}
260
+
261
+ # Mock valid snapshot data
262
+ mock_snapshot_data = {
263
+ "id": "$last",
264
+ "name": "Test Snapshot",
265
+ "status": "done",
266
+ "finish_status": "done",
267
+ "end": "2024-01-15T10:30:00Z",
268
+ "snapshot_id": "snapshot123",
269
+ "version": "6.0.0",
270
+ "sites": ["Test Site"],
271
+ "total_dev_count": 10,
272
+ "interface_count": 50,
273
+ }
274
+ mock_ipfclient_instance.snapshots.get.return_value = mock_snapshot_data
275
+
276
+ # Mock empty site data - this is what ipf.ipf.inventory.sites.all() returns when site doesn't exist in snapshot
277
+ mock_ipfclient_instance.inventory.sites.all.return_value = []
278
+
279
+ response = self.client.get(
280
+ self._get_queryset().first().get_absolute_url()
281
+ + f"topology/{self.site.pk}/",
282
+ **{"HTTP_HX-Request": "true"},
283
+ query_params={
284
+ "source": IPFabricSource.objects.first().pk,
285
+ "snapshot": "$last",
286
+ },
287
+ )
288
+ self.assertHttpStatus(response, 200)
289
+
290
+ # Verify that both API calls were made
291
+ mock_ipfclient_instance.snapshots.get.assert_called_once_with("$last")
292
+ mock_ipfclient_instance.inventory.sites.all.assert_called_once_with(
293
+ filters={"siteName": ["eq", "Test Site"]}
294
+ )
295
+
296
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
297
+ def test_get_topology_htmx_no_source(self):
298
+ response = self.client.get(
299
+ self._get_queryset().first().get_absolute_url()
300
+ + f"topology/{self.site.pk}/",
301
+ **{"HTTP_HX-Request": "true"},
302
+ )
303
+ self.assertHttpStatus(response, 200)
304
+ # Verify response contains HTMX content for no source scenario
305
+ self.assertContains(response, "Source ID not available in request")
306
+ # Check that context indicates no source selected
307
+ self.assertIn("source", response.context)
308
+ self.assertIsNone(response.context.get("source"))
309
+
310
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
311
+ def test_get_topology_htmx_no_snapshot(self):
312
+ response = self.client.get(
313
+ self._get_queryset().first().get_absolute_url()
314
+ + f"topology/{self.site.pk}/",
315
+ **{"HTTP_HX-Request": "true"},
316
+ query_params={"source": IPFabricSource.objects.first().pk},
317
+ )
318
+ self.assertHttpStatus(response, 200)
319
+ # Verify response contains HTMX content for no snapshot scenario
320
+ self.assertContains(response, "Snapshot ID not available in request.")
321
+ # Verify response indicates no snapshot selected
322
+ self.assertIn("snapshot", response.context)
323
+ self.assertIsNone(response.context.get("snapshot"))
324
+
325
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
326
+ def test_sync_view_get_redirect(self):
327
+ """Test that GET request to sync view redirects to source detail page."""
328
+ source = self._get_queryset().first()
329
+ response = self.client.get(source.get_absolute_url() + "sync/")
330
+ self.assertHttpStatus(response, 302)
331
+ self.assertRedirects(response, source.get_absolute_url())
332
+
333
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
334
+ @patch("ipfabric_netbox.models.IPFabricSource.enqueue_sync_job")
335
+ def test_sync_view_post_valid(self, mock_enqueue_sync_job):
336
+ """Test POST request to sync view successfully enqueues sync job."""
337
+
338
+ # Set up mock job
339
+ mock_job = Job(pk=123, name="Test Source Sync Job")
340
+ mock_enqueue_sync_job.return_value = mock_job
341
+
342
+ source = self._get_queryset().first()
343
+
344
+ response = self.client.post(source.get_absolute_url() + "sync/", follow=True)
345
+
346
+ # Should redirect to source detail page
347
+ self.assertHttpStatus(response, 200)
348
+ self.assertRedirects(response, source.get_absolute_url())
349
+
350
+ # Should have called enqueue_sync_job with correct parameters
351
+ mock_enqueue_sync_job.assert_called_once()
352
+ call_args = mock_enqueue_sync_job.call_args
353
+ self.assertIn("request", call_args[1])
354
+
355
+ # Should show success message
356
+ messages = list(response.context["messages"])
357
+ self.assertEqual(len(messages), 1)
358
+ self.assertIn(f"Queued job #{mock_job.pk}", str(messages[0]))
359
+
360
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
361
+ def test_sync_view_nonexistent_source(self):
362
+ """Test sync view with non-existent source returns 404."""
363
+ nonexistent_pk = 99999
364
+ response = self.client.post(
365
+ f"/plugins/ipfabric-netbox/ipfabricsource/{nonexistent_pk}/sync/"
366
+ )
367
+ self.assertHttpStatus(response, 404)
368
+
369
+
370
+ class IPFabricSnapshotTestCase(
371
+ PluginPathMixin,
372
+ ViewTestCases.GetObjectViewTestCase,
373
+ ViewTestCases.GetObjectChangelogViewTestCase,
374
+ # ViewTestCases.CreateObjectViewTestCase,
375
+ # ViewTestCases.EditObjectViewTestCase,
376
+ ViewTestCases.DeleteObjectViewTestCase,
377
+ ViewTestCases.ListObjectsViewTestCase,
378
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
379
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
380
+ # ViewTestCases.BulkEditObjectsViewTestCase,
381
+ # ViewTestCases.BulkImportObjectsViewTestCase,
382
+ ):
383
+ model = IPFabricSnapshot
384
+
385
+ @classmethod
386
+ def setUpTestData(cls):
387
+ # Create IPFabricSource instances needed for snapshots
388
+ source1 = IPFabricSource.objects.create(
389
+ name="Test Source 1",
390
+ type=IPFabricSourceTypeChoices.LOCAL,
391
+ url="https://ipfabric1.example.com",
392
+ status=DataSourceStatusChoices.NEW,
393
+ )
394
+ source2 = IPFabricSource.objects.create(
395
+ name="Test Source 2",
396
+ type=IPFabricSourceTypeChoices.REMOTE,
397
+ url="https://ipfabric2.example.com",
398
+ status=DataSourceStatusChoices.COMPLETED,
399
+ )
400
+
401
+ # Create three IPFabricSnapshot instances for testing
402
+ snapshots = (
403
+ IPFabricSnapshot(
404
+ source=source1,
405
+ name="Snapshot 1",
406
+ snapshot_id="snap001",
407
+ data={
408
+ "version": "6.0.0",
409
+ "sites": ["Site A", "Site B", "Site C"],
410
+ "total_dev_count": 100,
411
+ "interface_count": 500,
412
+ "note": "Test snapshot 1",
413
+ },
414
+ date=timezone.now(),
415
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
416
+ ),
417
+ IPFabricSnapshot(
418
+ source=source1,
419
+ name="Snapshot 2",
420
+ snapshot_id="snap002",
421
+ data={
422
+ "version": "6.0.1",
423
+ "sites": ["Site D", "Site E"],
424
+ "total_dev_count": 150,
425
+ "interface_count": 750,
426
+ "note": "Test snapshot 2",
427
+ },
428
+ date=timezone.now(),
429
+ status=IPFabricSnapshotStatusModelChoices.STATUS_UNLOADED,
430
+ ),
431
+ IPFabricSnapshot(
432
+ source=source2,
433
+ name="Snapshot 3",
434
+ snapshot_id="snap003",
435
+ data={
436
+ "version": "6.1.0",
437
+ "sites": ["Site F", "Site G", "Site H", "Site I"],
438
+ "total_dev_count": 200,
439
+ "interface_count": 1000,
440
+ "note": "Test snapshot 3",
441
+ },
442
+ date=timezone.now(),
443
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
444
+ ),
445
+ )
446
+ for snapshot in snapshots:
447
+ snapshot.save()
448
+ IPFabricData.objects.create(snapshot_data=snapshot, type="device", data={})
449
+
450
+ cls.form_data = {
451
+ "source": source1.pk,
452
+ "name": "Test Snapshot X",
453
+ "snapshot_id": "snapX",
454
+ "data": '{"version": "6.0.0", "sites": ["Site X"], "total_dev_count": 75, "interface_count": 375, "note": "Test snapshot X"}',
455
+ "status": IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
456
+ }
457
+
458
+ cls.csv_data = (
459
+ "source,name,snapshot_id,status",
460
+ f"{source1.pk},Snapshot CSV 1,snapcsv001,{IPFabricSnapshotStatusModelChoices.STATUS_LOADED}", # noqa: E231
461
+ f"{source1.pk},Snapshot CSV 2,snapcsv002,{IPFabricSnapshotStatusModelChoices.STATUS_UNLOADED}", # noqa: E231
462
+ f"{source2.pk},Snapshot CSV 3,snapcsv003,{IPFabricSnapshotStatusModelChoices.STATUS_LOADED}", # noqa: E231
463
+ )
464
+
465
+ cls.csv_update_data = (
466
+ "id,name,snapshot_id",
467
+ f"{snapshots[0].pk},Updated Snapshot 1,updsnap001", # noqa: E231
468
+ f"{snapshots[1].pk},Updated Snapshot 2,updsnap002", # noqa: E231
469
+ f"{snapshots[2].pk},Updated Snapshot 3,updsnap003", # noqa: E231
470
+ )
471
+
472
+ cls.bulk_edit_data = {
473
+ "status": IPFabricSnapshotStatusModelChoices.STATUS_UNLOADED,
474
+ }
475
+
476
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
477
+ def test_get_data(self):
478
+ snapshot = self._get_queryset().first()
479
+ response = self.client.get(snapshot.get_absolute_url() + "data/")
480
+ self.assertHttpStatus(response, 200)
481
+ # Verify the response contains expected data view elements
482
+ self.assertContains(response, "Raw Data")
483
+ # Check that context contains the snapshot object
484
+ self.assertIn("object", response.context)
485
+ response_snapshot = response.context["object"]
486
+ self.assertEqual(response_snapshot.name, snapshot.name)
487
+ # Verify related data is accessible
488
+ self.assertTrue(response_snapshot.ipf_data.exists())
489
+
490
+
491
+ # TODO: Get back to these tests
492
+ class IPFabricDataTestCase(
493
+ PluginPathMixin,
494
+ # ViewTestCases.GetObjectViewTestCase,
495
+ # ViewTestCases.GetObjectChangelogViewTestCase,
496
+ # ViewTestCases.CreateObjectViewTestCase,
497
+ # ViewTestCases.EditObjectViewTestCase,
498
+ ViewTestCases.DeleteObjectViewTestCase,
499
+ # ViewTestCases.ListObjectsViewTestCase,
500
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
501
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
502
+ # ViewTestCases.BulkEditObjectsViewTestCase,
503
+ # ViewTestCases.BulkImportObjectsViewTestCase,
504
+ ):
505
+ model = IPFabricData
506
+
507
+ @classmethod
508
+ def setUpTestData(cls):
509
+ # Create required dependencies
510
+ source = IPFabricSource.objects.create(
511
+ name="Test Source",
512
+ type=IPFabricSourceTypeChoices.LOCAL,
513
+ url="https://ipfabric.example.com",
514
+ status=DataSourceStatusChoices.NEW,
515
+ )
516
+
517
+ snapshot = IPFabricSnapshot.objects.create(
518
+ source=source,
519
+ name="Test Snapshot",
520
+ snapshot_id="data_snap001",
521
+ data={"version": "6.0.0", "sites": ["Site A"], "total_dev_count": 100},
522
+ date=timezone.now(),
523
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
524
+ )
525
+
526
+ # Create three IPFabricData instances for testing
527
+ data_instances = (
528
+ IPFabricData(
529
+ snapshot_data=snapshot,
530
+ type="devices",
531
+ data={"hostname": "device1", "vendor": "cisco", "model": "ISR4331"},
532
+ ),
533
+ IPFabricData(
534
+ snapshot_data=snapshot,
535
+ type="interfaces",
536
+ data={"name": "GigabitEthernet0/0/0", "type": "ethernet"},
537
+ ),
538
+ IPFabricData(
539
+ snapshot_data=snapshot,
540
+ type="sites",
541
+ data={"name": "Main Site", "location": "New York"},
542
+ ),
543
+ )
544
+ for data_instance in data_instances:
545
+ data_instance.save()
546
+
547
+ cls.form_data = {
548
+ "snapshot_data": snapshot.pk,
549
+ "type": "devices",
550
+ "data": '{"hostname": "test-device", "vendor": "juniper"}',
551
+ }
552
+
553
+ cls.csv_data = (
554
+ "snapshot_data,type,data",
555
+ f'{snapshot.pk},devices,"{{\\"hostname\\": \\"csv-device1\\", \\"vendor\\": \\"cisco\\"}}"', # noqa: E231
556
+ f'{snapshot.pk},interfaces,"{{\\"name\\": \\"Eth0/0\\", \\"type\\": \\"ethernet\\"}}"', # noqa: E231
557
+ f'{snapshot.pk},sites,"{{\\"name\\": \\"CSV Site\\", \\"location\\": \\"Boston\\"}}"', # noqa: E231
558
+ )
559
+
560
+ cls.csv_update_data = (
561
+ "id,type,data",
562
+ f'{data_instances[0].pk},devices,"{{\\"hostname\\": \\"updated-device1\\", \\"vendor\\": \\"juniper\\"}}"', # noqa: E231
563
+ f'{data_instances[1].pk},interfaces,"{{\\"name\\": \\"Updated-Eth0/0\\", \\"type\\": \\"ethernet\\"}}"', # noqa: E231
564
+ f'{data_instances[2].pk},sites,"{{\\"name\\": \\"Updated Site\\", \\"location\\": \\"Chicago\\"}}"', # noqa: E231
565
+ )
566
+
567
+ cls.bulk_edit_data = {
568
+ "type": "devices",
569
+ }
570
+
571
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
572
+ def test_get_json(self):
573
+ data = self._get_queryset().first()
574
+ response = self.client.get(
575
+ # No need to add +"json/" thanks to path="json" in @register_model_view
576
+ data.get_absolute_url()
577
+ )
578
+ self.assertHttpStatus(response, 200)
579
+ # Verify response contains expected content
580
+ self.assertContains(response, "JSON Output")
581
+ # Verify the data contains expected device information
582
+ for value in data.data.values():
583
+ self.assertContains(response, value)
584
+
585
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
586
+ def test_get_json_htmx(self):
587
+ data = self._get_queryset().first()
588
+ response = self.client.get(
589
+ # No need to add +"json/" thanks to path="json" in @register_model_view
590
+ data.get_absolute_url(),
591
+ **{"HTTP_HX-Request": "true"},
592
+ )
593
+ self.assertHttpStatus(response, 200)
594
+ # Verify HTMX response contains expected content
595
+ self.assertContains(response, "JSON Output")
596
+ # Verify the data contains expected device information
597
+ for value in data.data.values():
598
+ self.assertContains(response, value)
599
+
600
+
601
+ class IPFabricSyncTestCase(
602
+ PluginPathMixin,
603
+ ViewTestCases.GetObjectViewTestCase,
604
+ ViewTestCases.GetObjectChangelogViewTestCase,
605
+ ViewTestCases.CreateObjectViewTestCase,
606
+ ViewTestCases.EditObjectViewTestCase,
607
+ ViewTestCases.DeleteObjectViewTestCase,
608
+ ViewTestCases.ListObjectsViewTestCase,
609
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
610
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
611
+ # ViewTestCases.BulkEditObjectsViewTestCase,
612
+ # ViewTestCases.BulkImportObjectsViewTestCase,
613
+ ):
614
+ model = IPFabricSync
615
+ user_permissions = ("ipfabric_netbox.start_ipfabricsync",)
616
+
617
+ @classmethod
618
+ def setUpTestData(cls):
619
+ def get_parameters() -> dict:
620
+ """Create dict of randomized but expected parameters for testing."""
621
+ parameters = {}
622
+ for param in list(ipam_parameters.keys()) + list(dcim_parameters.keys()):
623
+ parameters[param] = bool(random.getrandbits(1))
624
+ return parameters
625
+
626
+ # Create required dependencies
627
+ source = IPFabricSource.objects.create(
628
+ name="Test Source",
629
+ type=IPFabricSourceTypeChoices.LOCAL,
630
+ url="https://ipfabric.example.com",
631
+ status=DataSourceStatusChoices.NEW,
632
+ )
633
+
634
+ snapshot1 = IPFabricSnapshot.objects.create(
635
+ source=source,
636
+ name="Test Snapshot 1",
637
+ snapshot_id="sync_snap001",
638
+ data={"version": "6.0.0", "sites": ["Site A"], "devices": 100},
639
+ date=timezone.now(),
640
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
641
+ )
642
+
643
+ snapshot2 = IPFabricSnapshot.objects.create(
644
+ source=source,
645
+ name="Test Snapshot 2",
646
+ snapshot_id="sync_snap002",
647
+ data={"version": "6.0.1", "sites": ["Site B"], "devices": 120},
648
+ date=timezone.now(),
649
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
650
+ )
651
+
652
+ # Create three IPFabricSync instances for testing
653
+ cls.syncs = (
654
+ IPFabricSync(
655
+ name="Sync Job 1",
656
+ snapshot_data=snapshot1,
657
+ status=DataSourceStatusChoices.NEW,
658
+ parameters=get_parameters(),
659
+ last_synced=timezone.now(),
660
+ ),
661
+ IPFabricSync(
662
+ name="Sync Job 2",
663
+ snapshot_data=snapshot1,
664
+ status=DataSourceStatusChoices.COMPLETED,
665
+ parameters=get_parameters(),
666
+ last_synced=timezone.now(),
667
+ ),
668
+ IPFabricSync(
669
+ name="Sync Job 3",
670
+ snapshot_data=snapshot2,
671
+ status=DataSourceStatusChoices.FAILED,
672
+ parameters=get_parameters(),
673
+ ),
674
+ )
675
+ for sync in cls.syncs:
676
+ sync.save()
677
+ job = Job.objects.create(
678
+ job_id=uuid4(),
679
+ name="Test Ingestion Job 1",
680
+ object_id=sync.pk,
681
+ object_type=ContentType.objects.get_for_model(IPFabricSync),
682
+ status=JobStatusChoices.STATUS_COMPLETED,
683
+ completed=timezone.now(),
684
+ created=timezone.now(),
685
+ )
686
+ IPFabricIngestion.objects.create(
687
+ sync=sync,
688
+ job=job,
689
+ )
690
+
691
+ cls.form_data = {
692
+ "name": "Test Sync X",
693
+ "source": source.pk,
694
+ "snapshot_data": snapshot1.pk,
695
+ "auto_merge": False,
696
+ "update_custom_fields": True,
697
+ **{f"ipf_{k}": v for k, v in get_parameters().items()},
698
+ }
699
+
700
+ cls.csv_data = (
701
+ "name,snapshot_data,status,parameters",
702
+ f'Sync CSV 1,{snapshot1.pk},{DataSourceStatusChoices.NEW},"{{\\"auto_merge\\": true}}"', # noqa: E231
703
+ f'Sync CSV 2,{snapshot1.pk},{DataSourceStatusChoices.COMPLETED},"{{\\"auto_merge\\": false}}"', # noqa: E231
704
+ f'Sync CSV 3,{snapshot2.pk},{DataSourceStatusChoices.NEW},"{{\\"auto_merge\\": true}}"', # noqa: E231
705
+ )
706
+
707
+ cls.csv_update_data = (
708
+ "id,name,status",
709
+ f"{cls.syncs[0].pk},Updated Sync 1,{DataSourceStatusChoices.COMPLETED}", # noqa: E231
710
+ f"{cls.syncs[1].pk},Updated Sync 2,{DataSourceStatusChoices.FAILED}", # noqa: E231
711
+ f"{cls.syncs[2].pk},Updated Sync 3,{DataSourceStatusChoices.COMPLETED}", # noqa: E231
712
+ )
713
+
714
+ cls.bulk_edit_data = {
715
+ "auto_merge": True,
716
+ }
717
+
718
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
719
+ def test_get_htmx_request(self):
720
+ instance = self._get_queryset().first()
721
+ # Try GET with HTMX
722
+ response = self.client.get(
723
+ instance.get_absolute_url(), **{"HTTP_HX-Request": "true"}
724
+ )
725
+ self.assertHttpStatus(response, 200)
726
+
727
+ # Verify HTMX response doesn't include full page structure
728
+ self.assertNotContains(response, "<html>")
729
+ self.assertNotContains(response, "<head>")
730
+
731
+ # Verify the response contains the sync instance data
732
+ self.assertContains(response, instance.name)
733
+ self.assertContains(response, instance.last_ingestion.name)
734
+ self.assertContains(response, instance.last_ingestion.get_absolute_url())
735
+
736
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
737
+ def test_get_yaml_format(self):
738
+ self.assertIsNone(self.user.config.get("data_format"))
739
+
740
+ instance = self._get_queryset().first()
741
+
742
+ # Try GET with yaml format
743
+ self.assertHttpStatus(
744
+ self.client.get(
745
+ instance.get_absolute_url(), query_params={"format": "yaml"}
746
+ ),
747
+ 200,
748
+ )
749
+ self.user.refresh_from_db()
750
+ self.assertTrue(self.user.is_authenticated)
751
+ self.assertEqual(self.user.config.get("data_format"), "yaml")
752
+
753
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
754
+ def test_get_transformmaps(self):
755
+ sync = self._get_queryset().first()
756
+ response = self.client.get(sync.get_absolute_url() + "transformmaps/")
757
+ self.assertHttpStatus(response, 200)
758
+
759
+ # Check that context contains the sync object
760
+ self.assertIn("object", response.context)
761
+ self.assertEqual(response.context["object"], sync)
762
+
763
+ # Check if transform maps are displayed if they exist
764
+ if hasattr(sync, "transform_maps") and sync.transform_maps.exists():
765
+ for transform_map in sync.transform_maps.all():
766
+ self.assertContains(response, transform_map.name)
767
+
768
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
769
+ def test_get_ingestions(self):
770
+ sync = self._get_queryset().first()
771
+ response = self.client.get(sync.get_absolute_url() + "ingestion/")
772
+ self.assertHttpStatus(response, 200)
773
+
774
+ # Verify the response contains expected elements
775
+ self.assertContains(response, "Ingestions")
776
+
777
+ # Check that context contains the sync object
778
+ self.assertIn("object", response.context)
779
+ self.assertEqual(response.context["object"], sync)
780
+
781
+ # Check if ingestions are displayed if they exist
782
+ ingestions = sync.ipfabricingestion_set.all()
783
+ if ingestions.exists():
784
+ for ingestion in ingestions:
785
+ self.assertContains(response, str(ingestion))
786
+
787
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
788
+ def test_sync_view_get_redirect(self):
789
+ """Test that GET request to sync view redirects to sync detail page."""
790
+ sync = self._get_queryset().first()
791
+ response = self.client.get(sync.get_absolute_url() + "sync/")
792
+ self.assertHttpStatus(response, 302)
793
+ self.assertRedirects(response, sync.get_absolute_url())
794
+
795
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
796
+ @patch("ipfabric_netbox.models.IPFabricSync.enqueue_sync_job")
797
+ def test_sync_view_post_valid(self, mock_enqueue_sync_job):
798
+ """Test POST request to sync view successfully enqueues sync job."""
799
+
800
+ # Set up mock job
801
+ mock_job = Job(pk=123, name="Test Sync Job")
802
+ mock_enqueue_sync_job.return_value = mock_job
803
+
804
+ sync = self._get_queryset().first()
805
+
806
+ response = self.client.post(sync.get_absolute_url() + "sync/", follow=True)
807
+
808
+ # Should redirect to sync detail page
809
+ self.assertHttpStatus(response, 200)
810
+ self.assertRedirects(response, sync.get_absolute_url())
811
+
812
+ # Should have called enqueue_sync_job with correct parameters
813
+ mock_enqueue_sync_job.assert_called_once()
814
+ call_args = mock_enqueue_sync_job.call_args
815
+ self.assertEqual(call_args[1]["user"], self.user)
816
+ self.assertEqual(call_args[1]["adhoc"], True)
817
+
818
+ # Should show success message
819
+ messages = list(response.context["messages"])
820
+ self.assertEqual(len(messages), 1)
821
+ self.assertIn(f"Queued job #{mock_job.pk}", str(messages[0]))
822
+
823
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
824
+ def test_sync_view_nonexistent_sync(self):
825
+ """Test sync view with non-existent sync returns 404."""
826
+ nonexistent_pk = 99999
827
+ response = self.client.post(
828
+ f"/plugins/ipfabric-netbox/ipfabricsync/{nonexistent_pk}/sync/"
829
+ )
830
+ self.assertHttpStatus(response, 404)
831
+
832
+
833
+ class IPFabricTransformMapGroupTestCase(
834
+ PluginPathMixin,
835
+ ViewTestCases.GetObjectViewTestCase,
836
+ ViewTestCases.GetObjectChangelogViewTestCase,
837
+ ViewTestCases.CreateObjectViewTestCase,
838
+ ViewTestCases.EditObjectViewTestCase,
839
+ ViewTestCases.DeleteObjectViewTestCase,
840
+ ViewTestCases.ListObjectsViewTestCase,
841
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
842
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
843
+ # ViewTestCases.BulkEditObjectsViewTestCase,
844
+ # ViewTestCases.BulkImportObjectsViewTestCase,
845
+ ):
846
+ model = IPFabricTransformMapGroup
847
+
848
+ @classmethod
849
+ def setUpTestData(cls):
850
+ # Create three IPFabricTransformMapGroup instances for testing
851
+ groups = (
852
+ IPFabricTransformMapGroup(
853
+ name="Device Transform Group",
854
+ description="Group for device transformations",
855
+ ),
856
+ IPFabricTransformMapGroup(
857
+ name="Interface Transform Group",
858
+ description="Group for interface transformations",
859
+ ),
860
+ IPFabricTransformMapGroup(
861
+ name="IP Address Transform Group",
862
+ description="Group for IP address transformations",
863
+ ),
864
+ )
865
+ for group in groups:
866
+ group.save()
867
+
868
+ cls.form_data = {
869
+ "name": "Test Transform Group X",
870
+ "description": "Test group description",
871
+ }
872
+
873
+
874
+ class IPFabricTransformMapTestCase(
875
+ PluginPathMixin,
876
+ ViewTestCases.GetObjectViewTestCase,
877
+ ViewTestCases.GetObjectChangelogViewTestCase,
878
+ ViewTestCases.CreateObjectViewTestCase,
879
+ ViewTestCases.EditObjectViewTestCase,
880
+ ViewTestCases.DeleteObjectViewTestCase,
881
+ ViewTestCases.ListObjectsViewTestCase,
882
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
883
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
884
+ # ViewTestCases.BulkEditObjectsViewTestCase,
885
+ # ViewTestCases.BulkImportObjectsViewTestCase,
886
+ ):
887
+ model = IPFabricTransformMap
888
+ user_permissions = (
889
+ "ipfabric_netbox.clone_ipfabrictransformmap",
890
+ "ipfabric_netbox.restore_ipfabrictransformmap",
891
+ )
892
+
893
+ @classmethod
894
+ def setUpTestData(cls):
895
+ # Number of IPFabricTransformMaps created during migration
896
+ cls.default_maps_count = 14
897
+
898
+ # Create required dependencies
899
+ group = IPFabricTransformMapGroup.objects.create(
900
+ name="Test Group",
901
+ description="Test group for transform maps",
902
+ )
903
+ cls.clone_group = IPFabricTransformMapGroup.objects.create(
904
+ name="Test Cloning Group",
905
+ description="Test group for cloning transform maps",
906
+ )
907
+
908
+ # Create IPFabricTransformMap instances with groups for testing
909
+ maps = (
910
+ IPFabricTransformMap(
911
+ name="Device Transform Map",
912
+ source_model="device",
913
+ target_model=ContentType.objects.get(app_label="dcim", model="device"),
914
+ group=group,
915
+ ),
916
+ IPFabricTransformMap(
917
+ name="Site Transform Map",
918
+ source_model="site",
919
+ target_model=ContentType.objects.get(app_label="dcim", model="site"),
920
+ group=group,
921
+ ),
922
+ )
923
+ for map_obj in maps:
924
+ map_obj.save()
925
+
926
+ cls.form_data = {
927
+ "name": "Test Transform Map X",
928
+ "source_model": "device",
929
+ "target_model": ContentType.objects.get(
930
+ app_label="dcim", model="manufacturer"
931
+ ).pk,
932
+ "group": group.pk,
933
+ }
934
+
935
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
936
+ def test_get_relationships(self):
937
+ transform_map = self._get_queryset().first()
938
+ response = self.client.get(transform_map.get_absolute_url() + "relationships/")
939
+ self.assertHttpStatus(response, 200)
940
+
941
+ # Check that context contains the transform map object
942
+ self.assertIn("object", response.context)
943
+ self.assertEqual(response.context["object"], transform_map)
944
+
945
+ # Verify the template used is correct (using the actual template)
946
+ self.assertTemplateUsed(
947
+ response, "ipfabric_netbox/inc/transform_map_relationship_map.html"
948
+ )
949
+
950
+ # Check if relationship fields are displayed if they exist
951
+ if transform_map.relationship_maps.exists():
952
+ for relationship in transform_map.relationship_maps.all():
953
+ self.assertContains(response, relationship.target_field)
954
+
955
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
956
+ def test_get_fields(self):
957
+ transform_map = self._get_queryset().first()
958
+ response = self.client.get(transform_map.get_absolute_url() + "fields/")
959
+ self.assertHttpStatus(response, 200)
960
+
961
+ # Check that context contains the transform map object
962
+ self.assertIn("object", response.context)
963
+ self.assertEqual(response.context["object"], transform_map)
964
+
965
+ # Verify the template used is correct (using the actual template)
966
+ self.assertTemplateUsed(
967
+ response, "ipfabric_netbox/inc/transform_map_field_map.html"
968
+ )
969
+
970
+ # Check if transform fields are displayed if they exist
971
+ if transform_map.field_maps.exists():
972
+ for field in transform_map.field_maps.all():
973
+ self.assertContains(response, field.source_field)
974
+ self.assertContains(response, field.target_field)
975
+
976
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
977
+ def test_clone_view_get_redirect(self):
978
+ """Test that GET request to clone view redirects to transform map detail page."""
979
+ transform_map = self._get_queryset().first()
980
+ response = self.client.get(transform_map.get_absolute_url() + "clone/")
981
+ self.assertHttpStatus(response, 302)
982
+ self.assertRedirects(response, transform_map.get_absolute_url())
983
+
984
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
985
+ def test_clone_view_get_htmx(self):
986
+ """Test that HTMX GET request to clone view returns form."""
987
+ transform_map = self._get_queryset().first()
988
+ response = self.client.get(
989
+ transform_map.get_absolute_url() + "clone/",
990
+ **{"HTTP_HX-Request": "true"},
991
+ )
992
+ self.assertHttpStatus(response, 200)
993
+ self.assertContains(response, f"Clone of {transform_map.name}")
994
+
995
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
996
+ def test_clone_view_post_valid_form(self):
997
+ """Test POST request with valid form data successfully clones transform map."""
998
+ # Get a TransformMap with at least one field and one relationship
999
+ transform_map = None
1000
+ for transform_map in self._get_queryset():
1001
+ if (
1002
+ transform_map.field_maps.count() > 0
1003
+ and transform_map.relationship_maps.count() > 0
1004
+ ):
1005
+ break
1006
+ transform_map = None
1007
+ self.assertIsNotNone(transform_map)
1008
+
1009
+ # Valid form data
1010
+ form_data = {
1011
+ "name": "Cloned Transform Map",
1012
+ "group": self.clone_group.pk,
1013
+ "clone_fields": True,
1014
+ "clone_relationships": True,
1015
+ }
1016
+
1017
+ response = self.client.post(
1018
+ transform_map.get_absolute_url() + "clone/",
1019
+ data=form_data,
1020
+ follow=True,
1021
+ **{"HTTP_HX-Request": "true"},
1022
+ )
1023
+
1024
+ # Should redirect to old transform map detail page since it's HTMX
1025
+ self.assertHttpStatus(response, 200)
1026
+ self.assertIn("HX-Redirect", response)
1027
+
1028
+ # Verify new transform map was created
1029
+ cloned_map = IPFabricTransformMap.objects.get(name="Cloned Transform Map")
1030
+ self.assertEqual(cloned_map.source_model, transform_map.source_model)
1031
+ self.assertEqual(cloned_map.target_model, transform_map.target_model)
1032
+ self.assertNotEqual(cloned_map.group, transform_map.group)
1033
+
1034
+ # Verify fields were cloned
1035
+ self.assertEqual(
1036
+ IPFabricTransformField.objects.filter(transform_map=cloned_map).count(),
1037
+ transform_map.field_maps.count(),
1038
+ )
1039
+
1040
+ # Verify relationships were cloned
1041
+ self.assertEqual(
1042
+ IPFabricRelationshipField.objects.filter(transform_map=cloned_map).count(),
1043
+ transform_map.relationship_maps.count(),
1044
+ )
1045
+
1046
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1047
+ def test_clone_view_post_without_fields_and_relationships(self):
1048
+ """Test POST request with clone_fields=False and clone_relationships=False."""
1049
+ transform_map = self._get_queryset().first()
1050
+
1051
+ # Create some fields and relationships
1052
+ IPFabricTransformField.objects.create(
1053
+ transform_map=transform_map,
1054
+ source_field="vendor",
1055
+ target_field="manufacturer",
1056
+ template="{{ object.vendor }}",
1057
+ )
1058
+ IPFabricRelationshipField.objects.create(
1059
+ transform_map=transform_map,
1060
+ source_model=ContentType.objects.get(app_label="dcim", model="site"),
1061
+ target_field="site",
1062
+ template="{{ object.site_id }}",
1063
+ )
1064
+
1065
+ # Form data without cloning fields and relationships
1066
+ form_data = {
1067
+ "name": "Cloned Map No Fields",
1068
+ "clone_fields": False,
1069
+ "clone_relationships": False,
1070
+ "group": self.clone_group.pk,
1071
+ }
1072
+
1073
+ response = self.client.post(
1074
+ transform_map.get_absolute_url() + "clone/",
1075
+ data=form_data,
1076
+ follow=True,
1077
+ )
1078
+
1079
+ cloned_map = IPFabricTransformMap.objects.get(name="Cloned Map No Fields")
1080
+
1081
+ # Should redirect to new transform map detail page
1082
+ self.assertHttpStatus(response, 200)
1083
+ self.assertRedirects(response, cloned_map.get_absolute_url())
1084
+
1085
+ # Verify new transform map was created but without fields/relationships
1086
+ self.assertEqual(cloned_map.source_model, transform_map.source_model)
1087
+ self.assertEqual(cloned_map.target_model, transform_map.target_model)
1088
+
1089
+ # Verify fields were not cloned
1090
+ self.assertEqual(
1091
+ IPFabricTransformField.objects.filter(transform_map=cloned_map).count(), 0
1092
+ )
1093
+
1094
+ # Verify relationships were not cloned
1095
+ self.assertEqual(
1096
+ IPFabricRelationshipField.objects.filter(transform_map=cloned_map).count(),
1097
+ 0,
1098
+ )
1099
+
1100
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1101
+ def test_clone_view_post_htmx_valid(self):
1102
+ """Test HTMX POST request with valid form data."""
1103
+ transform_map = self._get_queryset().first()
1104
+
1105
+ form_data = {
1106
+ "name": "HTMX Cloned Map",
1107
+ "clone_fields": False,
1108
+ "clone_relationships": False,
1109
+ "group": self.clone_group.pk,
1110
+ }
1111
+
1112
+ response = self.client.post(
1113
+ transform_map.get_absolute_url() + "clone/",
1114
+ data=form_data,
1115
+ **{"HTTP_HX-Request": "true"},
1116
+ )
1117
+
1118
+ # Should return HX-Redirect header
1119
+ self.assertHttpStatus(response, 200)
1120
+ self.assertIn("HX-Redirect", response)
1121
+
1122
+ # Verify new transform map was created
1123
+ cloned_map = IPFabricTransformMap.objects.get(name="HTMX Cloned Map")
1124
+ self.assertEqual(cloned_map.source_model, transform_map.source_model)
1125
+
1126
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1127
+ def test_clone_view_post_invalid_form(self):
1128
+ """Test POST request with invalid form data."""
1129
+ transform_map = self._get_queryset().first()
1130
+
1131
+ # Invalid form data - missing name
1132
+ form_data = {
1133
+ "group": self.clone_group.pk,
1134
+ "clone_fields": True,
1135
+ "clone_relationships": True,
1136
+ }
1137
+
1138
+ response = self.client.post(
1139
+ transform_map.get_absolute_url() + "clone/",
1140
+ data=form_data,
1141
+ )
1142
+
1143
+ # Should show form errors
1144
+ self.assertHttpStatus(response, 200)
1145
+ self.assertContains(response, "This field is required")
1146
+
1147
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1148
+ def test_clone_view_post_invalid_form_htmx(self):
1149
+ """Test POST request with invalid form data."""
1150
+ transform_map = IPFabricTransformMap.objects.filter(group__isnull=False).first()
1151
+
1152
+ # Invalid form data - same group as original
1153
+ form_data = {
1154
+ "name": "HTMX Cloned Map",
1155
+ "group": transform_map.group.pk,
1156
+ "clone_fields": True,
1157
+ "clone_relationships": True,
1158
+ }
1159
+
1160
+ response = self.client.post(
1161
+ transform_map.get_absolute_url() + "clone/",
1162
+ data=form_data,
1163
+ **{"HTTP_HX-Request": "true"},
1164
+ )
1165
+
1166
+ # Should show form errors
1167
+ self.assertHttpStatus(response, 200)
1168
+ self.assertContains(
1169
+ response, "A transform map with this group and target model already exists."
1170
+ )
1171
+ self.assertIn("X-Debug-HTMX-Partial", response)
1172
+
1173
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1174
+ def test_clone_view_nonexistent_transform_map(self):
1175
+ """Test clone view with non-existent transform map returns 404."""
1176
+ nonexistent_pk = 99999
1177
+ response = self.client.get(
1178
+ f"/plugins/ipfabric-netbox/ipfabrictransformmap/{nonexistent_pk}/clone/"
1179
+ )
1180
+ self.assertHttpStatus(response, 404)
1181
+
1182
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1183
+ def test_clone_view_post_general_validation_error(self):
1184
+ transform_map = self._get_queryset().first()
1185
+
1186
+ # Use valid form data but patch full_clean to raise ValidationError without error_dict
1187
+ form_data = {
1188
+ "name": "Test Clone General Error",
1189
+ "clone_fields": False,
1190
+ "clone_relationships": False,
1191
+ "group": self.clone_group.pk,
1192
+ }
1193
+
1194
+ # Patch full_clean to raise ValidationError without error_dict
1195
+ with patch.object(
1196
+ IPFabricTransformMap,
1197
+ "full_clean",
1198
+ side_effect=ValidationError("Test error"),
1199
+ ):
1200
+ response = self.client.post(
1201
+ transform_map.get_absolute_url() + "clone/",
1202
+ data=form_data,
1203
+ )
1204
+
1205
+ # Should show form with general error
1206
+ self.assertHttpStatus(response, 200)
1207
+ self.assertContains(response, "Test error")
1208
+
1209
+ # Verify no new transform map was created due to validation error
1210
+ self.assertFalse(
1211
+ IPFabricTransformMap.objects.filter(
1212
+ name="Test Clone General Error"
1213
+ ).exists()
1214
+ )
1215
+
1216
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1217
+ def test_restore_view_get_non_htmx(self):
1218
+ """Test that GET request to restore view without HTMX returns empty response."""
1219
+ response = self.client.get(self._get_url(action="restore"))
1220
+ self.assertHttpStatus(response, 302)
1221
+
1222
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1223
+ def test_restore_view_get_htmx(self):
1224
+ """Test that HTMX GET request to restore view returns confirmation form."""
1225
+ response = self.client.get(
1226
+ self._get_url(action="restore"),
1227
+ **{"HTTP_HX-Request": "true"},
1228
+ )
1229
+
1230
+ self.assertHttpStatus(response, 200)
1231
+ self.assertContains(response, self._get_url(action="restore"))
1232
+ # Check that dependent objects are included in context
1233
+ self.assertIn("dependent_objects", response.context)
1234
+ self.assertIn("form", response.context)
1235
+ self.assertIn("form_url", response.context)
1236
+
1237
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1238
+ def test_restore_view_post_success(self):
1239
+ """Test POST request to restore view successfully deletes ungrouped maps and rebuilds them."""
1240
+ default_maps = list(IPFabricTransformMap.objects.filter(group__isnull=True))
1241
+ self.assertEqual(len(default_maps), self.default_maps_count)
1242
+
1243
+ # Remove all transform maps created in migrations
1244
+ IPFabricTransformMap.objects.filter(group__isnull=True).delete()
1245
+ self.assertEqual(
1246
+ IPFabricTransformMap.objects.filter(group__isnull=True).count(), 0
1247
+ )
1248
+
1249
+ response = self.client.post(
1250
+ self._get_url(action="restore"),
1251
+ follow=True,
1252
+ )
1253
+
1254
+ # Should redirect to transform map list
1255
+ self.assertHttpStatus(response, 200)
1256
+ self.assertRedirects(response, "/plugins/ipfabric/transform-map/")
1257
+
1258
+ # Verify ungrouped transform maps were restored
1259
+ self.assertEqual(
1260
+ IPFabricTransformMap.objects.filter(group__isnull=True).count(),
1261
+ self.default_maps_count,
1262
+ )
1263
+ for map in default_maps:
1264
+ self.assertTrue(
1265
+ IPFabricTransformMap.objects.filter(
1266
+ name=map.name,
1267
+ source_model=map.source_model,
1268
+ target_model=map.target_model,
1269
+ group__isnull=True,
1270
+ ).exists()
1271
+ )
1272
+
1273
+ # Verify grouped transform map still exists
1274
+ self.assertGreater(
1275
+ IPFabricTransformMap.objects.filter(group__isnull=True).count(), 0
1276
+ )
1277
+
1278
+ def test_restore_view_requires_permission(self):
1279
+ """Test that restore view requires 'ipfabric_netbox.tm_restore' permission."""
1280
+ # Test without required permission
1281
+ response = self.client.get(self._get_url(action="restore"))
1282
+ # Should get permission denied (403) or redirect to login depending on settings
1283
+ self.assertIn(response.status_code, [302, 403])
1284
+
1285
+ # Test POST without required permission
1286
+ response = self.client.post(self._get_url(action="restore"))
1287
+ # Should get permission denied (403) or redirect to login depending on settings
1288
+ self.assertIn(response.status_code, [302, 403])
1289
+
1290
+
1291
+ # TODO: These have no detail view, so it's failing of finding reverse URL
1292
+ # class IPFabricTransformFieldTestCase(
1293
+ # PluginPathMixin,
1294
+ # # ViewTestCases.GetObjectViewTestCase,
1295
+ # # ViewTestCases.GetObjectChangelogViewTestCase,
1296
+ # ViewTestCases.CreateObjectViewTestCase,
1297
+ # ViewTestCases.EditObjectViewTestCase,
1298
+ # ViewTestCases.DeleteObjectViewTestCase,
1299
+ # ViewTestCases.ListObjectsViewTestCase,
1300
+ # ViewTestCases.BulkDeleteObjectsViewTestCase,
1301
+ # # ViewTestCases.BulkRenameObjectsViewTestCase,
1302
+ # # ViewTestCases.BulkEditObjectsViewTestCase,
1303
+ # # ViewTestCases.BulkImportObjectsViewTestCase,
1304
+ # ):
1305
+ # model = IPFabricTransformField
1306
+ #
1307
+ # @classmethod
1308
+ # def setUpTestData(cls):
1309
+ # # Create required dependencies
1310
+ # group = IPFabricTransformMapGroup.objects.create(
1311
+ # name="Test Group",
1312
+ # description="Test group for transform fields",
1313
+ # )
1314
+ #
1315
+ # transform_map = IPFabricTransformMap.objects.create(
1316
+ # name="Test Transform Map",
1317
+ # source_model="devices",
1318
+ # target_model=ContentType.objects.get(app_label="dcim", model="device"),
1319
+ # group=group,
1320
+ # )
1321
+ #
1322
+ # # Create three IPFabricTransformField instances for testing
1323
+ # fields = (
1324
+ # IPFabricTransformField(
1325
+ # transform_map=transform_map,
1326
+ # source_field="hostname",
1327
+ # target_field="name",
1328
+ # coalesce=False,
1329
+ # template="{{ object.hostname }}",
1330
+ # ),
1331
+ # IPFabricTransformField(
1332
+ # transform_map=transform_map,
1333
+ # source_field="vendor",
1334
+ # target_field="manufacturer",
1335
+ # coalesce=True,
1336
+ # template="{{ object.vendor }}",
1337
+ # ),
1338
+ # IPFabricTransformField(
1339
+ # transform_map=transform_map,
1340
+ # source_field="model",
1341
+ # target_field="device_type",
1342
+ # coalesce=False,
1343
+ # template="{{ object.model }}",
1344
+ # ),
1345
+ # )
1346
+ # for field in fields:
1347
+ # field.save()
1348
+ #
1349
+ # cls.form_data = {
1350
+ # "transform_map": transform_map.pk,
1351
+ # "source_field": "serial_number",
1352
+ # "target_field": "serial",
1353
+ # "coalesce": False,
1354
+ # "template": "{{ object.serial_number }}",
1355
+ # }
1356
+ #
1357
+ #
1358
+ # class IPFabricRelationshipFieldTestCase(
1359
+ # PluginPathMixin,
1360
+ # # ViewTestCases.GetObjectViewTestCase,
1361
+ # # ViewTestCases.GetObjectChangelogViewTestCase,
1362
+ # ViewTestCases.CreateObjectViewTestCase,
1363
+ # ViewTestCases.EditObjectViewTestCase,
1364
+ # ViewTestCases.DeleteObjectViewTestCase,
1365
+ # ViewTestCases.ListObjectsViewTestCase,
1366
+ # ViewTestCases.BulkDeleteObjectsViewTestCase,
1367
+ # # ViewTestCases.BulkRenameObjectsViewTestCase,
1368
+ # # ViewTestCases.BulkEditObjectsViewTestCase,
1369
+ # # ViewTestCases.BulkImportObjectsViewTestCase,
1370
+ # ):
1371
+ # model = IPFabricRelationshipField
1372
+ #
1373
+ # @classmethod
1374
+ # def setUpTestData(cls):
1375
+ # # Create required dependencies
1376
+ # group = IPFabricTransformMapGroup.objects.create(
1377
+ # name="Test Group",
1378
+ # description="Test group for relationship fields",
1379
+ # )
1380
+ #
1381
+ # device_ct = ContentType.objects.get(app_label="dcim", model="device")
1382
+ # site_ct = ContentType.objects.get(app_label="dcim", model="site")
1383
+ #
1384
+ # transform_map = IPFabricTransformMap.objects.create(
1385
+ # name="Test Transform Map",
1386
+ # source_model="devices",
1387
+ # target_model=device_ct,
1388
+ # group=group,
1389
+ # )
1390
+ #
1391
+ # # Create three IPFabricRelationshipField instances for testing
1392
+ # fields = (
1393
+ # IPFabricRelationshipField(
1394
+ # transform_map=transform_map,
1395
+ # source_model=site_ct,
1396
+ # target_field="site",
1397
+ # coalesce=False,
1398
+ # template="{{ object.site_id }}",
1399
+ # ),
1400
+ # IPFabricRelationshipField(
1401
+ # transform_map=transform_map,
1402
+ # source_model=device_ct,
1403
+ # target_field="parent_device",
1404
+ # coalesce=True,
1405
+ # template="{{ object.parent_id }}",
1406
+ # ),
1407
+ # IPFabricRelationshipField(
1408
+ # transform_map=transform_map,
1409
+ # source_model=site_ct,
1410
+ # target_field="location",
1411
+ # coalesce=False,
1412
+ # template="{{ object.location_id }}",
1413
+ # ),
1414
+ # )
1415
+ # for field in fields:
1416
+ # field.save()
1417
+ #
1418
+ # cls.form_data = {
1419
+ # "transform_map": transform_map.pk,
1420
+ # "source_model": site_ct.pk,
1421
+ # "target_field": "site",
1422
+ # "coalesce": False,
1423
+ # "template": "{{ object.site_id }}",
1424
+ # }
1425
+
1426
+
1427
+ class IPFabricIngestionTestCase(
1428
+ PluginPathMixin,
1429
+ ViewTestCases.GetObjectViewTestCase,
1430
+ # ViewTestCases.GetObjectChangelogViewTestCase,
1431
+ # ViewTestCases.CreateObjectViewTestCase,
1432
+ # ViewTestCases.EditObjectViewTestCase,
1433
+ # ViewTestCases.DeleteObjectViewTestCase,
1434
+ ViewTestCases.ListObjectsViewTestCase,
1435
+ # ViewTestCases.BulkDeleteObjectsViewTestCase,
1436
+ # ViewTestCases.BulkRenameObjectsViewTestCase,
1437
+ # ViewTestCases.BulkEditObjectsViewTestCase,
1438
+ # ViewTestCases.BulkImportObjectsViewTestCase,
1439
+ ):
1440
+ model = IPFabricIngestion
1441
+ user_permissions = ("ipfabric_netbox.merge_ipfabricingestion",)
1442
+
1443
+ @classmethod
1444
+ def setUpTestData(cls):
1445
+ # Create required dependencies
1446
+ source = IPFabricSource.objects.create(
1447
+ name="Test Source",
1448
+ type=IPFabricSourceTypeChoices.LOCAL,
1449
+ url="https://ipfabric.example.com",
1450
+ status=DataSourceStatusChoices.NEW,
1451
+ )
1452
+
1453
+ snapshot = IPFabricSnapshot.objects.create(
1454
+ source=source,
1455
+ name="Test Snapshot",
1456
+ snapshot_id="ingest_snap001",
1457
+ data={"devices": 100},
1458
+ date=timezone.now(),
1459
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
1460
+ )
1461
+
1462
+ # Create Sync instances for each ingestion
1463
+ sync1 = IPFabricSync.objects.create(
1464
+ name="Test Sync 1",
1465
+ snapshot_data=snapshot,
1466
+ status=DataSourceStatusChoices.NEW,
1467
+ parameters={"batch_size": 100},
1468
+ last_synced=timezone.now(),
1469
+ )
1470
+
1471
+ sync2 = IPFabricSync.objects.create(
1472
+ name="Test Sync 2",
1473
+ snapshot_data=snapshot,
1474
+ status=DataSourceStatusChoices.COMPLETED,
1475
+ parameters={"batch_size": 200},
1476
+ last_synced=timezone.now(),
1477
+ )
1478
+
1479
+ sync3 = IPFabricSync.objects.create(
1480
+ name="Test Sync 3",
1481
+ snapshot_data=snapshot,
1482
+ status=DataSourceStatusChoices.FAILED,
1483
+ parameters={"batch_size": 50},
1484
+ )
1485
+
1486
+ # Create Job instances for each ingestion
1487
+ job1 = Job.objects.create(
1488
+ job_id=uuid4(),
1489
+ name="Test Ingestion Job 1",
1490
+ object_id=sync1.pk,
1491
+ object_type=ContentType.objects.get_for_model(IPFabricSync),
1492
+ status=JobStatusChoices.STATUS_COMPLETED,
1493
+ completed=timezone.now(),
1494
+ created=timezone.now(),
1495
+ )
1496
+
1497
+ job2 = Job.objects.create(
1498
+ job_id=uuid4(),
1499
+ name="Test Ingestion Job 2",
1500
+ object_id=sync2.pk,
1501
+ object_type=ContentType.objects.get_for_model(IPFabricSync),
1502
+ status=JobStatusChoices.STATUS_RUNNING,
1503
+ created=timezone.now(),
1504
+ )
1505
+
1506
+ job3 = Job.objects.create(
1507
+ job_id=uuid4(),
1508
+ name="Test Ingestion Job 3",
1509
+ object_id=sync3.pk,
1510
+ object_type=ContentType.objects.get_for_model(IPFabricSync),
1511
+ status=JobStatusChoices.STATUS_FAILED,
1512
+ created=timezone.now(),
1513
+ )
1514
+
1515
+ branch1 = Branch.objects.create(name="Test Branch 1")
1516
+ branch2 = Branch.objects.create(name="Test Branch 2")
1517
+ branch3 = Branch.objects.create(name="Test Branch 3")
1518
+
1519
+ site = Site.objects.create(name="Default Site", slug="default-site")
1520
+ modified = model_to_dict(site)
1521
+ modified["name"] = "Updated Site Name"
1522
+ for branch in (branch1, branch2, branch3):
1523
+ ChangeDiff.objects.create(
1524
+ branch=branch,
1525
+ object=site,
1526
+ object_type=ContentType.objects.get_for_model(site),
1527
+ object_repr=repr(site),
1528
+ original=model_to_dict(site),
1529
+ modified=modified,
1530
+ )
1531
+
1532
+ # Create three IPFabricIngestion instances for testing (linked to sync and job instances)
1533
+ ingestions = (
1534
+ IPFabricIngestion(sync=sync1, job=job1, branch=branch1),
1535
+ IPFabricIngestion(sync=sync2, job=job2, branch=branch2),
1536
+ IPFabricIngestion(sync=sync3, job=job3, branch=branch3),
1537
+ )
1538
+ for ingestion in ingestions:
1539
+ ingestion.save()
1540
+
1541
+ cls.form_data = {
1542
+ "snapshot": snapshot.pk,
1543
+ "status": DataSourceStatusChoices.NEW,
1544
+ "parameters": '{"batch_size": 150}',
1545
+ }
1546
+
1547
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1548
+ def test_get_ingestion_issues(self):
1549
+ ingestion = self._get_queryset().first()
1550
+ response = self.client.get(ingestion.get_absolute_url() + "ingestion_issues/")
1551
+ self.assertHttpStatus(response, 200)
1552
+
1553
+ # Verify the response contains expected elements
1554
+ self.assertContains(response, "Ingestion Issues")
1555
+
1556
+ # Check that context contains the ingestion object
1557
+ self.assertIn("object", response.context)
1558
+ self.assertEqual(response.context["object"], ingestion)
1559
+
1560
+ # Check if issues table or empty state is displayed
1561
+ if hasattr(ingestion, "issues") and ingestion.issues.exists():
1562
+ for issue in ingestion.issues.all():
1563
+ self.assertContains(response, issue.model)
1564
+ else:
1565
+ # Should contain table structure even if empty
1566
+ self.assertContains(response, "table")
1567
+
1568
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1569
+ def test_get_logs(self):
1570
+ ingestion = self._get_queryset().first()
1571
+ response = self.client.get(ingestion.get_absolute_url() + "logs/")
1572
+ self.assertHttpStatus(response, 200)
1573
+
1574
+ # Verify the response contains expected elements
1575
+ self.assertContains(response, "Logs pending...")
1576
+
1577
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1578
+ def test_get_logs_htmx(self):
1579
+ ingestion = self._get_queryset().first()
1580
+ response = self.client.get(
1581
+ ingestion.get_absolute_url() + "logs/",
1582
+ **{"HTTP_HX-Request": "true"},
1583
+ )
1584
+ self.assertHttpStatus(response, 200)
1585
+
1586
+ # Verify HTMX-specific response characteristics
1587
+ self.assertIn("object", response.context)
1588
+ self.assertEqual(response.context["object"], ingestion)
1589
+
1590
+ # Verify HTMX response doesn't include full page structure
1591
+ self.assertNotContains(response, "<html>")
1592
+ self.assertNotContains(response, "<head>")
1593
+
1594
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1595
+ def test_get_changes(self):
1596
+ ingestion = self._get_queryset().first()
1597
+ response = self.client.get(ingestion.get_absolute_url() + "change/")
1598
+ self.assertHttpStatus(response, 200)
1599
+
1600
+ # Verify the response contains expected elements
1601
+ self.assertContains(response, "Changes")
1602
+
1603
+ # Check that context contains the ingestion object
1604
+ self.assertIn("object", response.context)
1605
+ self.assertEqual(response.context["object"], ingestion)
1606
+
1607
+ # Check if branch changes are displayed
1608
+ if (
1609
+ ingestion.branch
1610
+ and hasattr(ingestion.branch, "changes")
1611
+ and ingestion.branch.changes.exists()
1612
+ ):
1613
+ for change in ingestion.branch.changes.all():
1614
+ self.assertContains(response, str(change.id))
1615
+
1616
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1617
+ def test_get_change(self):
1618
+ ingestion = self._get_queryset().first()
1619
+ change = ChangeDiff.objects.get(branch=ingestion.branch)
1620
+ response = self.client.get(
1621
+ ingestion.get_absolute_url() + f"change/{change.pk}/"
1622
+ )
1623
+ self.assertHttpStatus(response, 200)
1624
+
1625
+ # Verify we remove empty change diff since it's not HTMX
1626
+ self.assertNotContains(response, str(change))
1627
+ self.assertContains(response, "Change Diff None")
1628
+
1629
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1630
+ def test_get_change_htmx(self):
1631
+ ingestion = self._get_queryset().first()
1632
+ change = ChangeDiff.objects.get(branch=ingestion.branch)
1633
+ response = self.client.get(
1634
+ ingestion.get_absolute_url() + f"change/{change.pk}/",
1635
+ **{"HTTP_HX-Request": "true"},
1636
+ )
1637
+ self.assertHttpStatus(response, 200)
1638
+
1639
+ # Verify HTMX response doesn't include full page structure
1640
+ self.assertNotContains(response, "<html>")
1641
+ self.assertNotContains(response, "<head>")
1642
+
1643
+ # Check that change diff content is displayed
1644
+ self.assertContains(response, str(change))
1645
+ if change.modified:
1646
+ for key, value in change.modified.items():
1647
+ if isinstance(value, str):
1648
+ self.assertContains(response, value)
1649
+
1650
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1651
+ def test_get_change_htmx_empty_diff(self):
1652
+ ingestion = self._get_queryset().first()
1653
+ change = ChangeDiff.objects.get(branch=ingestion.branch)
1654
+ change.original = {}
1655
+ change.modified = {}
1656
+ change.save()
1657
+ response = self.client.get(
1658
+ ingestion.get_absolute_url() + f"change/{change.pk}/",
1659
+ **{"HTTP_HX-Request": "true"},
1660
+ )
1661
+ self.assertHttpStatus(response, 200)
1662
+
1663
+ # Verify HTMX response handles empty diff gracefully
1664
+ self.assertContains(response, str(change))
1665
+
1666
+ # Should contain some indication of empty changes
1667
+ self.assertContains(response, "No Changes")
1668
+
1669
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1670
+ def test_get_change_htmx_no_change(self):
1671
+ ingestion = self._get_queryset().first()
1672
+ response = self.client.get(
1673
+ ingestion.get_absolute_url() + "change/0/",
1674
+ **{"HTTP_HX-Request": "true"},
1675
+ )
1676
+ self.assertHttpStatus(response, 200)
1677
+
1678
+ # Should contain some indication that change was not found
1679
+ self.assertContains(response, "Change Diff None")
1680
+ self.assertContains(response, "No Changes")
1681
+
1682
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1683
+ def test_merge_view_get_redirect(self):
1684
+ """Test that GET request to merge view redirects to ingestion detail page."""
1685
+ ingestion = self._get_queryset().first()
1686
+ response = self.client.get(ingestion.get_absolute_url() + "merge/")
1687
+ self.assertHttpStatus(response, 302)
1688
+ self.assertRedirects(response, ingestion.get_absolute_url())
1689
+
1690
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1691
+ def test_merge_view_get_htmx(self):
1692
+ """Test that HTMX GET request to merge view returns form."""
1693
+
1694
+ ingestion = self._get_queryset().first()
1695
+ response = self.client.get(
1696
+ ingestion.get_absolute_url() + "merge/",
1697
+ **{"HTTP_HX-Request": "true"},
1698
+ )
1699
+ self.assertHttpStatus(response, 200)
1700
+
1701
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1702
+ @patch("ipfabric_netbox.forms.IPFabricIngestionMergeForm.clean")
1703
+ def test_merge_view_post_invalid_form(self, mock_clean):
1704
+ """Test POST request with invalid form data."""
1705
+ # Mock the clean method to raise ValidationError to make form invalid
1706
+ mock_clean.side_effect = ValidationError("Mocked validation error")
1707
+
1708
+ ingestion = self._get_queryset().first()
1709
+
1710
+ # Valid form data (but form will be invalid due to mocked clean method)
1711
+ form_data = {"confirm": True, "remove_branch": True}
1712
+
1713
+ # The view should handle invalid form gracefully and redirect back
1714
+ response = self.client.post(
1715
+ ingestion.get_absolute_url() + "merge/", data=form_data, follow=True
1716
+ )
1717
+
1718
+ # Should redirect to ingestion detail page
1719
+ self.assertHttpStatus(response, 200)
1720
+ self.assertRedirects(response, ingestion.get_absolute_url())
1721
+
1722
+ # Should show error message for the validation error, 1 per field
1723
+ messages = list(response.context["messages"])
1724
+ self.assertEqual(len(messages), 2)
1725
+ self.assertIn("Mocked validation error", str(messages[0]))
1726
+
1727
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1728
+ @patch("ipfabric_netbox.models.Job.enqueue")
1729
+ def test_merge_view_post_valid_form(self, mock_enqueue):
1730
+ """Test POST request with valid form data successfully enqueues merge job."""
1731
+
1732
+ # Set up mock job
1733
+ mock_job = Job(pk=123, name="Test Merge Job")
1734
+ mock_enqueue.return_value = mock_job
1735
+
1736
+ ingestion = self._get_queryset().first()
1737
+
1738
+ # Valid form data
1739
+ form_data = {"confirm": True, "remove_branch": True}
1740
+
1741
+ response = self.client.post(
1742
+ ingestion.get_absolute_url() + "merge/", data=form_data, follow=True
1743
+ )
1744
+
1745
+ # Should redirect to ingestion detail page
1746
+ self.assertHttpStatus(response, 200)
1747
+ self.assertRedirects(response, ingestion.get_absolute_url())
1748
+
1749
+ # Should have called Job.enqueue with correct parameters
1750
+ mock_enqueue.assert_called_once()
1751
+ call_args = mock_enqueue.call_args
1752
+ self.assertEqual(call_args[1]["instance"], ingestion)
1753
+ self.assertEqual(call_args[1]["remove_branch"], True)
1754
+ self.assertIn("user", call_args[1])
1755
+
1756
+ # Should show success message
1757
+ messages = list(response.context["messages"])
1758
+ self.assertEqual(len(messages), 1)
1759
+ self.assertIn(f"Queued job #{mock_job.pk}", str(messages[0]))
1760
+
1761
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1762
+ @patch("ipfabric_netbox.models.Job.enqueue")
1763
+ def test_merge_view_post_valid_form_keep_branch(self, mock_enqueue):
1764
+ """Test POST request with remove_branch=False."""
1765
+
1766
+ # Set up mock job
1767
+ mock_job = Job(pk=124, name="Test Merge Job Keep Branch")
1768
+ mock_enqueue.return_value = mock_job
1769
+
1770
+ ingestion = self._get_queryset().first()
1771
+
1772
+ # Valid form data with remove_branch=False
1773
+ form_data = {"confirm": True, "remove_branch": False}
1774
+
1775
+ response = self.client.post(
1776
+ ingestion.get_absolute_url() + "merge/", data=form_data, follow=True
1777
+ )
1778
+
1779
+ # Should redirect to ingestion detail page
1780
+ self.assertHttpStatus(response, 200)
1781
+
1782
+ # Should have called Job.enqueue with remove_branch=False
1783
+ mock_enqueue.assert_called_once()
1784
+ call_args = mock_enqueue.call_args
1785
+ self.assertEqual(call_args[1]["remove_branch"], False)
1786
+
1787
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1788
+ def test_merge_view_nonexistent_ingestion(self):
1789
+ """Test merge view with non-existent ingestion returns 404."""
1790
+ response = self.client.get(
1791
+ "/plugins/ipfabric-netbox/ipfabricingestion/99999/merge/"
1792
+ )
1793
+ self.assertHttpStatus(response, 404)
1794
+
1795
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1796
+ @patch("ipfabric_netbox.models.Job.enqueue")
1797
+ def test_merge_view_job_enqueue_parameters(self, mock_enqueue):
1798
+ """Test that Job.enqueue is called with correct parameters."""
1799
+
1800
+ # Set up mock job with all expected attributes
1801
+ mock_job = Job(pk=125, name="Test Merge Job Parameters", job_id=uuid4())
1802
+ mock_enqueue.return_value = mock_job
1803
+
1804
+ ingestion = self._get_queryset().first()
1805
+
1806
+ form_data = {
1807
+ "confirm": True,
1808
+ "remove_branch": True,
1809
+ }
1810
+
1811
+ self.client.post(
1812
+ ingestion.get_absolute_url() + "merge/", data=form_data, follow=True
1813
+ )
1814
+
1815
+ # Verify Job.enqueue was called exactly once
1816
+ mock_enqueue.assert_called_once()
1817
+
1818
+ # Get the call arguments
1819
+ call_args, call_kwargs = mock_enqueue.call_args
1820
+
1821
+ # Check that the first argument is the correct job function
1822
+ self.assertEqual(call_args[0], merge_ipfabric_ingestion)
1823
+
1824
+ # Check keyword arguments
1825
+ self.assertEqual(call_kwargs["name"], f"{ingestion.name} Merge")
1826
+ self.assertEqual(call_kwargs["instance"], ingestion)
1827
+ self.assertEqual(call_kwargs["remove_branch"], True)
1828
+ self.assertIsNotNone(call_kwargs["user"])
1829
+
1830
+
1831
+ class IPFabricTableViewTestCase(PluginPathMixin, ModelTestCase):
1832
+ model = Device
1833
+
1834
+ @classmethod
1835
+ def setUpTestData(cls):
1836
+ """Prepare a single Device with all required data filled."""
1837
+
1838
+ manufacturer = Manufacturer.objects.create(
1839
+ name="Test Manufacturer", slug="test-manufacturer"
1840
+ )
1841
+
1842
+ device_role = DeviceRole.objects.create(
1843
+ name="Test Device Role",
1844
+ slug="test-device-role",
1845
+ color="ff0000", # Red color
1846
+ )
1847
+
1848
+ site = Site.objects.create(name="Test Site", slug="test-site")
1849
+
1850
+ device_type = DeviceType.objects.create(
1851
+ model="Test Device Model",
1852
+ slug="test-device-model",
1853
+ manufacturer=manufacturer,
1854
+ u_height=1,
1855
+ )
1856
+
1857
+ cls.source = IPFabricSource.objects.create(
1858
+ name="IP Fabric Source 1",
1859
+ type=IPFabricSourceTypeChoices.LOCAL,
1860
+ url="https://ipfabric1.example.com",
1861
+ status=DataSourceStatusChoices.NEW,
1862
+ parameters={"auth": "token1", "verify": True, "timeout": 30},
1863
+ last_synced=timezone.now(),
1864
+ )
1865
+ cls.snapshot = IPFabricSnapshot.objects.create(
1866
+ source=cls.source,
1867
+ name="Snapshot 1",
1868
+ snapshot_id="snap001",
1869
+ data={
1870
+ "version": "6.0.0",
1871
+ "sites": [site.name, "Site B", "Site C"],
1872
+ "total_dev_count": 100,
1873
+ "interface_count": 500,
1874
+ "note": "Test snapshot 1",
1875
+ },
1876
+ date=timezone.now(),
1877
+ status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
1878
+ )
1879
+ cls.device = Device.objects.create(
1880
+ name="test-device-001",
1881
+ device_type=device_type,
1882
+ role=device_role,
1883
+ site=site,
1884
+ serial="TST123456789",
1885
+ asset_tag="ASSET-001",
1886
+ custom_field_data={"ipfabric_source": cls.source.pk},
1887
+ )
1888
+
1889
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1890
+ def test_get_table(self):
1891
+ response = self.client.get(
1892
+ self.device.get_absolute_url() + "ipfabric/",
1893
+ )
1894
+ self.assertHttpStatus(response, 200)
1895
+
1896
+ # Validate template used
1897
+ self.assertTemplateUsed(response, "ipfabric_netbox/ipfabric_table.html")
1898
+
1899
+ # Validate context variables
1900
+ self.assertEqual(response.context["object"], self.device)
1901
+ self.assertIsNotNone(response.context["form"])
1902
+ self.assertIsNotNone(response.context["table"])
1903
+ self.assertIn("tab", response.context)
1904
+
1905
+ # Validate that the source is retrieved from custom field
1906
+ expected_source = IPFabricSource.objects.filter(
1907
+ pk=self.device.custom_field_data.get("ipfabric_source")
1908
+ ).first()
1909
+ self.assertEqual(response.context["source"], expected_source)
1910
+
1911
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1912
+ def test_get_table_without_cf(self):
1913
+ self.device.custom_field_data = {}
1914
+ self.device.save()
1915
+ response = self.client.get(
1916
+ self.device.get_absolute_url() + "ipfabric/",
1917
+ )
1918
+ self.assertHttpStatus(response, 200)
1919
+
1920
+ # Validate template used
1921
+ self.assertTemplateUsed(response, "ipfabric_netbox/ipfabric_table.html")
1922
+
1923
+ # Validate context variables
1924
+ self.assertEqual(response.context["object"], self.device)
1925
+ self.assertIsNotNone(response.context["form"])
1926
+ self.assertIsNotNone(response.context["table"])
1927
+
1928
+ # When no custom field is set, source should be retrieved from site
1929
+ expected_source = IPFabricSource.get_for_site(self.device.site).first()
1930
+ self.assertEqual(response.context["source"], expected_source)
1931
+
1932
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1933
+ def test_get_table_with_table_param(self):
1934
+ response = self.client.get(
1935
+ self.device.get_absolute_url() + "ipfabric/",
1936
+ query_params={"table": tableChoices[0][0]},
1937
+ )
1938
+ self.assertHttpStatus(response, 200)
1939
+
1940
+ # Validate template used
1941
+ self.assertTemplateUsed(response, "ipfabric_netbox/ipfabric_table.html")
1942
+
1943
+ # Validate context variables
1944
+ self.assertEqual(response.context["object"], self.device)
1945
+ self.assertIsNotNone(response.context["form"])
1946
+ self.assertIsNotNone(response.context["table"])
1947
+
1948
+ # Validate that form has the correct initial table value
1949
+ form = response.context["form"]
1950
+ self.assertEqual(form.initial.get("table"), None)
1951
+
1952
+ # Validate that the source is retrieved from custom field
1953
+ expected_source = IPFabricSource.objects.filter(
1954
+ pk=self.device.custom_field_data.get("ipfabric_source")
1955
+ ).first()
1956
+ self.assertEqual(response.context["source"], expected_source)
1957
+
1958
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1959
+ def test_get_table_without_source(self):
1960
+ self.device.custom_field_data = {}
1961
+ self.device.save()
1962
+ self.source.delete()
1963
+ response = self.client.get(
1964
+ self.device.get_absolute_url() + "ipfabric/",
1965
+ query_params={"table": tableChoices[0][0]},
1966
+ )
1967
+ self.assertHttpStatus(response, 200)
1968
+
1969
+ # Validate template used
1970
+ self.assertTemplateUsed(response, "ipfabric_netbox/ipfabric_table.html")
1971
+
1972
+ # Validate context variables
1973
+ self.assertEqual(response.context["object"], self.device)
1974
+ self.assertIsNotNone(response.context["form"])
1975
+
1976
+ table = response.context["table"]
1977
+ self.assertIsInstance(table, DeviceIPFTable)
1978
+
1979
+ # Verify the table has the expected structure for empty data scenario
1980
+ self.assertEqual(len(table.data), 0) # Should be empty when no source
1981
+ self.assertIn(
1982
+ "hostname", [col.name for col in table.columns]
1983
+ ) # Should have default hostname column
1984
+
1985
+ # Verify table meta attributes
1986
+ self.assertEqual(table.empty_text, "No results found")
1987
+ self.assertIn("table-hover", table.attrs.get("class", ""))
1988
+
1989
+ # When no source is available, source should be None
1990
+ self.assertIsNone(response.context["source"])
1991
+
1992
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1993
+ def test_get_table_with_snapshot_data(self):
1994
+ response = self.client.get(
1995
+ self.device.get_absolute_url() + "ipfabric/",
1996
+ query_params={
1997
+ "table": tableChoices[0][0],
1998
+ "source": self.source.pk,
1999
+ "snapshot_data": self.snapshot.pk,
2000
+ },
2001
+ )
2002
+ self.assertHttpStatus(response, 200)
2003
+
2004
+ # Validate template used
2005
+ self.assertTemplateUsed(response, "ipfabric_netbox/ipfabric_table.html")
2006
+
2007
+ # Validate context variables
2008
+ self.assertEqual(response.context["object"], self.device)
2009
+ self.assertIsNotNone(response.context["form"])
2010
+ self.assertIsNotNone(response.context["table"])
2011
+ self.assertEqual(response.context["source"], self.source)
2012
+
2013
+ # Note: The actual implementation doesn't set form initial values,
2014
+ # it validates the form and uses cleaned_data instead
2015
+ form = response.context["form"]
2016
+ self.assertTrue(form.is_valid())
2017
+ self.assertEqual(form.cleaned_data.get("source"), self.source)
2018
+ self.assertEqual(form.cleaned_data.get("snapshot_data"), self.snapshot)
2019
+
2020
+ # Validate that the response contains expected elements
2021
+ self.assertContains(response, self.device.name)
2022
+ self.assertContains(response, self.source.name)
2023
+
2024
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2025
+ @patch("ipfabric_netbox.utilities.ipfutils.IPFClient")
2026
+ def test_get_table_with_cache(self, mock_ipfclient_class):
2027
+ # Clear cache before the test
2028
+ cache.clear()
2029
+
2030
+ table_name = tableChoices[0][0]
2031
+
2032
+ # Mock the IPFClient instance that gets created inside IPFabric.__init__
2033
+ mock_ipfclient_instance = mock_ipfclient_class.return_value
2034
+ mock_ipfclient_instance._client.headers = {"user-agent": "test-user-agent"}
2035
+ mock_ipfclient_instance.get_columns.return_value = [
2036
+ "id",
2037
+ "hostname",
2038
+ "vendor",
2039
+ "model",
2040
+ ]
2041
+
2042
+ mock_table = getattr(mock_ipfclient_instance.inventory, table_name)
2043
+ mock_table.all.return_value = [
2044
+ {"hostname": "mock-device-1", "vendor": "cisco", "model": "ISR4331"},
2045
+ ]
2046
+ mock_table.endpoint = f"inventory/{table_name}"
2047
+
2048
+ response = self.client.get(
2049
+ self.device.get_absolute_url() + "ipfabric/",
2050
+ query_params={
2051
+ "table": table_name,
2052
+ "cache_enable": "True",
2053
+ },
2054
+ )
2055
+ self.assertHttpStatus(response, 200)
2056
+
2057
+ mock_ipfclient_class.assert_called_once()
2058
+ mock_table.all.assert_called_once()
2059
+
2060
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2061
+ def test_get_table_htmx(self):
2062
+ response = self.client.get(
2063
+ self.device.get_absolute_url() + "ipfabric/",
2064
+ query_params={"table": tableChoices[0][0]},
2065
+ **{"HTTP_HX-Request": "true"},
2066
+ )
2067
+ self.assertHttpStatus(response, 200)
2068
+
2069
+ # Validate HTMX-specific behavior - should not include full page structure
2070
+ self.assertNotContains(response, "<html>")
2071
+ self.assertNotContains(response, "<head>")
2072
+
2073
+ # Validate that response contains the htmx form elements
2074
+ self.assertContains(response, "hx-target") # HTMX attributes should be present
2075
+
2076
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2077
+ @patch("ipfabric_netbox.utilities.ipfutils.IPFClient")
2078
+ def test_get_table_with_snapshot_data_and_api_call(self, mock_ipfclient_class):
2079
+ """Test that snapshot data properly triggers API calls when needed."""
2080
+ # Mock the IPFClient instance
2081
+ mock_ipfclient_instance = mock_ipfclient_class.return_value
2082
+ mock_ipfclient_instance._client.headers = {"user-agent": "test-user-agent"}
2083
+ mock_ipfclient_instance.get_columns.return_value = ["id", "hostname", "vendor"]
2084
+
2085
+ table_name = tableChoices[0][0]
2086
+ mock_table = getattr(mock_ipfclient_instance.inventory, table_name)
2087
+ mock_table.all.return_value = [
2088
+ {"hostname": "test-device-001", "vendor": "cisco", "model": "ISR4331"},
2089
+ ]
2090
+ mock_table.endpoint = f"inventory/{table_name}"
2091
+
2092
+ response = self.client.get(
2093
+ self.device.get_absolute_url() + "ipfabric/",
2094
+ query_params={
2095
+ "table": table_name,
2096
+ "source": self.source.pk,
2097
+ "snapshot_data": self.snapshot.pk,
2098
+ "cache_enable": "False", # Disable cache to force API call
2099
+ },
2100
+ )
2101
+ self.assertHttpStatus(response, 200)
2102
+
2103
+ # Validate that API was called
2104
+ mock_ipfclient_class.assert_called_once()
2105
+ mock_table.all.assert_called_once()
2106
+
2107
+ # Validate that response contains the mocked data
2108
+ self.assertContains(response, "test-device-001")
2109
+ self.assertContains(response, "cisco")
2110
+
2111
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2112
+ def test_get_table_htmx_with_empty_table_param(self):
2113
+ """Test HTMX request with empty table parameter."""
2114
+ response = self.client.get(
2115
+ self.device.get_absolute_url() + "ipfabric/",
2116
+ query_params={"table": ""},
2117
+ **{"HTTP_HX-Request": "true"},
2118
+ )
2119
+ self.assertHttpStatus(response, 200)
2120
+
2121
+ # For HTMX requests with empty table, it still returns a table context
2122
+ self.assertIn("table", response.context)
2123
+ self.assertIsNotNone(response.context["table"])
2124
+
2125
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2126
+ def test_get_table_with_invalid_snapshot_data(self):
2127
+ """Test behavior with invalid snapshot data parameter."""
2128
+ response = self.client.get(
2129
+ self.device.get_absolute_url() + "ipfabric/",
2130
+ query_params={
2131
+ "table": tableChoices[0][0],
2132
+ "source": self.source.pk,
2133
+ "snapshot_data": 99999, # Non-existent snapshot ID
2134
+ },
2135
+ )
2136
+ self.assertHttpStatus(response, 200)
2137
+
2138
+ # Should handle invalid snapshot gracefully
2139
+ self.assertEqual(response.context["object"], self.device)
2140
+ self.assertEqual(response.context["source"], self.source)
2141
+
2142
+ # The form should be invalid due to invalid snapshot_data
2143
+ form = response.context["form"]
2144
+ self.assertFalse(form.is_valid())
2145
+
2146
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2147
+ def test_get_table_htmx_form_validation(self):
2148
+ """Test HTMX request form validation and data handling."""
2149
+ response = self.client.get(
2150
+ self.device.get_absolute_url() + "ipfabric/",
2151
+ query_params={
2152
+ "table": tableChoices[0][0],
2153
+ "source": self.source.pk,
2154
+ "cache_enable": "True",
2155
+ },
2156
+ **{"HTTP_HX-Request": "true"},
2157
+ )
2158
+ self.assertHttpStatus(response, 200)
2159
+
2160
+ # For HTMX requests, context only contains table object
2161
+ self.assertIn("table", response.context)
2162
+ self.assertIsNotNone(response.context["table"])
2163
+
2164
+ # HTMX requests don't include full page structure
2165
+ self.assertNotContains(response, "<html>")
2166
+ self.assertNotContains(response, "<body>")