Skip to content

Commit 400ca95

Browse files
Abel Milashclaude
andcommitted
Improve _batch.py coverage: add tests for dispatch, execute, and edge cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 44b8928 commit 400ca95

1 file changed

Lines changed: 318 additions & 5 deletions

File tree

tests/unit/data/test_batch_serialization.py

Lines changed: 318 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,27 @@
1616
_RecordGet,
1717
_RecordUpdate,
1818
_RecordUpsert,
19+
_TableCreate,
20+
_TableDelete,
1921
_TableGet,
2022
_TableList,
23+
_TableAddColumns,
24+
_TableRemoveColumns,
25+
_TableCreateOneToMany,
26+
_TableCreateManyToMany,
27+
_TableDeleteRelationship,
28+
_TableGetRelationship,
29+
_TableCreateLookupField,
2130
_QuerySql,
2231
_extract_boundary,
2332
_raise_top_level_batch_error,
24-
_split_multipart,
2533
_parse_mime_part,
2634
_parse_http_response_part,
2735
_CRLF,
2836
)
29-
from PowerPlatform.Dataverse.core.errors import HttpError
37+
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError, ValidationError
3038
from PowerPlatform.Dataverse.models.upsert import UpsertItem
3139
from PowerPlatform.Dataverse.data._raw_request import _RawRequest
32-
from PowerPlatform.Dataverse.models.batch import BatchItemResponse
3340

3441

3542
def _make_od():
@@ -389,12 +396,36 @@ def test_exceeds_1000_raises(self):
389396
client = _BatchClient(od)
390397

391398
items = [_RecordGet(table="account", record_id=f"guid-{i}") for i in range(1001)]
392-
from PowerPlatform.Dataverse.core.errors import ValidationError
393-
394399
with self.assertRaises(ValidationError):
395400
client.execute(items)
396401

397402

403+
class TestContinueOnError(unittest.TestCase):
404+
"""execute() sends Prefer: odata.continue-on-error when requested."""
405+
406+
def setUp(self):
407+
self.od = _make_od()
408+
self.od._build_get.return_value = _RawRequest(method="GET", url="https://x/accounts(g)")
409+
mock_resp = MagicMock()
410+
mock_resp.headers = {"Content-Type": 'multipart/mixed; boundary="batch_x"'}
411+
mock_resp.status_code = 200
412+
mock_resp.text = "--batch_x\r\n\r\nHTTP/1.1 204 No Content\r\n\r\n\r\n--batch_x--"
413+
self.od._request.return_value = mock_resp
414+
self.client = _BatchClient(self.od)
415+
416+
def test_continue_on_error_header_sent(self):
417+
"""Prefer: odata.continue-on-error header is included when continue_on_error=True."""
418+
self.client.execute([_RecordGet(table="account", record_id="guid-1")], continue_on_error=True)
419+
_, kwargs = self.od._request.call_args
420+
self.assertEqual(kwargs.get("headers", {}).get("Prefer"), "odata.continue-on-error")
421+
422+
def test_no_continue_on_error_header_by_default(self):
423+
"""Prefer header is absent when continue_on_error is not set."""
424+
self.client.execute([_RecordGet(table="account", record_id="guid-1")])
425+
_, kwargs = self.od._request.call_args
426+
self.assertNotIn("Prefer", kwargs.get("headers", {}))
427+
428+
398429
class TestChangeSetInternal(unittest.TestCase):
399430
def test_add_create_returns_dollar_n(self):
400431
cs = _ChangeSet()
@@ -628,5 +659,287 @@ def test_parse_batch_response_raises_on_missing_boundary(self):
628659
client._parse_batch_response(resp)
629660

630661

