Skip to content

Commit fa38ac6

Browse files
committed
Preserve empty query values in mapping matchers
1 parent eadf7bb commit fa38ac6

5 files changed

Lines changed: 74 additions & 2 deletions

File tree

‎doc/howto.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ query string as a dictionary.
4848

4949
Behind the scenes an additional step is done by the library: it parses up the
5050
query_string into the dict and then compares it with the dict provided.
51+
Empty values are preserved. For example, ``query_string={"flag": ""}`` matches
52+
both ``?flag=`` and ``?flag``. Neither matches an empty dictionary.
53+
If a parameter appears more than once, a dictionary matches its first value,
54+
including an empty value. A ``werkzeug.datastructures.MultiDict`` matches all
55+
values, including empty ones.
5156

5257

5358
URI matching

‎pytest_httpserver/httpserver.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,13 @@ def __init__(self, query_dict: Mapping[str, str] | MultiDict[str, str]) -> None:
234234
key-value mapping where both key and value should be string. If there are multiple
235235
values specified for the same key in the request, the first element will be used.
236236
If you want to match multiple values, use a MultiDict object from werkzeug, which
237-
represents multiple values for one key.
237+
represents multiple values for one key. Empty values are kept, including
238+
parameters without an equals sign. An empty value is distinct from a missing key.
238239
"""
239240
self.query_dict = query_dict
240241

241242
def get_comparing_values(self, request_query_string: bytes) -> tuple[Mapping[str, str], Mapping[str, str]]:
242-
query = MultiDict(urllib.parse.parse_qsl(request_query_string.decode("utf-8")))
243+
query = MultiDict(urllib.parse.parse_qsl(request_query_string.decode("utf-8"), keep_blank_values=True))
243244
if isinstance(self.query_dict, MultiDict):
244245
return (query, self.query_dict)
245246
else:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
fixes:
2+
- |
3+
Preserve empty query values when matching dictionaries and MultiDict objects.
4+
Both ``flag=`` and ``flag`` match ``{"flag": ""}`` and no longer match ``{}``.
5+
For repeated parameters, dictionary matching uses the first value even when it
6+
is empty. MultiDict matching includes every empty value.

‎tests/test_querymatcher.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from werkzeug.datastructures import MultiDict
23

34
from pytest_httpserver.httpserver import BooleanQueryMatcher
@@ -55,3 +56,40 @@ def test_qm_mapping_multiple_values():
5556
md = MultiDict([("k1", "v1"), ("k1", "v2")])
5657
qm = MappingQueryMatcher(md)
5758
assert_match(qm, b"k1=v1&k1=v2")
59+
60+
61+
@pytest.mark.parametrize("query_string", [b"flag=", b"flag", b"flag=&", b"&flag="])
62+
def test_qm_mapping_empty_value(query_string):
63+
qm = MappingQueryMatcher({"flag": ""})
64+
assert_match(qm, query_string)
65+
assert_not_match(qm, b"")
66+
assert_not_match(qm, b"flag=value")
67+
68+
69+
@pytest.mark.parametrize("query_string", [b"flag=", b"flag", b"flag=&flag="])
70+
def test_qm_mapping_empty_value_is_not_missing(query_string):
71+
qm = MappingQueryMatcher({})
72+
assert_not_match(qm, query_string)
73+
assert_match(qm, b"")
74+
75+
76+
@pytest.mark.parametrize("query_string", [b"flag=&flag=value", b"flag&flag=value"])
77+
def test_qm_mapping_first_value_is_empty(query_string):
78+
assert_match(MappingQueryMatcher({"flag": ""}), query_string)
79+
assert_not_match(MappingQueryMatcher({"flag": "value"}), query_string)
80+
assert_match(MappingQueryMatcher({"flag": "value"}), b"flag=value&flag=")
81+
82+
83+
@pytest.mark.parametrize("query_string", [b"flag=&flag=value", b"flag&flag=value"])
84+
def test_qm_mapping_multiple_values_include_empty(query_string):
85+
qm = MappingQueryMatcher(MultiDict([("flag", ""), ("flag", "value")]))
86+
assert_match(qm, query_string)
87+
assert_not_match(qm, b"flag=value")
88+
assert_not_match(qm, b"flag=")
89+
90+
91+
def test_qm_mapping_empty_encoded_name():
92+
qm = MappingQueryMatcher({"empty name": "", "": "value"})
93+
assert_match(qm, b"empty%20name=&=value")
94+
assert_match(qm, b"=value&empty+name=")
95+
assert_not_match(qm, b"=value")

‎tests/test_querystring.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import pytest
12
import requests
3+
from werkzeug.datastructures import MultiDict
24

35
from pytest_httpserver import HTTPServer
46

@@ -32,3 +34,23 @@ def test_querystring_dict(httpserver: HTTPServer):
3234
httpserver.check_assertions()
3335
assert response.text == "example_response"
3436
assert response.status_code == 200
37+
38+
39+
@pytest.mark.parametrize("query_string", [{"flag": ""}, MultiDict([("flag", ""), ("flag", "value")])])
40+
def test_querystring_empty_value(httpserver: HTTPServer, query_string):
41+
httpserver.expect_request("/foobar", query_string=query_string).respond_with_data("example_response")
42+
response = requests.get(
43+
httpserver.url_for("/foobar"),
44+
params=query_string.items(multi=True) if isinstance(query_string, MultiDict) else query_string,
45+
)
46+
httpserver.check_assertions()
47+
assert response.status_code == 200
48+
assert response.text == "example_response"
49+
50+
51+
def test_querystring_empty_value_does_not_match_missing(httpserver: HTTPServer):
52+
httpserver.expect_request("/foobar", query_string={}).respond_with_data("example_response")
53+
response = requests.get(httpserver.url_for("/foobar"), params={"flag": ""})
54+
assert response.status_code == 500
55+
with pytest.raises(AssertionError, match="No handler found"):
56+
httpserver.check_assertions()

0 commit comments

Comments
 (0)