22# Licensed under the MIT license.
33
44"""Comprehensive tests for _create_multiple / _update_multiple / _upsert_multiple
5- client-side chunking (issue #156).
6-
7- Coverage goals
8- --------------
9- - Boundary conditions: 0, 1, BATCH-1, BATCH, BATCH+1, 2*BATCH, 2*BATCH+1 records
10- - Chunk sizes: first chunk always full, last chunk carries the remainder
11- - Payload correctness: each chunk sent to the right endpoint with the right records
12- - ID aggregation: IDs from all chunks are collected in order
13- - _update_by_ids: delegates correctly to _update_multiple (broadcast + paired)
14- - Public API (records.create / records.update / records.upsert): delegates correctly
5+ client-side chunking.
156"""
167
178import unittest
@@ -31,7 +22,6 @@ def _make_odata_client() -> _ODataClient:
3122 mock_auth ._acquire_token .return_value = MagicMock (access_token = "token" )
3223 client = _ODataClient (mock_auth , "https://org.crm.dynamics.com" )
3324 client ._request = MagicMock ()
34- # Skip picklist HTTP calls so _request counts reflect only batch POSTs
3525 client ._convert_labels_to_ints = MagicMock (side_effect = lambda _t , r : r )
3626 return client
3727
@@ -45,7 +35,7 @@ def _mock_create_response(ids):
4535
4636
4737def _mock_update_response ():
48- """Mock HTTP response for UpdateMultiple (no meaningful body) ."""
38+ """Mock HTTP response for UpdateMultiple."""
4939 resp = MagicMock ()
5040 resp .text = ""
5141 return resp
@@ -80,43 +70,43 @@ def test_zero_records_no_request(self):
8070 self .assertEqual (result , [])
8171
8272 def test_one_record_single_request (self ):
83- """Single record → one request, one ID returned."""
73+ """Single record produces one request and one ID returned."""
8474 result = self ._run (1 , [_mock_create_response (["id-0" ])])
8575 self .od ._execute_raw .assert_called_once ()
8676 self .assertEqual (result , ["id-0" ])
8777
8878 def test_batch_minus_one_single_request (self ):
89- """B -1 records fit in one chunk."""
79+ """_MULTIPLE_BATCH_SIZE -1 records fit in one chunk."""
9080 ids = [f"id-{ i } " for i in range (_MULTIPLE_BATCH_SIZE - 1 )]
9181 result = self ._run (_MULTIPLE_BATCH_SIZE - 1 , [_mock_create_response (ids )])
9282 self .od ._execute_raw .assert_called_once ()
9383 self .assertEqual (len (result ), _MULTIPLE_BATCH_SIZE - 1 )
9484
9585 def test_exact_batch_size_single_request (self ):
96- """Exactly _MULTIPLE_BATCH_SIZE records → one chunk, one request."""
86+ """Exactly _MULTIPLE_BATCH_SIZE records produces one chunk and one request."""
9787 ids = [f"id-{ i } " for i in range (_MULTIPLE_BATCH_SIZE )]
9888 result = self ._run (_MULTIPLE_BATCH_SIZE , [_mock_create_response (ids )])
9989 self .od ._execute_raw .assert_called_once ()
10090 self .assertEqual (len (result ), _MULTIPLE_BATCH_SIZE )
10191
10292 def test_batch_plus_one_two_requests (self ):
103- """B +1 records → two chunks, two requests."""
93+ """_MULTIPLE_BATCH_SIZE +1 records produces two chunks and two requests."""
10494 ids1 = [f"id-{ i } " for i in range (_MULTIPLE_BATCH_SIZE )]
10595 ids2 = ["id-last" ]
10696 result = self ._run (_MULTIPLE_BATCH_SIZE + 1 , [_mock_create_response (ids1 ), _mock_create_response (ids2 )])
10797 self .assertEqual (self .od ._execute_raw .call_count , 2 )
10898 self .assertEqual (len (result ), _MULTIPLE_BATCH_SIZE + 1 )
10999
110100 def test_two_full_batches (self ):
111- """2*_MULTIPLE_BATCH_SIZE records → two full chunks."""
101+ """2*_MULTIPLE_BATCH_SIZE records produces two full chunks."""
112102 ids1 = [f"id-{ i } " for i in range (_MULTIPLE_BATCH_SIZE )]
113103 ids2 = [f"id-{ i } " for i in range (_MULTIPLE_BATCH_SIZE , 2 * _MULTIPLE_BATCH_SIZE )]
114104 result = self ._run (2 * _MULTIPLE_BATCH_SIZE , [_mock_create_response (ids1 ), _mock_create_response (ids2 )])
115105 self .assertEqual (self .od ._execute_raw .call_count , 2 )
116106 self .assertEqual (len (result ), 2 * _MULTIPLE_BATCH_SIZE )
117107
118108 def test_two_batches_plus_one (self ):
119- """2*_MULTIPLE_BATCH_SIZE+1 records → three chunks."""
109+ """2*_MULTIPLE_BATCH_SIZE+1 records produces three chunks."""
120110 se = [_mock_create_response ([f"id-{ j } " for j in range (_MULTIPLE_BATCH_SIZE )]) for _ in range (2 )]
121111 se .append (_mock_create_response (["id-extra" ]))
122112 result = self ._run (2 * _MULTIPLE_BATCH_SIZE + 1 , se )
@@ -131,11 +121,6 @@ def setUp(self):
131121 self .od = _make_odata_client ()
132122 self .od ._execute_raw = MagicMock (return_value = _mock_create_response ([]))
133123
134- def _captured_targets (self , call_index ):
135- """Return the Targets list from the _build_create_multiple payload for a given call."""
136- # _execute_raw is called with the result of _build_create_multiple, which
137- # we can't easily inspect without going deeper. Instead, patch _build_create_multiple.
138- return None # handled in test below
139124
140125 def test_first_chunk_has_batch_size_records (self ):
141126 """The first chunk sent to the server has exactly _MULTIPLE_BATCH_SIZE records."""
@@ -510,6 +495,36 @@ def test_paired_delegates_correctly(self):
510495 [{"accountid" : "id-1" , "name" : "A" }, {"accountid" : "id-2" , "name" : "B" }],
511496 )
512497
498+ def test_empty_ids_returns_none_without_delegating (self ):
499+ """Empty ids list returns immediately without calling _update_multiple."""
500+ result = self .od ._update_by_ids ("account" , [], {"name" : "X" })
501+ self .assertIsNone (result )
502+ self .od ._update_multiple .assert_not_called ()
503+
504+ def test_non_list_ids_raises_type_error (self ):
505+ """Non-list ids raises TypeError before any delegation."""
506+ with self .assertRaises (TypeError ):
507+ self .od ._update_by_ids ("account" , "id-1" , {"name" : "X" }) # type: ignore
508+ self .od ._update_multiple .assert_not_called ()
509+
510+ def test_changes_non_dict_non_list_raises_type_error (self ):
511+ """changes that is neither dict nor list raises TypeError."""
512+ with self .assertRaises (TypeError ):
513+ self .od ._update_by_ids ("account" , ["id-1" ], "invalid" ) # type: ignore
514+ self .od ._update_multiple .assert_not_called ()
515+
516+ def test_changes_list_length_mismatch_raises_value_error (self ):
517+ """Paired changes list with different length from ids raises ValueError."""
518+ with self .assertRaises (ValueError ):
519+ self .od ._update_by_ids ("account" , ["id-1" , "id-2" ], [{"name" : "A" }])
520+ self .od ._update_multiple .assert_not_called ()
521+
522+ def test_changes_list_non_dict_element_raises_type_error (self ):
523+ """Non-dict element in paired changes list raises TypeError."""
524+ with self .assertRaises (TypeError ):
525+ self .od ._update_by_ids ("account" , ["id-1" , "id-2" ], [{"name" : "A" }, "bad" ]) # type: ignore
526+ self .od ._update_multiple .assert_not_called ()
527+
513528
514529# ---------------------------------------------------------------------------
515530# Public API: records.create / records.update / records.upsert
@@ -567,6 +582,14 @@ def test_list_delegates_to_update_by_ids(self):
567582 "account" , ["id-1" , "id-2" ], {"name" : "X" }
568583 )
569584
585+ def test_list_paired_delegates_to_update_by_ids (self ):
586+ """Paired list-of-patches passes through to _update_by_ids unchanged."""
587+ ops , mock_odata = _make_records_client ()
588+ ops .update ("account" , ["id-1" , "id-2" ], [{"name" : "A" }, {"name" : "B" }])
589+ mock_odata ._update_by_ids .assert_called_once_with (
590+ "account" , ["id-1" , "id-2" ], [{"name" : "A" }, {"name" : "B" }]
591+ )
592+
570593 def test_single_delegates_to_update (self ):
571594 """Single-record update calls _update, not _update_by_ids."""
572595 ops , mock_odata = _make_records_client ()
0 commit comments