diff --git a/CHANGES b/CHANGES index c3823d65..7546221a 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,9 @@ Unreleased ---------- +* Match query parameters independently of order in ``assert_call_count``, + including repeated values and bracket-encoded nested keys. See #822. + * Fixed ``fragment_identifier_matcher`` ignoring blank-valued fragment parameters, which allowed missing or different parameters to match. * Added Python 3.14 support (classifiers, tox envlist, and CI test matrix). See #810 diff --git a/responses/__init__.py b/responses/__init__.py index 5b1546a3..09842014 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -1281,12 +1281,21 @@ def stop(self, allow_assert: bool = True) -> None: ) def assert_call_count(self, url: str, count: int) -> bool: + target = _ensure_url_default_path(url) + target_parts = urlsplit(str(target)) + target_base = urlunsplit(target_parts._replace(query="")) + target_qsl = sorted(parse_qsl(target_parts.query, keep_blank_values=True)) + + def _url_matches_target(request_url: Optional[str]) -> bool: + if request_url is None: + return False + req_parts = urlsplit(request_url) + req_base = urlunsplit(req_parts._replace(query="")) + req_qsl = sorted(parse_qsl(req_parts.query, keep_blank_values=True)) + return req_base == target_base and req_qsl == target_qsl + call_count = len( - [ - 1 - for call in self.calls - if call.request.url == _ensure_url_default_path(url) - ] + [1 for call in self.calls if _url_matches_target(call.request.url)] ) if call_count == count: return True diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index bc8f1e04..68e99f8f 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -2094,6 +2094,52 @@ def run(): assert_reset() +def test_assert_call_count_query_param_order(): + """assert_call_count matches URLs with query params in any order.""" + + @responses.activate + def run(): + responses.add(responses.GET, "http://example.com/api", body="ok") + + # Make a request; requests preserves the insertion order of params. + requests.get("http://example.com/api", params={"b": "2", "a": "1"}) + # The stored URL is ?b=2&a=1. Asserting with reversed order must still pass. + assert responses.assert_call_count("http://example.com/api?a=1&b=2", 1) is True + # Asserting with the original order must also pass. + assert responses.assert_call_count("http://example.com/api?b=2&a=1", 1) is True + + run() + assert_reset() + + +@pytest.mark.parametrize( + "query", + [ + "filter[name]=alice&filter[roles][]=reader&filter[roles][]=writer", + "filter[roles][]=writer&filter[name]=alice&filter[roles][]=reader", + "filter%5Broles%5D%5B%5D=reader&filter%5Broles%5D%5B%5D=writer&filter%5Bname%5D=alice", + ], +) +def test_assert_call_count_nested_query_params(query): # type: ignore[misc] + with responses.RequestsMock() as mock: + url = "http://example.com/api" + mock.get(url, body="ok") + requests.get( + url, + params={"filter[name]": "alice", "filter[roles][]": ["writer", "reader"]}, + ) + + assert mock.assert_call_count(f"{url}?{query}", 1) is True + # Missing, changed, or duplicated nested values must not match. + for other in ( + "filter[name]=alice&filter[roles][]=reader", + "filter[name]=bob&filter[roles][]=writer&filter[roles][]=reader", + "filter[name]=alice&filter[roles][]=writer" + "&filter[roles][]=reader&filter[roles][]=reader", + ): + assert mock.assert_call_count(f"{url}?{other}", 0) is True + + def test_call_count_with_matcher(): @responses.activate def run():