Skip to content

Commit d2c5cc8

Browse files
Abel Milashclaude
andcommitted
Add unit test cases for uncovered areas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a9f0b7f commit d2c5cc8

10 files changed

Lines changed: 488 additions & 188 deletions

tests/unit/core/test_auth.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import unittest
5+
from unittest.mock import MagicMock
6+
7+
from azure.core.credentials import TokenCredential
8+
9+
from PowerPlatform.Dataverse.core._auth import _AuthManager, _TokenPair
10+
11+
12+
class TestAuthManager(unittest.TestCase):
13+
"""Tests for _AuthManager credential validation and token acquisition."""
14+
15+
def test_non_token_credential_raises(self):
16+
"""_AuthManager raises TypeError when credential does not implement TokenCredential."""
17+
with self.assertRaises(TypeError):
18+
_AuthManager("not-a-credential")
19+
20+
def test_acquire_token_returns_token_pair(self):
21+
"""_acquire_token calls get_token and returns a _TokenPair with scope and token."""
22+
mock_credential = MagicMock(spec=TokenCredential)
23+
mock_credential.get_token.return_value = MagicMock(token="my-access-token")
24+
25+
manager = _AuthManager(mock_credential)
26+
result = manager._acquire_token("https://org.crm.dynamics.com/.default")
27+
28+
mock_credential.get_token.assert_called_once_with("https://org.crm.dynamics.com/.default")
29+
self.assertIsInstance(result, _TokenPair)
30+
self.assertEqual(result.resource, "https://org.crm.dynamics.com/.default")
31+
self.assertEqual(result.access_token, "my-access-token")
32+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import unittest
5+
from unittest.mock import MagicMock, patch, call
6+
7+
import requests
8+
9+
from PowerPlatform.Dataverse.core._http import _HttpClient
10+
11+
12+
class TestHttpClientTimeout(unittest.TestCase):
13+
"""Tests for automatic timeout selection in _HttpClient._request."""
14+
15+
def _make_response(self, status=200):
16+
resp = MagicMock(spec=requests.Response)
17+
resp.status_code = status
18+
return resp
19+
20+
def test_get_uses_10s_default_timeout(self):
21+
"""GET requests use 10s default when no timeout is specified."""
22+
client = _HttpClient(retries=1)
23+
with patch("requests.request", return_value=self._make_response()) as mock_req:
24+
client._request("get", "https://example.com/data")
25+
_, kwargs = mock_req.call_args
26+
self.assertEqual(kwargs["timeout"], 10)
27+
28+
def test_post_uses_120s_default_timeout(self):
29+
"""POST requests use 120s default when no timeout is specified."""
30+
client = _HttpClient(retries=1)
31+
with patch("requests.request", return_value=self._make_response()) as mock_req:
32+
client._request("post", "https://example.com/data")
33+
_, kwargs = mock_req.call_args
34+
self.assertEqual(kwargs["timeout"], 120)
35+
36+
def test_delete_uses_120s_default_timeout(self):
37+
"""DELETE requests use 120s default when no timeout is specified."""
38+
client = _HttpClient(retries=1)
39+
with patch("requests.request", return_value=self._make_response()) as mock_req:
40+
client._request("delete", "https://example.com/data")
41+
_, kwargs = mock_req.call_args
42+
self.assertEqual(kwargs["timeout"], 120)
43+
44+
def test_default_timeout_overrides_per_method_default(self):
45+
"""Explicit default_timeout on the client overrides per-method defaults."""
46+
client = _HttpClient(retries=1, timeout=30.0)
47+
with patch("requests.request", return_value=self._make_response()) as mock_req:
48+
client._request("get", "https://example.com/data")
49+
_, kwargs = mock_req.call_args
50+
self.assertEqual(kwargs["timeout"], 30.0)
51+
52+
def test_explicit_timeout_in_kwargs_is_not_overridden(self):
53+
"""If timeout is already in kwargs it is passed through unchanged."""
54+
client = _HttpClient(retries=1, timeout=30.0)
55+
with patch("requests.request", return_value=self._make_response()) as mock_req:
56+
client._request("get", "https://example.com/data", timeout=5)
57+
_, kwargs = mock_req.call_args
58+
self.assertEqual(kwargs["timeout"], 5)
59+
60+
61+
class TestHttpClientRequester(unittest.TestCase):
62+
"""Tests for session vs direct requests.request routing."""
63+
64+
def _make_response(self):
65+
resp = MagicMock(spec=requests.Response)
66+
resp.status_code = 200
67+
return resp
68+
69+
def test_uses_requests_request_when_no_session(self):
70+
"""Without a session, _request uses requests.request directly."""
71+
client = _HttpClient(retries=1)
72+
with patch("requests.request", return_value=self._make_response()) as mock_req:
73+
client._request("get", "https://example.com/data")
74+
mock_req.assert_called_once()
75+
76+
def test_uses_session_request_when_session_provided(self):
77+
"""With a session, _request uses session.request instead of requests.request."""
78+
mock_session = MagicMock(spec=requests.Session)
79+
mock_session.request.return_value = self._make_response()
80+
client = _HttpClient(retries=1, session=mock_session)
81+
with patch("requests.request") as mock_req:
82+
client._request("get", "https://example.com/data")
83+
mock_session.request.assert_called_once()
84+
mock_req.assert_not_called()
85+
86+
87+
class TestHttpClientRetry(unittest.TestCase):
88+
"""Tests for retry behavior on RequestException."""
89+
90+
def test_retries_on_request_exception_and_succeeds(self):
91+
"""Retries after a RequestException and returns response on second attempt."""
92+
resp = MagicMock(spec=requests.Response)
93+
resp.status_code = 200
94+
client = _HttpClient(retries=2, backoff=0)
95+
with patch("requests.request", side_effect=[requests.exceptions.ConnectionError(), resp]) as mock_req:
96+
with patch("time.sleep"):
97+
result = client._request("get", "https://example.com/data")
98+
self.assertEqual(mock_req.call_count, 2)
99+
self.assertIs(result, resp)
100+
101+
def test_raises_after_all_retries_exhausted(self):
102+
"""Raises RequestException after all retry attempts fail."""
103+
client = _HttpClient(retries=3, backoff=0)
104+
with patch("requests.request", side_effect=requests.exceptions.ConnectionError("timeout")):
105+
with patch("time.sleep"):
106+
with self.assertRaises(requests.exceptions.RequestException):
107+
client._request("get", "https://example.com/data")
108+
109+
def test_backoff_delay_between_retries(self):
110+
"""Sleeps with exponential backoff between retry attempts."""
111+
resp = MagicMock(spec=requests.Response)
112+
resp.status_code = 200
113+
client = _HttpClient(retries=3, backoff=1.0)
114+
side_effects = [
115+
requests.exceptions.ConnectionError(),
116+
requests.exceptions.ConnectionError(),
117+
resp,
118+
]
119+
with patch("requests.request", side_effect=side_effects):
120+
with patch("time.sleep") as mock_sleep:
121+
client._request("get", "https://example.com/data")
122+
# First retry: delay = 1.0 * 2^0 = 1.0, second retry: 1.0 * 2^1 = 2.0
123+
mock_sleep.assert_has_calls([call(1.0), call(2.0)])