662+
class TestContinueOnError(unittest.TestCase):
663+
"""execute() sends Prefer: odata.continue-on-error when requested."""
664+
665+
def setUp(self):
666+
self.od = _make_od()
667+
self.od._build_get.return_value = _RawRequest(method="GET", url="https://x/accounts(g)")
668+
mock_resp = MagicMock()
669+
mock_resp.headers = {"Content-Type": 'multipart/mixed; boundary="batch_x"'}
670+
mock_resp.status_code = 200
671+
mock_resp.text = "--batch_x\r\n\r\nHTTP/1.1 204 No Content\r\n\r\n\r\n--batch_x--"
672+
self.od._request.return_value = mock_resp
673+
self.client = _BatchClient(self.od)
674+
675+
def test_continue_on_error_header_sent(self):
676+
self.client.execute([_RecordGet(table="account", record_id="guid-1")], continue_on_error=True)
677+
_, kwargs = self.od._request.call_args
678+
self.assertEqual(kwargs.get("headers", {}).get("Prefer"), "odata.continue-on-error")
679+
680+
def test_no_continue_on_error_header_by_default(self):
681+
self.client.execute([_RecordGet(table="account", record_id="guid-1")])
682+
_, kwargs = self.od._request.call_args
683+
self.assertNotIn("Prefer", kwargs.get("headers", {}))
684+
685+
686+
class TestResolveItemDispatch(unittest.TestCase):
687+
"""_resolve_item() routes each intent type to the correct resolver."""
688+
689+
def _client_and_od(self):
690+
od = _make_od()
691+
client = _BatchClient(od)
692+
return client, od
693+
694+
def test_dispatch_record_update(self):
695+
"""_resolve_item routes _RecordUpdate to _resolve_record_update."""
696+
client, od = self._client_and_od()
697+
od._build_update.return_value = MagicMock()
698+
op = _RecordUpdate(table="account", ids="guid-1", changes={"name": "X"})
699+
result = client._resolve_item(op)
700+
od._build_update.assert_called_once()
701+
self.assertEqual(len(result), 1)
702+
703+
def test_dispatch_record_delete(self):
704+
"""_resolve_item routes _RecordDelete to _resolve_record_delete."""
705+
client, od = self._client_and_od()
706+
od._build_delete.return_value = MagicMock()
707+
op = _RecordDelete(table="account", ids="guid-1")
708+
result = client._resolve_item(op)
709+
od._build_delete.assert_called_once()
710+
self.assertEqual(len(result), 1)
711+
712+
def test_dispatch_table_create(self):
713+
"""_resolve_item routes _TableCreate to _build_create_entity."""
714+
client, od = self._client_and_od()
715+
od._build_create_entity.return_value = MagicMock()
716+
op = _TableCreate(table="new_Widget", columns={"new_name": str})
717+
result = client._resolve_item(op)
718+
od._build_create_entity.assert_called_once()
719+
self.assertEqual(len(result), 1)
720+
721+
def test_dispatch_table_delete(self):
722+
"""_resolve_item routes _TableDelete, resolving MetadataId before calling _build_delete_entity."""
723+
client, od = self._client_and_od()
724+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
725+
od._build_delete_entity.return_value = MagicMock()
726+
op = _TableDelete(table="new_Widget")
727+
result = client._resolve_item(op)
728+
od._build_delete_entity.assert_called_once_with("meta-1")
729+
self.assertEqual(len(result), 1)
730+
731+
def test_dispatch_table_get(self):
732+
"""_resolve_item routes _TableGet to _build_get_entity."""
733+
client, od = self._client_and_od()
734+
od._build_get_entity.return_value = MagicMock()
735+
op = _TableGet(table="account")
736+
result = client._resolve_item(op)
737+
od._build_get_entity.assert_called_once()
738+
self.assertEqual(len(result), 1)
739+
740+
def test_dispatch_table_list(self):
741+
"""_resolve_item routes _TableList to _build_list_entities."""
742+
client, od = self._client_and_od()
743+
od._build_list_entities.return_value = MagicMock()
744+
op = _TableList()
745+
result = client._resolve_item(op)
746+
od._build_list_entities.assert_called_once()
747+
self.assertEqual(len(result), 1)
748+
749+
def test_dispatch_table_add_columns(self):
750+
"""_resolve_item routes _TableAddColumns, emitting one request per column."""
751+
client, od = self._client_and_od()
752+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
753+
od._build_create_column.return_value = MagicMock()
754+
op = _TableAddColumns(table="account", columns={"new_col": str})
755+
result = client._resolve_item(op)
756+
od._build_create_column.assert_called_once()
757+
self.assertEqual(len(result), 1)
758+
759+
def test_dispatch_table_remove_columns(self):
760+
"""_resolve_item routes _TableRemoveColumns, fetching attribute metadata before deleting."""
761+
client, od = self._client_and_od()
762+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
763+
od._get_attribute_metadata.return_value = {"MetadataId": "attr-1"}
764+
od._build_delete_column.return_value = MagicMock()
765+
op = _TableRemoveColumns(table="account", columns="new_col")
766+
result = client._resolve_item(op)
767+
od._build_delete_column.assert_called_once()
768+
self.assertEqual(len(result), 1)
769+
770+
def test_dispatch_table_create_one_to_many(self):
771+
"""_resolve_item routes _TableCreateOneToMany, merging lookup into relationship body."""
772+
client, od = self._client_and_od()
773+
od._build_create_relationship.return_value = MagicMock()
774+
lookup = MagicMock()
775+
lookup.to_dict.return_value = {}
776+
relationship = MagicMock()
777+
relationship.to_dict.return_value = {}
778+
op = _TableCreateOneToMany(lookup=lookup, relationship=relationship)
779+
result = client._resolve_item(op)
780+
od._build_create_relationship.assert_called_once()
781+
self.assertEqual(len(result), 1)
782+
783+
def test_dispatch_table_create_many_to_many(self):
784+
"""_resolve_item routes _TableCreateManyToMany to _build_create_relationship."""
785+
client, od = self._client_and_od()
786+
od._build_create_relationship.return_value = MagicMock()
787+
relationship = MagicMock()
788+
relationship.to_dict.return_value = {}
789+
op = _TableCreateManyToMany(relationship=relationship)
790+
result = client._resolve_item(op)
791+
od._build_create_relationship.assert_called_once()
792+
self.assertEqual(len(result), 1)
793+
794+
def test_dispatch_table_delete_relationship(self):
795+
"""_resolve_item routes _TableDeleteRelationship, passing relationship_id."""
796+
client, od = self._client_and_od()
797+
od._build_delete_relationship.return_value = MagicMock()
798+
op = _TableDeleteRelationship(relationship_id="rel-guid-1")
799+
result = client._resolve_item(op)
800+
od._build_delete_relationship.assert_called_once_with("rel-guid-1")
801+
self.assertEqual(len(result), 1)
802+
803+
def test_dispatch_table_get_relationship(self):
804+
"""_resolve_item routes _TableGetRelationship, passing schema_name."""
805+
client, od = self._client_and_od()
806+
od._build_get_relationship.return_value = MagicMock()
807+
op = _TableGetRelationship(schema_name="new_account_contact")
808+
result = client._resolve_item(op)
809+
od._build_get_relationship.assert_called_once_with("new_account_contact")
810+
self.assertEqual(len(result), 1)
811+
812+
def test_dispatch_table_create_lookup_field(self):
813+
"""_resolve_item routes _TableCreateLookupField, building lookup and relationship models."""
814+
client, od = self._client_and_od()
815+
lookup = MagicMock()
816+
lookup.to_dict.return_value = {}
817+
relationship = MagicMock()
818+
relationship.to_dict.return_value = {}
819+
od._build_lookup_field_models.return_value = (lookup, relationship)
820+
od._build_create_relationship.return_value = MagicMock()
821+
op = _TableCreateLookupField(
822+
referencing_table="new_Widget",
823+
lookup_field_name="new_accountid",
824+
referenced_table="account",
825+
)
826+
result = client._resolve_item(op)
827+
od._build_lookup_field_models.assert_called_once()
828+
od._build_create_relationship.assert_called_once()
829+
self.assertEqual(len(result), 1)
830+
831+
def test_dispatch_query_sql(self):
832+
"""_resolve_item routes _QuerySql to _build_sql, passing the SQL string."""
833+
client, od = self._client_and_od()
834+
od._build_sql.return_value = MagicMock()
835+
op = _QuerySql(sql="SELECT name FROM account")
836+
result = client._resolve_item(op)
837+
od._build_sql.assert_called_once_with("SELECT name FROM account")
838+
self.assertEqual(len(result), 1)
839+
840+
841+
class TestResolveOneChangeset(unittest.TestCase):
842+
"""_resolve_one() raises ValidationError when operation produces != 1 request."""
843+
844+
def test_multi_request_op_in_changeset_raises(self):
845+
"""use_bulk_delete=False with 2 ids produces 2 requests — not allowed in a changeset."""
846+
od = _make_od()
847+
client = _BatchClient(od)
848+
od._build_delete.return_value = MagicMock()
849+
op = _RecordDelete(table="account", ids=["guid-1", "guid-2"], use_bulk_delete=False)
850+
with self.assertRaises(ValidationError):
851+
client._resolve_one(op)
852+
853+
854+
class TestRequireEntityMetadata(unittest.TestCase):
855+
"""_require_entity_metadata raises MetadataError when table not found."""
856+
857+
def test_missing_entity_raises_metadata_error(self):
858+
"""MetadataError raised when _get_entity_by_table_schema_name returns None."""
859+
od = _make_od()
860+
od._get_entity_by_table_schema_name.return_value = None
861+
client = _BatchClient(od)
862+
with self.assertRaises(MetadataError):
863+
client._require_entity_metadata("new_Missing")
864+
865+
def test_entity_without_metadata_id_raises(self):
866+
"""MetadataError raised when entity exists but has no MetadataId field."""
867+
od = _make_od()
868+
od._get_entity_by_table_schema_name.return_value = {"LogicalName": "new_missing"}
869+
client = _BatchClient(od)
870+
with self.assertRaises(MetadataError):
871+
client._require_entity_metadata("new_Missing")
872+
873+
def test_valid_entity_returns_metadata_id(self):
874+
"""Returns MetadataId string when entity is found and has a MetadataId."""
875+
od = _make_od()
876+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-abc"}
877+
client = _BatchClient(od)
878+
result = client._require_entity_metadata("account")
879+
self.assertEqual(result, "meta-abc")
880+
881+
882+
class TestTableRemoveColumnsResolver(unittest.TestCase):
883+
"""_resolve_table_remove_columns covers string input and missing column error."""
884+
885+
def _client_and_od(self):
886+
od = _make_od()
887+
client = _BatchClient(od)
888+
return client, od
889+
890+
def test_single_string_column_resolved(self):
891+
"""A single string column name is accepted and resolved to one delete request."""
892+
client, od = self._client_and_od()
893+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
894+
od._get_attribute_metadata.return_value = {"MetadataId": "attr-1"}
895+
od._build_delete_column.return_value = MagicMock()
896+
op = _TableRemoveColumns(table="account", columns="new_col")
897+
result = client._resolve_table_remove_columns(op)
898+
self.assertEqual(len(result), 1)
899+
900+
def test_missing_column_raises_metadata_error(self):
901+
"""MetadataError raised when attribute metadata is not found for the column."""
902+
client, od = self._client_and_od()
903+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
904+
od._get_attribute_metadata.return_value = None
905+
op = _TableRemoveColumns(table="account", columns="new_missing")
906+
with self.assertRaises(MetadataError):
907+
client._resolve_table_remove_columns(op)
908+
909+
def test_column_without_metadata_id_raises(self):
910+
"""MetadataError raised when attribute metadata exists but has no MetadataId."""
911+
client, od = self._client_and_od()
912+
od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"}
913+
od._get_attribute_metadata.return_value = {"AttributeType": "String"}
914+
op = _TableRemoveColumns(table="account", columns="new_col")
915+
with self.assertRaises(MetadataError):
916+
client._resolve_table_remove_columns(op)
917+
918+
919+
class TestParseMimePartNoSeparator(unittest.TestCase):
920+
"""_parse_mime_part handles raw string with no blank-line separator."""
921+
922+
def test_no_double_newline_returns_empty_body(self):
923+
"""When raw part has no blank-line separator, headers are parsed and body is empty."""
924+
raw = "Content-Type: application/http"
925+
headers, body = _parse_mime_part(raw)
926+
self.assertEqual(headers.get("content-type"), "application/http")
927+
self.assertEqual(body, "")
928+
929+
930+
class TestParseHttpResponsePartMalformed(unittest.TestCase):
931+
"""_parse_http_response_part returns None for malformed status lines."""
932+
933+
def test_status_line_too_short_returns_none(self):
934+
"""Returns None when status line has fewer than 2 tokens (no status code)."""
935+
result = _parse_http_response_part("HTTP/1.1\r\n\r\n", content_id=None)
936+
self.assertIsNone(result)
937+
938+
def test_non_integer_status_code_returns_none(self):
939+
"""Returns None when status code token is not a valid integer."""
940+
result = _parse_http_response_part("HTTP/1.1 XYZ OK\r\n\r\n", content_id=None)
941+
self.assertIsNone(result)
942+
943+
631944
if __name__ == "__main__":
632945
unittest.main()

0 commit comments

Comments
 (0)