Skip to content

Commit c02b6e9

Browse files
Abel Milashclaude
andcommitted
Raise unit test coverage to 96.6%: exclude tooling files, add async batch tests
pyproject.toml: - Omit _skill_installer.py (CLI installer, not SDK logic) and extensions/__init__.py (empty placeholder) from coverage measurement tests/unit/aio/data/test_async_batch_internal.py: - TestSyncResponseWrapper: json() returns payload, None, status_code, text - TestExecuteEdgeCases: batch size exceeded raises, json parse failure falls back to empty dict - TestResolveAllEdgeCases: empty changeset silently skipped, non-changeset item resolved and extended - TestResolveItemDispatch: one test per intent type exercising every branch of _resolve_item() (record update/upsert, all table types, sql, unknown) - Add _build_list, _build_create_entity, _build_get_entity, _build_list_entities, _build_create_relationship, _build_delete_relationship, _build_get_relationship, _build_lookup_field_models to _make_batch_client() - Add imports for _SyncResponseWrapper, _MAX_BATCH_SIZE, and all intent types Result: 2091 tests, 96.63% coverage (was 93.64%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1b4c811 commit c02b6e9

2 files changed

Lines changed: 238 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ asyncio_mode = "auto"
103103

104104
[tool.coverage.run]
105105
source = ["src/PowerPlatform"]
106+
omit = [
107+
"*/Dataverse/_skill_installer.py",
108+
"*/Dataverse/extensions/__init__.py",
109+
]
106110

107111
[tool.coverage.report]
108112
fail_under = 90

tests/unit/aio/data/test_async_batch_internal.py

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010

11-
from PowerPlatform.Dataverse.aio.data._async_batch import _AsyncBatchClient
11+
from PowerPlatform.Dataverse.aio.data._async_batch import _AsyncBatchClient, _SyncResponseWrapper
1212
from PowerPlatform.Dataverse.core.errors import MetadataError, ValidationError
1313
from PowerPlatform.Dataverse.data._batch_base import (
1414
_RecordCreate,
@@ -18,10 +18,19 @@
1818
_RecordUpdate,
1919
_RecordUpsert,
2020
_TableAddColumns,
21+
_TableCreate,
2122
_TableDelete,
23+
_TableGet,
24+
_TableList,
25+
_TableCreateOneToMany,
26+
_TableCreateManyToMany,
27+
_TableDeleteRelationship,
28+
_TableGetRelationship,
29+
_TableCreateLookupField,
2230
_TableRemoveColumns,
2331
_QuerySql,
2432
_ChangeSet,
33+
_MAX_BATCH_SIZE,
2534
)
2635
from PowerPlatform.Dataverse.models.upsert import UpsertItem
2736

@@ -107,6 +116,19 @@ def _make_batch_client():
107116
content_id=None,
108117
)
109118
)
119+
# Sync _build_* for pure-logic table intents inherited from _BatchBase
120+
_raw = lambda method, url: MagicMock(method=method, url=url, body=None, headers=None, content_id=None)
121+
od._build_create_entity = MagicMock(return_value=_raw("POST", "https://x/EntityDefinitions"))
122+
od._build_get_entity = MagicMock(return_value=_raw("GET", "https://x/EntityDefinitions(m)"))
123+
od._build_list_entities = MagicMock(return_value=_raw("GET", "https://x/EntityDefinitions"))
124+
od._build_create_relationship = MagicMock(return_value=_raw("POST", "https://x/RelationshipDefinitions"))
125+
od._build_delete_relationship = MagicMock(return_value=_raw("DELETE", "https://x/RelationshipDefinitions(r)"))
126+
od._build_get_relationship = MagicMock(return_value=_raw("GET", "https://x/RelationshipDefinitions(r)"))
127+
_mock_lookup = MagicMock()
128+
_mock_lookup.to_dict.return_value = {}
129+
_mock_rel = MagicMock()
130+
_mock_rel.to_dict.return_value = {}
131+
od._build_lookup_field_models = MagicMock(return_value=(_mock_lookup, _mock_rel))
110132
return _AsyncBatchClient(od), od
111133

112134

