diff --git a/CHANGES b/CHANGES index c3823d65..dee5ff71 100644 --- a/CHANGES +++ b/CHANGES @@ -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 diff --git a/responses/matchers.py b/responses/matchers.py index 893b7f25..6d4337ba 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -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 = "" @@ -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}" diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 77fdee0a..6dec8974 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -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():