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
2 changes: 2 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased
----------

* Fixed ``json_params_matcher`` treating JSON booleans as equal to the numbers
0 and 1, including inside nested objects and arrays.
* 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
20 changes: 19 additions & 1 deletion responses/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ def _filter_dict_recursively(
return filtered_dict


def _json_values_equal(expected: Any, actual: Any) -> bool:
if isinstance(expected, bool) or isinstance(actual, bool):
return type(expected) is type(actual) and expected == actual
if isinstance(expected, dict) and isinstance(actual, dict):
return expected.keys() == actual.keys() and all(
_json_values_equal(value, actual[key]) for key, value in expected.items()
)
if isinstance(expected, list) and isinstance(actual, list):
return len(expected) == len(actual) and all(
_json_values_equal(left, right) for left, right in zip(expected, actual)
)
return expected == actual


def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]:
def match(request: PreparedRequest) -> Tuple[bool, str]:
reason = ""
Expand Down Expand Up @@ -152,7 +166,11 @@ def match(request: PreparedRequest) -> Tuple[bool, str]:
# filter down to just the params specified in the matcher
json_body = _filter_dict_recursively(json_body, json_params)

valid = params is None if request_body is None else json_params == json_body
valid = (
params is None
if request_body is None
else _json_values_equal(json_params, json_body)
)

if not valid:
reason = f"request.body doesn't match: {json_body} doesn't match {json_params}"
Expand Down
35 changes: 35 additions & 0 deletions responses/tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@
from responses.tests.test_responses import assert_response


@pytest.mark.parametrize("strict_match", [False, True])
@pytest.mark.parametrize(
"expected, actual",
[
({"enabled": True}, {"enabled": 1}),
({"enabled": 1}, {"enabled": True}),
({"enabled": False}, {"enabled": 0}),
({"enabled": 0}, {"enabled": False}),
([True], [1]),
({"nested": [{"enabled": False}]}, {"nested": [{"enabled": 0.0}]}),
],
)
def test_json_boolean_number_mismatch(expected, actual, strict_match): # type: ignore[misc]
request = requests.Request("POST", "http://example.com", json=actual).prepare()
matched, reason = matchers.json_params_matcher(expected, strict_match=strict_match)(
request
)
assert not matched
assert "request.body doesn't match" in reason


@pytest.mark.parametrize("value", [1, 1.0, True, False])
def test_json_scalar_equality(value): # type: ignore[misc]
expected = {"value": value}
request = requests.Request("POST", "http://example.com", json=expected).prepare()
assert matchers.json_params_matcher(expected)(request) == (True, "")


def test_json_params_matcher_preserves_numeric_equality():
request = requests.Request(
"POST", "http://example.com", json={"value": [1.0]}
).prepare()
assert matchers.json_params_matcher({"value": [1]})(request) == (True, "")


def test_body_match_get():
@responses.activate
def run():
Expand Down