@@ -603,3 +625,214 @@ async def test_not_found_raises(self):
603625
od._get_entity_by_table_schema_name = AsyncMock(return_value=None)
604626
with pytest.raises(MetadataError):
605627
await client._require_entity_metadata("nonexistent")
628+
629+
630+
# ---------------------------------------------------------------------------
631+
# _SyncResponseWrapper
632+
# ---------------------------------------------------------------------------
633+
634+
635+
class TestSyncResponseWrapper:
636+
"""Tests for the _SyncResponseWrapper adapter used in execute()."""
637+
638+
def test_json_returns_payload(self):
639+
"""json() returns the payload passed at construction time."""
640+
wrapper = _SyncResponseWrapper(
641+
status_code=200,
642+
headers={"Content-Type": "application/json"},
643+
text='{"value": []}',
644+
json_payload={"value": []},
645+
)
646+
assert wrapper.json() == {"value": []}
647+
648+
def test_json_returns_none_payload(self):
649+
"""json() returns None when payload was None."""
650+
wrapper = _SyncResponseWrapper(200, {}, "", None)
651+
assert wrapper.json() is None
652+
653+
def test_status_code_stored(self):
654+
"""status_code is accessible as an attribute."""
655+
wrapper = _SyncResponseWrapper(207, {}, "", {})
656+
assert wrapper.status_code == 207
657+
658+
def test_text_stored(self):
659+
"""text is accessible as an attribute."""
660+
wrapper = _SyncResponseWrapper(200, {}, "body text", {})
661+
assert wrapper.text == "body text"
662+
663+
664+
# ---------------------------------------------------------------------------
665+
# execute() edge cases
666+
# ---------------------------------------------------------------------------
667+
668+
669+
class TestExecuteEdgeCases:
670+
"""Tests for execute() error paths not covered by TestExecute."""
671+
672+
async def test_batch_size_exceeded_raises(self):
673+
"""execute() raises ValidationError when more than _MAX_BATCH_SIZE items are resolved."""
674+
client, od = _make_batch_client()
675+
# _RecordCreate × (_MAX_BATCH_SIZE + 1) — each resolves to one request
676+
items = [_RecordCreate(table="account", data={"name": f"X{i}"}) for i in range(_MAX_BATCH_SIZE + 1)]
677+
with pytest.raises(ValidationError, match="exceeds the limit"):
678+
await client.execute(items)
679+
680+
async def test_json_parse_failure_defaults_to_empty_dict(self):
681+
"""execute() uses an empty dict for json_payload when r.json() raises."""
682+
from PowerPlatform.Dataverse.models.batch import BatchResult
683+
684+
client, od = _make_batch_client()
685+
resp_mock = _batch_resp(status=200)
686+
resp_mock.json = AsyncMock(side_effect=ValueError("bad json"))
687+
od._request = AsyncMock(return_value=resp_mock)
688+
item = _RecordCreate(table="account", data={"name": "X"})
689+
with patch.object(client, "_parse_batch_response", return_value=BatchResult()) as mock_parse:
690+
await client.execute([item])
691+
# _parse_batch_response was called; the wrapper's json() returns {} (fallback)
692+
wrapper = mock_parse.call_args[0][0]
693+
assert wrapper.json() == {}
694+
695+
696+
# ---------------------------------------------------------------------------
697+
# _resolve_all() edge cases
698+
# ---------------------------------------------------------------------------
699+
700+
701+
class TestResolveAllEdgeCases:
702+
"""Tests for _resolve_all() paths not covered elsewhere."""
703+
704+
async def test_empty_changeset_is_skipped(self):
705+
"""An empty _ChangeSet is silently dropped from the resolved list."""
706+
client, od = _make_batch_client()
707+
cs = _ChangeSet(_counter=[1])
708+
# No operations added — operations list is empty
709+
result = await client._resolve_all([cs])
710+
assert result == []
711+
712+
async def test_non_changeset_item_extended(self):
713+
"""Non-changeset items are resolved and extended into the flat result."""
714+
client, od = _make_batch_client()
715+
item = _RecordCreate(table="account", data={"name": "X"})
716+
result = await client._resolve_all([item])
717+
assert len(result) == 1
718+
719+
720+
# ---------------------------------------------------------------------------
721+
# _resolve_item() full dispatch coverage
722+
# ---------------------------------------------------------------------------
723+
724+
725+
class TestResolveItemDispatch:
726+
"""One test per intent type — drives every branch of _resolve_item().
727+
728+
Each test replaces the specific resolver method with a mock so only the
729+
dispatch logic is exercised. Record/SQL resolvers are async (awaited);
730+
pure table resolvers inherited from _BatchBase are sync (not awaited).
731+
"""
732+
733+
_sentinel = MagicMock(method="GET", url="https://x/test", body=None, headers=None, content_id=None)
734+
735+
def _async_mock(self):
736+
return AsyncMock(return_value=[self._sentinel])
737+
738+
def _sync_mock(self):
739+
return MagicMock(return_value=[self._sentinel])
740+
741+
async def test_dispatch_record_update(self):
742+
client, _ = _make_batch_client()
743+
client._resolve_record_update = self._async_mock()
744+
result = await client._resolve_item(_RecordUpdate(table="account", ids="g", changes={"name": "X"}))
745+
client._resolve_record_update.assert_called_once()
746+
747+
async def test_dispatch_record_upsert(self):
748+
client, _ = _make_batch_client()
749+
client._resolve_record_upsert = self._async_mock()
750+
item = UpsertItem(alternate_key={"k": "v"}, record={"name": "X"})
751+
result = await client._resolve_item(_RecordUpsert(table="account", items=[item]))
752+
client._resolve_record_upsert.assert_called_once()
753+
754+
async def test_dispatch_table_create(self):
755+
client, _ = _make_batch_client()
756+
client._resolve_table_create = self._sync_mock()
757+
result = await client._resolve_item(_TableCreate(table="new_Test", columns={"new_Name": "string"}))
758+
client._resolve_table_create.assert_called_once()
759+
760+
async def test_dispatch_table_delete(self):
761+
client, _ = _make_batch_client()
762+
client._resolve_table_delete = self._async_mock()
763+
result = await client._resolve_item(_TableDelete(table="new_Test"))
764+
client._resolve_table_delete.assert_called_once()
765+
766+
async def test_dispatch_table_get(self):
767+
client, _ = _make_batch_client()
768+
client._resolve_table_get = self._sync_mock()
769+
result = await client._resolve_item(_TableGet(table="account"))
770+
client._resolve_table_get.assert_called_once()
771+
772+
async def test_dispatch_table_list(self):
773+
client, _ = _make_batch_client()
774+
client._resolve_table_list = self._sync_mock()
775+
result = await client._resolve_item(_TableList())
776+
client._resolve_table_list.assert_called_once()
777+
778+
async def test_dispatch_table_add_columns(self):
779+
client, _ = _make_batch_client()
780+
client._resolve_table_add_columns = self._async_mock()
781+
result = await client._resolve_item(_TableAddColumns(table="account", columns={"new_X": "string"}))
782+
client._resolve_table_add_columns.assert_called_once()
783+
784+
async def test_dispatch_table_remove_columns(self):
785+
client, _ = _make_batch_client()
786+
client._resolve_table_remove_columns = self._async_mock()
787+
result = await client._resolve_item(_TableRemoveColumns(table="account", columns="new_X"))
788+
client._resolve_table_remove_columns.assert_called_once()
789+
790+
async def test_dispatch_table_create_one_to_many(self):
791+
client, _ = _make_batch_client()
792+
client._resolve_table_create_one_to_many = self._sync_mock()
793+
op = _TableCreateOneToMany(relationship=MagicMock(), lookup=MagicMock())
794+
result = await client._resolve_item(op)
795+
client._resolve_table_create_one_to_many.assert_called_once()
796+
797+
async def test_dispatch_table_create_many_to_many(self):
798+
client, _ = _make_batch_client()
799+
client._resolve_table_create_many_to_many = self._sync_mock()
800+
op = _TableCreateManyToMany(relationship=MagicMock())
801+
result = await client._resolve_item(op)
802+
client._resolve_table_create_many_to_many.assert_called_once()
803+
804+
async def test_dispatch_table_delete_relationship(self):
805+
client, _ = _make_batch_client()
806+
client._resolve_table_delete_relationship = self._sync_mock()
807+
result = await client._resolve_item(_TableDeleteRelationship(relationship_id="rel-guid"))
808+
client._resolve_table_delete_relationship.assert_called_once()
809+
810+
async def test_dispatch_table_get_relationship(self):
811+
client, _ = _make_batch_client()
812+
client._resolve_table_get_relationship = self._sync_mock()
813+
result = await client._resolve_item(_TableGetRelationship(schema_name="new_a_c"))
814+
client._resolve_table_get_relationship.assert_called_once()
815+
816+
async def test_dispatch_table_create_lookup_field(self):
817+
client, _ = _make_batch_client()
818+
client._resolve_table_create_lookup_field = self._sync_mock()
819+
result = await client._resolve_item(
820+
_TableCreateLookupField(
821+
referencing_table="contact",
822+
lookup_field_name="new_accountid",
823+
referenced_table="account",
824+
)
825+
)
826+
client._resolve_table_create_lookup_field.assert_called_once()
827+
828+
async def test_dispatch_query_sql(self):
829+
client, _ = _make_batch_client()
830+
client._resolve_query_sql = self._async_mock()
831+
result = await client._resolve_item(_QuerySql(sql="SELECT accountid FROM account"))
832+
client._resolve_query_sql.assert_called_once()
833+
834+
async def test_dispatch_unknown_type_raises(self):
835+
"""An unrecognised intent type raises ValidationError."""
836+
client, _ = _make_batch_client()
837+
with pytest.raises(ValidationError, match="Unknown batch item type"):
838+
await client._resolve_item("not-an-intent")

0 commit comments

Comments
 (0)