tests/unit/core/test_http_errors.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,42 @@ def test_correlation_id_shared_inside_call_scope():
180180
h1, h2 = recorder.recorded_headers
181181
assert h1["x-ms-client-request-id"] != h2["x-ms-client-request-id"]
182182
assert h1["x-ms-correlation-id"] == h2["x-ms-correlation-id"]
183+
184+
185+
# --- ValidationError / SQLParseError / HttpError optional fields ---
186+
187+
def test_validation_error_instantiates():
188+
"""ValidationError can be raised and carries the correct code."""
189+
from PowerPlatform.Dataverse.core.errors import ValidationError
190+
191+
err = ValidationError("bad input", subcode="missing_field", details={"field": "name"})
192+
assert err.code == "validation_error"
193+
assert err.subcode == "missing_field"
194+
assert err.details["field"] == "name"
195+
assert err.source == "client"
196+
197+
198+
def test_sql_parse_error_instantiates():
199+
"""SQLParseError can be raised and carries the correct code."""
200+
from PowerPlatform.Dataverse.core.errors import SQLParseError
201+
202+
err = SQLParseError("unexpected token", subcode="syntax_error")
203+
assert err.code == "sql_parse_error"
204+
assert err.subcode == "syntax_error"
205+
assert err.source == "client"
206+
207+
208+
def test_http_error_optional_diagnostic_fields():
209+
"""HttpError stores correlation_id, service_request_id, and traceparent in details."""
210+
from PowerPlatform.Dataverse.core.errors import HttpError
211+
212+
err = HttpError(
213+
"Server error",
214+
status_code=500,
215+
correlation_id="corr-123",
216+
service_request_id="svc-456",
217+
traceparent="00-abc-def-01",
218+
)
219+
assert err.details["correlation_id"] == "corr-123"
220+
assert err.details["service_request_id"] == "svc-456"
221+
assert err.details["traceparent"] == "00-abc-def-01"

tests/unit/data/test_relationships.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ def test_create_m2m_relationship_returns_result(self):
196196
self.assertEqual(result["entity1_logical_name"], "account")
197197
self.assertEqual(result["entity2_logical_name"], "contact")
198198

199+
def test_create_m2m_relationship_with_solution(self):
200+
"""Solution name is added as MSCRM.SolutionUniqueName header."""
201+
mock_response = Mock()
202+
mock_response.headers = {
203+
"OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(abcd1234-abcd-1234-abcd-1234abcd5678)"
204+
}
205+
self.client._mock_request.return_value = mock_response
206+
207+
self.client._create_many_to_many_relationship(self.relationship, solution="MySolution")
208+
209+
headers = self.client._mock_request.call_args.kwargs["headers"]
210+
self.assertEqual(headers["MSCRM.SolutionUniqueName"], "MySolution")
211+
199212

200213
class TestDeleteRelationship(unittest.TestCase):
201214
"""Tests for _delete_relationship method."""

0 commit comments

Comments
 (0)