diff --git a/CHANGES b/CHANGES index c259139c..c8e5c8c1 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,9 @@ Unreleased This also applies to headers returned by callbacks. * Fixed ``urlencoded_params_matcher`` rejecting matching form data supplied as bytes or a binary file-like object. +* Fixed ``urlencoded_params_matcher`` keeping only the last value of a key + that is sent more than once. Repeated keys are now matched against a list, + e.g. ``{"id": ["1", "2"]}``. * Added ``responses.CallbackRequest`` so callback and matcher annotations can include the ``params`` and ``req_kwargs`` attributes added by ``responses``. diff --git a/responses/matchers.py b/responses/matchers.py index 32d068a4..75d48165 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -4,6 +4,7 @@ from json.decoder import JSONDecodeError from typing import Any from typing import Callable +from typing import Dict from typing import List from typing import Mapping from typing import MutableMapping @@ -38,6 +39,19 @@ def _filter_dict_recursively( return filtered_dict +def _parse_form_values(body: str, *, allow_blank: bool) -> Dict[str, Any]: + """Parse URL encoded data, keeping every value of a repeated key in a list.""" + values: Dict[str, Any] = {} + for key, value in parse_qsl(body, keep_blank_values=allow_blank): + if key not in values: + values[key] = value + elif isinstance(values[key], list): + values[key].append(value) + else: + values[key] = [values[key], value] + return values + + def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]: def match(request: PreparedRequest) -> Tuple[bool, str]: reason = "" @@ -54,7 +68,7 @@ def match(request: PreparedRequest) -> Tuple[bool, str]: def urlencoded_params_matcher( - params: Optional[Mapping[str, str]], + params: Optional[Mapping[str, Union[str, List[str]]]], *, allow_blank: bool = False, strict_match: bool = True, @@ -62,7 +76,8 @@ def urlencoded_params_matcher( """ Matches URL encoded data - :param params: (dict) data provided to 'data' arg of request + :param params: (dict) data provided to 'data' arg of request. Use a list + for a key that is sent more than once, e.g. ``{"id": ["1", "2"]}`` :param allow_blank If true, blank values are accounted as empty strings :param strict_match If true, all keys must match; otherwise, partial matches allowed @@ -75,7 +90,7 @@ def match(request: PreparedRequest) -> Tuple[bool, str]: if isinstance(request_body, bytes): request_body = request_body.decode("utf-8") qsl_body: Mapping[Any, Any] = ( - dict(parse_qsl(request_body, keep_blank_values=allow_blank)) + _parse_form_values(request_body, allow_blank=allow_blank) if request_body else {} ) diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 29c60475..eeab559b 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -279,6 +279,48 @@ def run(): assert_reset() +@pytest.mark.parametrize("strict_match", [False, True]) +def test_urlencoded_params_matcher_repeated_keys(strict_match: bool) -> None: + @responses.activate + def run(): + responses.add( + method=responses.POST, + url="http://example.com/", + body="all values", + match=[ + matchers.urlencoded_params_matcher( + {"id": ["1", "2"], "page": "a"}, strict_match=strict_match + ) + ], + ) + # only the last value of the repeated key must not match + responses.add( + method=responses.POST, + url="http://example.com/", + body="last value", + match=[ + matchers.urlencoded_params_matcher( + {"id": "2", "page": "a"}, strict_match=strict_match + ) + ], + ) + + resp = requests.post( + "http://example.com/", data={"id": ["1", "2"], "page": "a"} + ) + assert_response(resp, "all values") + + # values of a repeated key are kept even when they are not adjacent + resp = requests.post("http://example.com/", data="id=1&page=a&id=2") + assert_response(resp, "all values") + + resp = requests.post("http://example.com/", data={"id": "2", "page": "a"}) + assert_response(resp, "last value") + + run() + assert_reset() + + @pytest.mark.parametrize("as_file", [False, True]) @pytest.mark.parametrize("strict_match", [False, True]) @pytest.mark.parametrize(