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