|
1 | 1 | import unittest |
| 2 | +from unittest.mock import MagicMock, patch |
2 | 3 |
|
3 | 4 | from TM1py.Objects import Element |
4 | | -from TM1py.Services.ElementService import _build_elements_filter, _coerce_element_types |
| 5 | +from TM1py.Services.ElementService import ( |
| 6 | + ElementService, |
| 7 | + _build_elements_filter, |
| 8 | + _coerce_element_types, |
| 9 | + _odata_str_literal, |
| 10 | +) |
5 | 11 |
|
6 | 12 |
|
7 | 13 | class TestCoerceElementTypes(unittest.TestCase): |
@@ -227,5 +233,74 @@ def test_level_bool_raises(self): |
227 | 233 | _build_elements_filter(None, None, True) |
228 | 234 |
|
229 | 235 |
|
| 236 | +class TestOdataStrLiteralUrlSafety(unittest.TestCase): |
| 237 | + """The filter clause is concatenated raw into the URL query string |
| 238 | + (url += '&$filter=' + clause), so any string literal inside it must |
| 239 | + percent-encode URL-reserved characters (&, %, #, ?) — not just escape |
| 240 | + OData single quotes. Otherwise an element name like 'Sales & Marketing' |
| 241 | + would prematurely terminate $filter and corrupt the query.""" |
| 242 | + |
| 243 | + NAME_EXPR = "tolower(replace(Name,' ',''))" |
| 244 | + |
| 245 | + def test_literal_url_escapes_ampersand(self): |
| 246 | + self.assertEqual(_odata_str_literal("a&b"), "'a%26b'") |
| 247 | + |
| 248 | + def test_literal_url_escapes_percent(self): |
| 249 | + self.assertEqual(_odata_str_literal("a%b"), "'a%25b'") |
| 250 | + |
| 251 | + def test_literal_url_escapes_hash(self): |
| 252 | + self.assertEqual(_odata_str_literal("a#b"), "'a%23b'") |
| 253 | + |
| 254 | + def test_literal_url_escapes_question_mark(self): |
| 255 | + self.assertEqual(_odata_str_literal("a?b"), "'a%3Fb'") |
| 256 | + |
| 257 | + def test_literal_still_escapes_single_quote(self): |
| 258 | + # OData spec requires doubling embedded single quotes; the URL fix |
| 259 | + # must not regress this. |
| 260 | + self.assertEqual(_odata_str_literal("O'Brien"), "'O''Brien'") |
| 261 | + |
| 262 | + def test_pattern_with_ampersand_produces_url_safe_filter(self): |
| 263 | + # The bug: 'Sales & Marketing' would emit a raw '&' that splits the |
| 264 | + # query string at &$filter= and creates a phantom '$filter' param. |
| 265 | + result = _build_elements_filter(None, "*Sales & Marketing*", None) |
| 266 | + self.assertNotIn("&", result) |
| 267 | + self.assertIn("%26", result) |
| 268 | + self.assertEqual(result, f"contains({self.NAME_EXPR},'sales%26marketing')") |
| 269 | + |
| 270 | + |
| 271 | +class TestGetElementsDataFrameForwardsKwargs(unittest.TestCase): |
| 272 | + """Regression: in the trio-filter branch of get_elements_dataframe, the |
| 273 | + internal call to get_element_names previously dropped **kwargs, silently |
| 274 | + discarding request-level options (timeout, cancel_at_timeout, |
| 275 | + async_requests_mode) that the rest of the method forwards consistently.""" |
| 276 | + |
| 277 | + def test_trio_path_forwards_kwargs_to_get_element_names(self): |
| 278 | + service = ElementService.__new__(ElementService) |
| 279 | + service._rest = MagicMock() |
| 280 | + |
| 281 | + sentinel = RuntimeError("__stop__") |
| 282 | + recorded = {} |
| 283 | + |
| 284 | + def fake_get_element_names(*args, **kwargs): |
| 285 | + recorded["kwargs"] = kwargs |
| 286 | + raise sentinel |
| 287 | + |
| 288 | + with patch.object(service, "get_element_names", side_effect=fake_get_element_names): |
| 289 | + with self.assertRaises(RuntimeError) as ctx: |
| 290 | + service.get_elements_dataframe( |
| 291 | + dimension_name="d", |
| 292 | + hierarchy_name="h", |
| 293 | + name_pattern="foo*", |
| 294 | + timeout=42, |
| 295 | + cancel_at_timeout=True, |
| 296 | + async_requests_mode=True, |
| 297 | + ) |
| 298 | + self.assertIs(ctx.exception, sentinel) |
| 299 | + |
| 300 | + self.assertEqual(recorded["kwargs"].get("timeout"), 42) |
| 301 | + self.assertEqual(recorded["kwargs"].get("cancel_at_timeout"), True) |
| 302 | + self.assertEqual(recorded["kwargs"].get("async_requests_mode"), True) |
| 303 | + |
| 304 | + |
230 | 305 | if __name__ == "__main__": |
231 | 306 | unittest.main() |
0 commit comments