@@ -267,6 +267,25 @@ def test_set_location_no_switch_info(self, device_info, mock_nautobot):
267267 assert device_info .location_id is None
268268 assert device_info .rack_id is None
269269
270+ def test_set_location_switch_info_is_string_none (self , device_info , mock_nautobot ):
271+ """Test that literal string 'None' in switch_info is skipped."""
272+ ports = [
273+ MagicMock (
274+ local_link_connection = {
275+ "switch_info" : "None" ,
276+ "switch_id" : "00:00:00:00:00:00" ,
277+ "port_id" : "None" ,
278+ }
279+ )
280+ ]
281+
282+ _set_location_from_switches (device_info , ports , mock_nautobot )
283+
284+ # Should not make any API calls
285+ mock_nautobot .dcim .devices .get .assert_not_called ()
286+ assert device_info .location_id is None
287+ assert device_info .rack_id is None
288+
270289 def test_set_location_switch_not_found (self , device_info , mock_nautobot ):
271290 ports = [
272291 MagicMock (
@@ -405,6 +424,54 @@ def test_no_changes(self, mock_nautobot_device):
405424 assert result is False
406425 mock_nautobot_device .save .assert_not_called ()
407426
427+ def test_update_position_and_face (self , mock_nautobot_device ):
428+ """Test that position and face are updated when preserved from old device."""
429+ mock_nautobot_device .position = None
430+ mock_nautobot_device .face = None
431+ device_info = DeviceInfo (
432+ uuid = "test-uuid" ,
433+ position = 42 ,
434+ face = "front" ,
435+ )
436+
437+ result = _update_nautobot_device (device_info , mock_nautobot_device )
438+
439+ assert result is True
440+ assert mock_nautobot_device .position == 42
441+ assert mock_nautobot_device .face == "front"
442+ mock_nautobot_device .save .assert_called_once ()
443+
444+ def test_update_position_defaults_face_to_front (self , mock_nautobot_device ):
445+ """Test that face defaults to 'front' when position is set but face is not."""
446+ mock_nautobot_device .position = None
447+ mock_nautobot_device .face = None
448+ device_info = DeviceInfo (
449+ uuid = "test-uuid" ,
450+ position = 10 ,
451+ # face is None
452+ )
453+
454+ result = _update_nautobot_device (device_info , mock_nautobot_device )
455+
456+ assert result is True
457+ assert mock_nautobot_device .position == 10
458+ assert mock_nautobot_device .face == "front"
459+
460+ def test_update_position_no_change_when_same (self , mock_nautobot_device ):
461+ """Test that no update occurs when position/face are already set correctly."""
462+ mock_nautobot_device .position = 42
463+ mock_nautobot_device .face = MagicMock (value = "front" )
464+ device_info = DeviceInfo (
465+ uuid = "test-uuid" ,
466+ position = 42 ,
467+ face = "front" ,
468+ )
469+
470+ result = _update_nautobot_device (device_info , mock_nautobot_device )
471+
472+ assert result is False
473+ mock_nautobot_device .save .assert_not_called ()
474+
408475
409476class TestExtractNodeUuidFromEvent :
410477 """Test cases for _extract_node_uuid_from_event function."""
@@ -443,7 +510,7 @@ def mock_nautobot(self):
443510 return MagicMock ()
444511
445512 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
446- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
513+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
447514 @patch (
448515 "understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
449516 )
@@ -470,7 +537,7 @@ def test_sync_creates_new_device(
470537 mock_nautobot .dcim .devices .create .assert_called_once ()
471538
472539 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
473- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
540+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
474541 @patch (
475542 "understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
476543 )
@@ -507,29 +574,34 @@ def test_sync_with_empty_uuid_returns_error(self, mock_nautobot):
507574 assert result == EXIT_STATUS_FAILURE
508575
509576 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
510- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
511- def test_sync_without_location_returns_error (
577+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
578+ def test_sync_without_location_skips_for_uninspected_node (
512579 self , mock_fetch , mock_ironic_class , mock_nautobot
513580 ):
581+ """Test that sync skips gracefully for uninspected nodes without location."""
514582 node_uuid = str (uuid .uuid4 ())
515583 device_info = DeviceInfo (uuid = node_uuid ) # No location
516584 mock_fetch .return_value = (device_info , {}, [])
517585 mock_nautobot .dcim .devices .get .return_value = None
518586
519587 result = sync_device_to_nautobot (node_uuid , mock_nautobot )
520588
589+ # Should fail since no location available
521590 assert result == EXIT_STATUS_FAILURE
591+ # Should not attempt to create device
592+ mock_nautobot .dcim .devices .create .assert_not_called ()
522593
523594 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
524- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
595+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
525596 @patch (
526597 "understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
527598 )
528- def test_sync_finds_device_by_name_with_matching_uuid (
599+ def test_sync_recreates_device_with_mismatched_uuid (
529600 self , mock_sync_interfaces , mock_fetch , mock_ironic_class , mock_nautobot
530601 ):
531- """Test that device found by name with matching UUID is updated ."""
602+ """Test device with mismatched UUID is deleted and recreated ."""
532603 node_uuid = str (uuid .uuid4 ())
604+ old_uuid = str (uuid .uuid4 ()) # Different UUID
533605 device_info = DeviceInfo (
534606 uuid = node_uuid ,
535607 name = "Dell-ABC123" ,
@@ -541,39 +613,34 @@ def test_sync_finds_device_by_name_with_matching_uuid(
541613 mock_fetch .return_value = (device_info , {}, [])
542614
543615 # First get by ID returns None
544- # Second get by name returns device with same UUID
616+ # Second get by name returns device with different UUID
545617 existing_device = MagicMock ()
546- existing_device .id = node_uuid # Same UUID
618+ existing_device .id = old_uuid # Different UUID
547619 existing_device .status = MagicMock (name = "Planned" )
548620 existing_device .name = "Dell-ABC123"
549- existing_device .serial = None
550- existing_device .location = None
551- existing_device .rack = None
552- existing_device .tenant = None
553- existing_device .custom_fields = {}
554621
555622 mock_nautobot .dcim .devices .get .side_effect = [None , existing_device ]
623+ mock_nautobot .dcim .devices .create .return_value = MagicMock ()
556624 mock_sync_interfaces .return_value = EXIT_STATUS_SUCCESS
557625
558626 result = sync_device_to_nautobot (node_uuid , mock_nautobot )
559627
560628 assert result == EXIT_STATUS_SUCCESS
561- # Should NOT delete since UUIDs match
562- existing_device .delete .assert_not_called ()
563- # Should NOT create new device
564- mock_nautobot .dcim .devices .create .assert_not_called ()
629+ # Should delete old device
630+ existing_device .delete .assert_called_once ()
631+ # Should create new device with correct UUID
632+ mock_nautobot .dcim .devices .create .assert_called_once ()
565633
566634 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
567- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
635+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
568636 @patch (
569637 "understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
570638 )
571- def test_sync_recreates_device_with_mismatched_uuid (
639+ def test_sync_device_not_found_by_name_creates_new (
572640 self , mock_sync_interfaces , mock_fetch , mock_ironic_class , mock_nautobot
573641 ):
574- """Test device with mismatched UUID is deleted and recreated ."""
642+ """Test that device not found by UUID or name is created ."""
575643 node_uuid = str (uuid .uuid4 ())
576- old_uuid = str (uuid .uuid4 ()) # Different UUID
577644 device_info = DeviceInfo (
578645 uuid = node_uuid ,
579646 name = "Dell-ABC123" ,
@@ -584,53 +651,60 @@ def test_sync_recreates_device_with_mismatched_uuid(
584651 )
585652 mock_fetch .return_value = (device_info , {}, [])
586653
587- # First get by ID returns None
588- # Second get by name returns device with different UUID
589- existing_device = MagicMock ()
590- existing_device .id = old_uuid # Different UUID
591- existing_device .status = MagicMock (name = "Planned" )
592- existing_device .name = "Dell-ABC123"
593-
594- mock_nautobot .dcim .devices .get .side_effect = [None , existing_device ]
654+ # Both lookups return None
655+ mock_nautobot .dcim .devices .get .side_effect = [None , None ]
595656 mock_nautobot .dcim .devices .create .return_value = MagicMock ()
596657 mock_sync_interfaces .return_value = EXIT_STATUS_SUCCESS
597658
598659 result = sync_device_to_nautobot (node_uuid , mock_nautobot )
599660
600661 assert result == EXIT_STATUS_SUCCESS
601- # Should delete old device
602- existing_device .delete .assert_called_once ()
603- # Should create new device with correct UUID
604662 mock_nautobot .dcim .devices .create .assert_called_once ()
605663
606664 @patch ("understack_workflows.oslo_event.nautobot_device_sync.IronicClient" )
607- @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_device_info " )
665+ @patch ("understack_workflows.oslo_event.nautobot_device_sync.fetch_node_details " )
608666 @patch (
609667 "understack_workflows.oslo_event.nautobot_device_sync.sync_interfaces_from_data"
610668 )
611- def test_sync_device_not_found_by_name_creates_new (
669+ def test_sync_uuid_mismatch_uses_old_device_location (
612670 self , mock_sync_interfaces , mock_fetch , mock_ironic_class , mock_nautobot
613671 ):
614- """Test that device not found by UUID or name is created."""
672+ """Test that location is preserved from old device when new node has none.
673+
674+ When re-enrolling a node that hasn't been inspected yet, we should use
675+ the location from the old Nautobot device to create the new one.
676+ """
615677 node_uuid = str (uuid .uuid4 ())
678+ old_uuid = str (uuid .uuid4 ())
616679 device_info = DeviceInfo (
617680 uuid = node_uuid ,
618681 name = "Dell-ABC123" ,
619682 manufacturer = "Dell" ,
620683 model = "PowerEdge R640" ,
621- location_id = "location-uuid" ,
684+ # No location_id from switch lookup
622685 status = "Active" ,
623686 )
624687 mock_fetch .return_value = (device_info , {}, [])
625688
626- # Both lookups return None
627- mock_nautobot .dcim .devices .get .side_effect = [None , None ]
689+ # Old device has location
690+ existing_device = MagicMock ()
691+ existing_device .id = old_uuid
692+ existing_device .name = "Dell-ABC123"
693+ existing_device .location = MagicMock (id = "old-location-uuid" )
694+ existing_device .rack = MagicMock (id = "old-rack-uuid" )
695+ existing_device .position = 42
696+ existing_device .face = MagicMock (value = "front" )
697+
698+ mock_nautobot .dcim .devices .get .side_effect = [None , existing_device ]
628699 mock_nautobot .dcim .devices .create .return_value = MagicMock ()
629700 mock_sync_interfaces .return_value = EXIT_STATUS_SUCCESS
630701
631702 result = sync_device_to_nautobot (node_uuid , mock_nautobot )
632703
633704 assert result == EXIT_STATUS_SUCCESS
705+ # Should delete old device after preserving location
706+ existing_device .delete .assert_called_once ()
707+ # Should create new device
634708 mock_nautobot .dcim .devices .create .assert_called_once ()
635709
636710
0 commit comments