Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 14 additions & 5 deletions responses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions responses/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to have a test case covering nested structures as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added three nested-query cases covering bracket-encoded keys, list values, reordered pairs, and percent-encoded names. The tests also ensure missing, changed, and duplicate values do not match. Included the changelog entry and corrected the optional request-URL type handling.

Validation: 255 tests passed; the six focused call-count cases passed after the type adjustment. Black, flake8, and git diff --check passed. Local mypy still reports three errors in unchanged matchers.py and test_recorder.py; no errors remain in the changed files.

# 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():
Expand Down