@@ -175,6 +175,270 @@ def test_process_migrate_resource_item_raises_on_missing_resource_data():
175175 cmd ._process_and_migrate_resource_item (resource_item , resource_context )
176176
177177
178+ @pytest .mark .django_db
179+ def test_process_resource_page_batch_raises_on_missing_resource_data ():
180+ """Test that _process_resource_page_batch raises when resource_data is missing."""
181+ cmd = MigrateCommand ()
182+ results = [{"ansible_id" : "test-id-456" , "name" : "test" }]
183+ resource_context = {"type" : Mock ()}
184+
185+ with pytest .raises (RuntimeError , match = "missing 'resource_data'" ):
186+ cmd ._process_resource_page_batch (results , resource_context )
187+
188+
189+ @pytest .mark .django_db
190+ def test_process_resource_page_batch_bulk_update ():
191+ """Test that _process_resource_page_batch calls bulk_update_resources with correct payloads."""
192+ import uuid
193+
194+ from ansible_base .resource_registry .models import ResourceType
195+
196+ cmd = MigrateCommand ()
197+ cmd .stdout = StringIO ()
198+ cmd .stderr = StringIO ()
199+ cmd .upstream_service_id = str (uuid .uuid4 ())
200+
201+ mock_client = Mock ()
202+ mock_client .service .service_cluster .service_type .name = "awx"
203+ mock_resp = Mock ()
204+ mock_resp .status_code = 200
205+ mock_resp .json .return_value = {"updated" : 2 , "errors" : []}
206+ mock_client .bulk_update_resources .return_value = mock_resp
207+ cmd .client = mock_client
208+
209+ Organization .objects .create (name = "BatchOrg1" )
210+
211+ org_resource_type = ResourceType .objects .get (name = "shared.organization" )
212+ resource_context = {
213+ "type" : org_resource_type ,
214+ "type_name" : "shared.organization" ,
215+ "type_serializer" : org_resource_type .serializer_class ,
216+ "type_name_field" : org_resource_type .get_resource_config ().name_field ,
217+ "unique_fields" : ["name" ],
218+ "LocalResourceModel" : Organization ,
219+ }
220+
221+ batch_org_id = str (uuid .uuid4 ())
222+ new_org_id = str (uuid .uuid4 ())
223+ results = [
224+ {
225+ "ansible_id" : batch_org_id ,
226+ "name" : "BatchOrg1" ,
227+ "resource_type" : "shared.organization" ,
228+ "resource_data" : {"name" : "BatchOrg1" },
229+ },
230+ {
231+ "ansible_id" : new_org_id ,
232+ "name" : "NewOrg" ,
233+ "resource_type" : "shared.organization" ,
234+ "resource_data" : {"name" : "NewOrg" },
235+ },
236+ ]
237+
238+ count = cmd ._process_resource_page_batch (results , resource_context )
239+ # Returns the "updated" count from the bulk response
240+ assert count == 2
241+
242+ mock_client .bulk_update_resources .assert_called_once ()
243+ bulk_items = mock_client .bulk_update_resources .call_args [0 ][0 ]
244+ assert len (bulk_items ) == 2
245+ assert all ("ansible_id" in item for item in bulk_items )
246+ # The first item (BatchOrg1 exists) triggers reconcile which sets ansible_id and resource_data
247+ merged_item = bulk_items [0 ]
248+ assert "new_ansible_id" in merged_item
249+ assert "resource_data" in merged_item
250+ # The second item (NewOrg is new) only gets service_id
251+ new_item = bulk_items [1 ]
252+ assert "service_id" in new_item
253+
254+
255+ @pytest .mark .django_db
256+ def test_process_resource_page_batch_with_partially_migrated ():
257+ """Test that is_partially_migrated is included in bulk payload when set."""
258+ import uuid
259+ from unittest .mock import patch as mock_patch
260+
261+ from ansible_base .resource_registry .models import ResourceType
262+
263+ cmd = MigrateCommand ()
264+ cmd .stdout = StringIO ()
265+ cmd .stderr = StringIO ()
266+ cmd .upstream_service_id = str (uuid .uuid4 ())
267+
268+ mock_client = Mock ()
269+ mock_client .service .service_cluster .service_type .name = "awx"
270+ mock_resp = Mock ()
271+ mock_resp .status_code = 200
272+ mock_resp .json .return_value = {"updated" : 1 , "errors" : []}
273+ mock_client .bulk_update_resources .return_value = mock_resp
274+ cmd .client = mock_client
275+
276+ org_resource_type = ResourceType .objects .get (name = "shared.organization" )
277+ resource_context = {
278+ "type" : org_resource_type ,
279+ "type_name" : "shared.organization" ,
280+ "type_serializer" : org_resource_type .serializer_class ,
281+ "type_name_field" : org_resource_type .get_resource_config ().name_field ,
282+ "unique_fields" : ["name" ],
283+ "LocalResourceModel" : Organization ,
284+ }
285+
286+ results = [
287+ {
288+ "ansible_id" : str (uuid .uuid4 ()),
289+ "name" : "PartialOrg" ,
290+ "resource_type" : "shared.organization" ,
291+ "resource_data" : {"name" : "PartialOrg" },
292+ },
293+ ]
294+
295+ # Mock _reconcile_existing_resource to inject is_partially_migrated
296+ def mock_reconcile (upstream_resource , ctx , validated_data , updated_service_resource ):
297+ updated_service_resource ["is_partially_migrated" ] = True
298+ return True
299+
300+ with mock_patch .object (cmd , "_reconcile_existing_resource" , side_effect = mock_reconcile ):
301+ count = cmd ._process_resource_page_batch (results , resource_context )
302+
303+ assert count == 1
304+ bulk_items = mock_client .bulk_update_resources .call_args [0 ][0 ]
305+ assert bulk_items [0 ]["is_partially_migrated" ] is True
306+
307+
308+ @pytest .mark .django_db
309+ def test_process_resource_page_batch_graceful_on_bulk_failure ():
310+ """Test that bulk update HTTP failure is non-fatal and returns 0 (items will be retried)."""
311+ import uuid
312+
313+ from ansible_base .resource_registry .models import ResourceType
314+
315+ cmd = MigrateCommand ()
316+ cmd .stdout = StringIO ()
317+ cmd .stderr = StringIO ()
318+ cmd .upstream_service_id = str (uuid .uuid4 ())
319+
320+ mock_client = Mock ()
321+ mock_client .service .service_cluster .service_type .name = "awx"
322+ mock_resp = Mock ()
323+ mock_resp .status_code = 500
324+ mock_resp .text = "Internal Server Error"
325+ mock_client .bulk_update_resources .return_value = mock_resp
326+ cmd .client = mock_client
327+
328+ org_resource_type = ResourceType .objects .get (name = "shared.organization" )
329+ resource_context = {
330+ "type" : org_resource_type ,
331+ "type_name" : "shared.organization" ,
332+ "type_serializer" : org_resource_type .serializer_class ,
333+ "type_name_field" : org_resource_type .get_resource_config ().name_field ,
334+ "unique_fields" : ["name" ],
335+ "LocalResourceModel" : Organization ,
336+ }
337+
338+ results = [
339+ {
340+ "ansible_id" : str (uuid .uuid4 ()),
341+ "name" : "RetryOrg" ,
342+ "resource_type" : "shared.organization" ,
343+ "resource_data" : {"name" : "RetryOrg" },
344+ },
345+ ]
346+
347+ # Bulk failure is non-fatal: returns 0 and logs warning (items retry next iteration)
348+ count = cmd ._process_resource_page_batch (results , resource_context )
349+ assert count == 0
350+
351+ # Local gateway resource was still created (will be reconciled on retry)
352+ assert Organization .objects .filter (name = "RetryOrg" ).exists ()
353+
354+
355+ @pytest .mark .django_db
356+ def test_process_resource_page_batch_partial_errors ():
357+ """Test that per-item errors from bulk update are logged as warnings."""
358+ import uuid
359+
360+ from ansible_base .resource_registry .models import ResourceType
361+
362+ cmd = MigrateCommand ()
363+ cmd .stdout = StringIO ()
364+ cmd .stderr = StringIO ()
365+ cmd .upstream_service_id = str (uuid .uuid4 ())
366+
367+ mock_client = Mock ()
368+ mock_client .service .service_cluster .service_type .name = "awx"
369+ mock_resp = Mock ()
370+ mock_resp .status_code = 200
371+ mock_resp .json .return_value = {
372+ "updated" : 1 ,
373+ "errors" : [{"ansible_id" : "some-id" , "error" : "Resource not found." }],
374+ }
375+ mock_client .bulk_update_resources .return_value = mock_resp
376+ cmd .client = mock_client
377+
378+ Organization .objects .create (name = "PartialErrOrg" )
379+
380+ org_resource_type = ResourceType .objects .get (name = "shared.organization" )
381+ resource_context = {
382+ "type" : org_resource_type ,
383+ "type_name" : "shared.organization" ,
384+ "type_serializer" : org_resource_type .serializer_class ,
385+ "type_name_field" : org_resource_type .get_resource_config ().name_field ,
386+ "unique_fields" : ["name" ],
387+ "LocalResourceModel" : Organization ,
388+ }
389+
390+ results = [
391+ {
392+ "ansible_id" : str (uuid .uuid4 ()),
393+ "name" : "PartialErrOrg" ,
394+ "resource_type" : "shared.organization" ,
395+ "resource_data" : {"name" : "PartialErrOrg" },
396+ },
397+ {
398+ "ansible_id" : str (uuid .uuid4 ()),
399+ "name" : "NewPartialOrg" ,
400+ "resource_type" : "shared.organization" ,
401+ "resource_data" : {"name" : "NewPartialOrg" },
402+ },
403+ ]
404+
405+ count = cmd ._process_resource_page_batch (results , resource_context )
406+ # Only 1 updated (the other had an error on upstream side)
407+ assert count == 1
408+ # Warning was logged for the failed item
409+ output = cmd .stderr .getvalue ()
410+ assert "bulk-update failed" in output
411+
412+
413+ def test_build_bulk_update_item_all_fields ():
414+ """Test that _build_bulk_update_item includes all present fields."""
415+ import uuid
416+
417+ cmd = MigrateCommand ()
418+ ansible_id = str (uuid .uuid4 ())
419+ new_ansible_id = uuid .uuid4 ()
420+ updated_service_resource = {
421+ "service_id" : "svc-123" ,
422+ "is_partially_migrated" : True ,
423+ "ansible_id" : new_ansible_id ,
424+ "resource_data" : {"username" : "test" },
425+ }
426+
427+ result = cmd ._build_bulk_update_item (ansible_id , updated_service_resource )
428+ assert result ["ansible_id" ] == ansible_id
429+ assert result ["service_id" ] == "svc-123"
430+ assert result ["is_partially_migrated" ] is True
431+ assert result ["new_ansible_id" ] == str (new_ansible_id )
432+ assert result ["resource_data" ] == {"username" : "test" }
433+
434+
435+ def test_build_bulk_update_item_minimal ():
436+ """Test that _build_bulk_update_item only includes ansible_id when no updates."""
437+ cmd = MigrateCommand ()
438+ result = cmd ._build_bulk_update_item ("some-id" , {})
439+ assert result == {"ansible_id" : "some-id" }
440+
441+
178442@pytest .mark .django_db
179443def test_reconcile_existing_resource_matching_ansible_id_same_data ():
180444 """Case 1 with matching data: logs 'Correcting service_id'."""
0 commit comments