diff --git a/README.md b/README.md index 6d97041..40d9557 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Use `match_params` to partially match query parameters without having to provide If this parameter is provided, `url` parameter must not contain any query parameter. -All query parameters have to be provided (as `str`). You can however use `unittest.mock.ANY` to do partial matching. +All query parameters have to be provided as strings (`str`). However, **boolean values (True and False) are automatically converted to "true" and "false"** in the query string, so you can use them directly in `match_params`. You can also use `unittest.mock.ANY` for partial matching. ```python import httpx diff --git a/pytest_httpx/_httpx_internals.py b/pytest_httpx/_httpx_internals.py index d761f17..5d1623c 100644 --- a/pytest_httpx/_httpx_internals.py +++ b/pytest_httpx/_httpx_internals.py @@ -17,6 +17,8 @@ Sequence[tuple[bytes, bytes]], ] +PrimitiveData = Optional[Union[str, int, float, bool]] + class IteratorStream(AsyncIteratorByteStream, IteratorByteStream): def __init__(self, stream: Iterable[bytes]): @@ -58,3 +60,18 @@ def _proxy_url( real_pool := real_transport._pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy) ): return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers) + + +def _primitive_value_to_str(value: PrimitiveData) -> str: + """ + Coerce a primitive data type into a string value. + + Note that we prefer JSON-style 'true'/'false' for boolean values here. + """ + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index 504f47d..16762aa 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -6,14 +6,18 @@ import httpx from httpx import QueryParams -from pytest_httpx._httpx_internals import _proxy_url +from pytest_httpx._httpx_internals import _proxy_url, _primitive_value_to_str from pytest_httpx._options import _HTTPXMockOptions +def _normalize_bool(value: Union[str | bool]) -> str: + return _primitive_value_to_str(value) if isinstance(value, bool) else value + + def _url_match( url_to_match: Union[Pattern[str], httpx.URL], received: httpx.URL, - params: Optional[dict[str, Union[str | list[str]]]], + params: Optional[dict[str, Union[str | list[str] | bool]]], ) -> bool: if isinstance(url_to_match, re.Pattern): return url_to_match.match(str(received)) is not None @@ -22,6 +26,15 @@ def _url_match( received_params = to_params_dict(received.params) if params is None: params = to_params_dict(url_to_match.params) + else: + params = { + k: ( + [_normalize_bool(x) for x in v] + if isinstance(v, list) + else _normalize_bool(v) + ) + for k, v in params.items() + } # Remove the query parameters from the original URL to compare everything besides query parameters received_url = received.copy_with(query=None) @@ -52,7 +65,7 @@ def __init__( match_data: Optional[dict[str, Any]] = None, match_files: Optional[Any] = None, match_extensions: Optional[dict[str, Any]] = None, - match_params: Optional[dict[str, Union[str | list[str]]]] = None, + match_params: Optional[dict[str, Union[str | list[str] | bool]]] = None, is_optional: Optional[bool] = None, is_reusable: Optional[bool] = None, ): diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index b5ad27a..fe6b228 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -4,6 +4,7 @@ import re import time from collections.abc import AsyncIterable +from typing import Union, Any import httpx import pytest @@ -184,6 +185,46 @@ async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("match_params", "request_params"), + [ + ({"a": True, "b": False}, {"a": True, "b": False}), + ({"a": [True, "1", "2"]}, {"a": [True, 1, "2"]}), + ], +) +async def test_url_query_params_stringification_for_matching_with_params( + httpx_mock: HTTPXMock, + match_params: dict[str, Any], + request_params: dict[str, Any], +) -> None: + httpx_mock.add_response(url="https://test_url", match_params=match_params) + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url", params=request_params) + assert response.content == b"" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("match_params", "url"), + [ + ({"a": True, "b": False}, "https://test_url?a=true&b=false"), + ({"a": [True, "1", "2"]}, "https://test_url?a=true&a=1&a=2"), + ], +) +async def test_url_query_params_stringification_for_matching_in_url( + httpx_mock: HTTPXMock, + match_params: dict[str, Any], + url: str, +) -> None: + httpx_mock.add_response(url="https://test_url", match_params=match_params) + + async with httpx.AsyncClient() as client: + response = await client.get(url) + assert response.content == b"" + + @pytest.mark.asyncio async def test_url_matching_with_more_than_one_value_on_same_param( httpx_mock: HTTPXMock, diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 3ad9043..b5e85c9 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -1,6 +1,7 @@ import os import re from collections.abc import Iterable +from typing import Union, Any from unittest.mock import ANY import httpx @@ -199,6 +200,44 @@ def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None: ) +@pytest.mark.parametrize( + ("match_params", "request_params"), + [ + ({"a": True, "b": False}, {"a": True, "b": False}), + ({"a": [True, "1", "2"]}, {"a": [True, 1, "2"]}), + ], +) +def test_url_query_params_stringification_for_matching_with_params( + httpx_mock: HTTPXMock, + match_params: dict[str, Any], + request_params: dict[str, Any], +) -> None: + httpx_mock.add_response(url="https://test_url", match_params=match_params) + + with httpx.Client() as client: + response = client.get("https://test_url", params=request_params) + assert response.content == b"" + + +@pytest.mark.parametrize( + ("match_params", "url"), + [ + ({"a": True, "b": False}, "https://test_url?a=true&b=false"), + ({"a": [True, "1", "2"]}, "https://test_url?a=true&a=1&a=2"), + ], +) +def test_url_query_params_stringification_for_matching_in_url( + httpx_mock: HTTPXMock, + match_params: dict[str, Any], + url: str, +) -> None: + httpx_mock.add_response(url="https://test_url", match_params=match_params) + + with httpx.Client() as client: + response = client.get(url) + assert response.content == b"" + + def test_url_matching_with_more_than_one_value_on_same_param( httpx_mock: HTTPXMock, ) -> None: