datasette/tests/test_internals_request.py
August Cayzer a759c47c9e Return MultiParams from request.post_vars() to preserve multi-valued form fields
Form submissions with multiple values for the same key (e.g. ``<select
multiple>``) previously had every value but the last silently discarded,
because ``post_vars()`` called ``dict(parse_qsl(...))``. Switch the
implementation to return a ``MultiParams`` object built from
``parse_qs(...)``, mirroring the existing ``request.args`` shape so both
sides of the GET/POST surface behave consistently.

``MultiParams`` gains an ``items()`` method that yields (key, first_value)
pairs, matching ``__getitem__`` semantics, so it works in patterns such as
``dict(post_vars())`` and the existing ``_coerce_execute_write_payload``
dict comprehension.

Internal callers of ``_json_or_form_payload`` (``execute_write`` and
``stored_queries``) had ``isinstance(data, dict)`` guards intended to
validate JSON shape but also rejected the new ``MultiParams`` return on
the form path. Gate those guards on ``is_json`` so the form path passes
through unchanged.

The bundled ``my_plugin`` test fixture wraps ``post_vars()`` in ``dict()``
before passing to ``Response.json``, demonstrating the migration path for
plugins that need a JSON-serialisable mapping.

Closes #2425
2026-05-29 21:43:39 +01:00

179 lines
5.4 KiB
Python

from datasette.utils.asgi import Request
import json
import pytest
def _post_request(body: bytes) -> Request:
scope = {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": [[b"content-type", b"application/x-www-form-urlencoded"]],
}
async def receive():
return {
"type": "http.request",
"body": body,
"more_body": False,
}
return Request(scope, receive)
@pytest.mark.asyncio
async def test_request_post_vars():
request = _post_request(b"foo=bar&baz=1&empty=")
post_vars = await request.post_vars()
assert post_vars["foo"] == "bar"
assert post_vars["baz"] == "1"
assert post_vars["empty"] == ""
assert post_vars.get("missing") is None
assert set(post_vars.keys()) == {"foo", "baz", "empty"}
assert dict(post_vars.items()) == {"foo": "bar", "baz": "1", "empty": ""}
@pytest.mark.asyncio
async def test_request_post_vars_multi():
# post_vars() returns a MultiParams so multiple values for the same key are
# preserved, matching the behaviour of request.args. See issue #2425.
request = _post_request(b"multi=1&multi=2&single=3")
post_vars = await request.post_vars()
assert post_vars.get("multi") == "1"
assert post_vars.get("single") == "3"
assert post_vars["multi"] == "1"
assert post_vars["single"] == "3"
assert post_vars.getlist("multi") == ["1", "2"]
assert post_vars.getlist("single") == ["3"]
assert post_vars.getlist("missing") == []
assert "multi" in post_vars
assert "missing" not in post_vars
assert list(post_vars.keys()) == ["multi", "single"]
assert len(post_vars) == 2
with pytest.raises(KeyError):
post_vars["missing"]
@pytest.mark.asyncio
async def test_request_post_body():
scope = {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": [[b"content-type", b"application/json"]],
}
data = {"hello": "world"}
async def receive():
return {
"type": "http.request",
"body": json.dumps(data, indent=4).encode("utf-8"),
"more_body": False,
}
request = Request(scope, receive)
body = await request.post_body()
assert isinstance(body, bytes)
assert data == json.loads(body)
def test_request_args():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert "1" == request.args.get("multi")
assert "3" == request.args.get("single")
assert "1" == request.args["multi"]
assert "3" == request.args["single"]
assert ["1", "2"] == request.args.getlist("multi")
assert [] == request.args.getlist("missing")
assert "multi" in request.args
assert "single" in request.args
assert "missing" not in request.args
expected = ["multi", "single"]
assert expected == list(request.args.keys())
for i, key in enumerate(request.args):
assert expected[i] == key
assert 2 == len(request.args)
with pytest.raises(KeyError):
request.args["missing"]
def test_request_fake_url_vars():
request = Request.fake("/")
assert request.url_vars == {}
request = Request.fake("/", url_vars={"database": "fixtures"})
assert request.url_vars == {"database": "fixtures"}
def test_request_repr():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert (
repr(request)
== '<asgi.Request method="GET" url="http://localhost/foo?multi=1&multi=2&single=3">'
)
def test_request_url_vars():
scope = {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": [[b"content-type", b"application/x-www-form-urlencoded"]],
}
assert {} == Request(scope, None).url_vars
assert {"name": "cleo"} == Request(
dict(scope, url_route={"kwargs": {"name": "cleo"}}), None
).url_vars
@pytest.mark.parametrize(
"path,query_string,expected_full_path",
[("/", "", "/"), ("/", "foo=bar", "/?foo=bar"), ("/foo", "bar", "/foo?bar")],
)
def test_request_properties(path, query_string, expected_full_path):
path_with_query_string = path
if query_string:
path_with_query_string += "?" + query_string
scope = {
"http_version": "1.1",
"method": "POST",
"path": path,
"raw_path": path_with_query_string.encode("latin-1"),
"query_string": query_string.encode("latin-1"),
"scheme": "http",
"type": "http",
}
request = Request(scope, None)
assert request.path == path
assert request.query_string == query_string
assert request.full_path == expected_full_path
def test_request_blank_values():
request = Request.fake("/?a=b&foo=bar&foo=bar2&baz=")
assert request.args._data == {"a": ["b"], "foo": ["bar", "bar2"], "baz": [""]}
def test_json_in_query_string_name():
query_string = (
'?_through.["roadside_attraction_characteristics"%2C"characteristic_id"]=1'
)
request = Request.fake("/" + query_string)
assert (
request.args[
'_through.["roadside_attraction_characteristics","characteristic_id"]'
]
== "1"
)