@@ -87,7 +87,8 @@ def test_empty_result_returns_empty_query_result(self):
8787
8888 def test_pagination_fetches_all_pages (self ):
8989 """execute_pages() drives the HTTP loop; each page yields one QueryResult."""
90- cookie_raw = "%25253Cpagingcookie%252520pagingcookie%25253D%252522"
90+ # Annotation is outer XML; pagingcookie attribute is double URL-encoded inner cookie.
91+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
9192 page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
9293 page2 = self ._mock_response ([{"name" : "B" }], more = False )
9394 self .client ._odata ._request .side_effect = [page1 , page2 ]
@@ -98,7 +99,8 @@ def test_pagination_fetches_all_pages(self):
9899
99100 def test_pagination_second_request_includes_page_and_cookie (self ):
100101 """execute_pages() injects the decoded paging cookie into the second request."""
101- cookie_raw = "%25253Cpagingcookie%252520test%25253D%252522"
102+ # pagingcookie="%253Cc%252F%253E": double URL-decode gives "<c/>" (the inner cookie XML).
103+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
102104 page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
103105 page2 = self ._mock_response ([{"name" : "B" }], more = False )
104106 self .client ._odata ._request .side_effect = [page1 , page2 ]
@@ -168,7 +170,7 @@ def test_no_deprecation_warning_emitted(self):
168170
169171 def test_execute_pages_returns_iterator_of_query_result (self ):
170172 """execute_pages() yields QueryResult objects, one per HTTP page."""
171- cookie_raw = "%25253Cpagingcookie%252520pagingcookie%25253D%252522"
173+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
172174 page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
173175 page2 = self ._mock_response ([{"name" : "B" }], more = False )
174176 self .client ._odata ._request .side_effect = [page1 , page2 ]
@@ -180,7 +182,7 @@ def test_execute_pages_returns_iterator_of_query_result(self):
180182
181183 def test_execute_pages_one_http_call_per_page (self ):
182184 """Each execute_pages() iteration fires exactly one HTTP request."""
183- cookie_raw = "%25253Cpagingcookie%252520pagingcookie%25253D%252522"
185+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
184186 page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
185187 page2 = self ._mock_response ([{"name" : "B" }], more = False )
186188 self .client ._odata ._request .side_effect = [page1 , page2 ]
@@ -193,7 +195,7 @@ def test_execute_pages_one_http_call_per_page(self):
193195
194196 def test_execute_pages_per_page_records (self ):
195197 """Each page yielded by execute_pages() contains only its own records."""
196- cookie_raw = "%25253Cpagingcookie%252520pagingcookie%25253D%252522"
198+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
197199 page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
198200 page2 = self ._mock_response ([{"name" : "B" }, {"name" : "C" }], more = False )
199201 self .client ._odata ._request .side_effect = [page1 , page2 ]
@@ -204,6 +206,123 @@ def test_execute_pages_per_page_records(self):
204206 self .assertEqual (pages [0 ].first ()["name" ], "A" )
205207 self .assertEqual (pages [1 ].first ()["name" ], "B" )
206208
209+ # ------------------------------------------------------------------
210+ # Input validation
211+ # ------------------------------------------------------------------
212+
213+ def test_non_string_input_raises_validation_error (self ):
214+ from PowerPlatform .Dataverse .core .errors import ValidationError
215+
216+ with self .assertRaises (ValidationError ):
217+ self .client .query .fetchxml (123 )
218+
219+ def test_empty_string_raises_validation_error (self ):
220+ from PowerPlatform .Dataverse .core .errors import ValidationError
221+
222+ with self .assertRaises (ValidationError ):
223+ self .client .query .fetchxml ("" )
224+
225+ def test_whitespace_only_raises_validation_error (self ):
226+ from PowerPlatform .Dataverse .core .errors import ValidationError
227+
228+ with self .assertRaises (ValidationError ):
229+ self .client .query .fetchxml (" " )
230+
231+ def test_malformed_xml_raises_validation_error (self ):
232+ from PowerPlatform .Dataverse .core .errors import ValidationError
233+
234+ with self .assertRaises (ValidationError ):
235+ self .client .query .fetchxml ("<fetch><unclosed>" )
236+
237+ def test_url_too_long_raises_validation_error (self ):
238+ """XML whose URL-encoded form exceeds 32,768 chars is rejected before any HTTP."""
239+ from PowerPlatform .Dataverse .core .errors import ValidationError
240+
241+ # Alphanumeric chars are URL-safe and don't expand; a 32,769-char name attribute
242+ # value pushes the encoded XML over the limit.
243+ long_name = "a" * 32_769
244+ big_xml = f'<fetch><entity name="{ long_name } "><attribute name="x"/></entity></fetch>'
245+ with self .assertRaises (ValidationError ):
246+ self .client .query .fetchxml (big_xml )
247+
248+ # ------------------------------------------------------------------
249+ # Paging behaviour
250+ # ------------------------------------------------------------------
251+
252+ def test_morerecords_string_true_continues_paging (self ):
253+ """morerecords annotation as string "true" (not bool) is handled correctly."""
254+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
255+ page1_payload = {
256+ "value" : [{"name" : "A" }],
257+ "@Microsoft.Dynamics.CRM.morerecords" : "true" ,
258+ "@Microsoft.Dynamics.CRM.fetchxmlpagingcookie" : cookie_raw ,
259+ }
260+ page2_payload = {
261+ "value" : [{"name" : "B" }],
262+ "@Microsoft.Dynamics.CRM.morerecords" : False ,
263+ }
264+ r1 , r2 = MagicMock (), MagicMock ()
265+ r1 .json .return_value = page1_payload
266+ r2 .json .return_value = page2_payload
267+ self .client ._odata ._request .side_effect = [r1 , r2 ]
268+
269+ result = self .client .query .fetchxml (self ._fetch_xml ()).execute ()
270+ self .assertEqual (len (result ), 2 )
271+ self .assertEqual (self .client ._odata ._request .call_count , 2 )
272+
273+ def test_simple_paging_fallback_emits_user_warning (self ):
274+ """No cookie returned with morerecords=True triggers a UserWarning."""
275+ page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = "" )
276+ page2 = self ._mock_response ([{"name" : "B" }], more = False )
277+ self .client ._odata ._request .side_effect = [page1 , page2 ]
278+
279+ with warnings .catch_warnings (record = True ) as caught :
280+ warnings .simplefilter ("always" )
281+ list (self .client .query .fetchxml (self ._fetch_xml ()).execute_pages ())
282+
283+ user_warnings = [w for w in caught if issubclass (w .category , UserWarning )]
284+ self .assertEqual (len (user_warnings ), 1 )
285+ self .assertIn ("simple paging" , str (user_warnings [0 ].message ).lower ())
286+
287+ def test_simple_paging_fallback_fetches_all_pages (self ):
288+ """Simple paging fallback continues iterating; caller still gets all records."""
289+ page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = "" )
290+ page2 = self ._mock_response ([{"name" : "B" }], more = False )
291+ self .client ._odata ._request .side_effect = [page1 , page2 ]
292+
293+ with warnings .catch_warnings (record = True ):
294+ warnings .simplefilter ("always" )
295+ result = self .client .query .fetchxml (self ._fetch_xml ()).execute ()
296+
297+ self .assertEqual (len (result ), 2 )
298+ self .assertEqual (self .client ._odata ._request .call_count , 2 )
299+
300+ def test_malformed_cookie_falls_back_to_simple_paging (self ):
301+ """A cookie annotation that is not parseable as XML triggers simple paging fallback."""
302+ page1 = self ._mock_response ([{"name" : "A" }], more = True , cookie = "not-valid-xml" )
303+ page2 = self ._mock_response ([{"name" : "B" }], more = False )
304+ self .client ._odata ._request .side_effect = [page1 , page2 ]
305+
306+ with warnings .catch_warnings (record = True ) as caught :
307+ warnings .simplefilter ("always" )
308+ result = self .client .query .fetchxml (self ._fetch_xml ()).execute ()
309+
310+ self .assertEqual (len (result ), 2 )
311+ user_warnings = [w for w in caught if issubclass (w .category , UserWarning )]
312+ self .assertEqual (len (user_warnings ), 1 )
313+
314+ def test_max_pages_exceeded_raises (self ):
315+ """Paging loop raises ValidationError after exceeding _MAX_PAGES."""
316+ from PowerPlatform .Dataverse .core .errors import ValidationError
317+
318+ cookie_raw = '<cookie pagenumber="2" pagingcookie="%253Cc%252F%253E" istracking="False" />'
319+ always_more = self ._mock_response ([{"name" : "A" }], more = True , cookie = cookie_raw )
320+ self .client ._odata ._request .return_value = always_more
321+
322+ with patch ("PowerPlatform.Dataverse.models.fetchxml_query._MAX_PAGES" , 3 ):
323+ with self .assertRaises (ValidationError ):
324+ list (self .client .query .fetchxml (self ._fetch_xml ()).execute_pages ())
325+
207326
208327# ---------------------------------------------------------------------------
209328# Removed SQL helpers — raise AttributeError
0 commit comments