Skip to content

tests/core/test_request.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """Request tests (Phase 1c/2): the HTTP request parses a real ASGI scope16 (method/path/query/headers/cookies), body dispatch through ``handler_kwargs``17 follows the content-type (form merges, hydrated body → ``body_data``, opaque18 bytes → ``body_raw``, empty body → query only), the request id comes from the19 header or is generated, TYTX mode is read off the header, and auth/session ride20 the scope.21 22 Body decoding is exercised per content-type (xml/msgpack/json hydration, an23 urlencoded form with typed values, an unknown type and a type-less body kept24 raw, a body split over several ASGI messages) and multipart forms deliver text25 fields hydrated and file parts as ``UploadedFile`` kwargs.26 27 The ``db`` preparation layer is exercised end-to-end through the server: a fake28 app touches ``request.db`` and the server drains ``closeConnection`` at end of29 request; ``get_db`` never registers a cleanup; an unregistered code answers30 ``None``.31 """32 33 from __future__ import annotations34 35 import uuid36 from datetime import date37 from decimal import Decimal38 from typing import Any39 40 from genro_tytx import to_tytx41 42 from genro_asgi import (43     Avatar,44     BaseApplication,45     BaseServer,46     Request,47     Response,48     Session,49     UploadedFile,50 )51 from genro_asgi.types import Receive, Scope, Send52 53 54 async def make_request(55     *,56     headers: list[tuple[bytes, bytes]] | None = None,57     query: bytes = b"",58     body: bytes = b"",59     chunks: list[bytes] | None = None,60     method: str = "GET",61     path: str = "/",62     scope_extra: dict[str, Any] | None = None,63     **kwargs: Any,64 ) -> Request:65     """Build a ``Request`` from a synthetic ASGI scope and init it.66 67     ``chunks`` delivers the body in several ``http.request`` messages (the last68     one alone with ``more_body`` false); ``body`` delivers it in a single one.69     """70     pending = list(chunks) if chunks is not None else [body]71 72     async def receive() -> dict[str, Any]:73         chunk = pending.pop(0)74         return {"type": "http.request", "body": chunk, "more_body": bool(pending)}75 76     scope: Scope = {77         "type": "http",78         "method": method,79         "path": path,80         "query_string": query,81         "headers": headers or [],82     }83     if scope_extra:84         scope.update(scope_extra)85     request = Request(scope, receive, **kwargs)86     await request.init()87     return request88 89 90 class TestParsing:91     async def test_parses_method_path_query_headers_cookies(self) -> None:92         request = await make_request(93             method="post",94             path="/users",95             query=b"page=2&q=hello",96             headers=[97                 (b"content-type", b"application/json"),98                 (b"x-request-id", b"req-123"),99                 (b"cookie", b"sid=abc; theme=dark"),100             ],101         )102         assert request.method == "POST"  # uppercased103         assert request.path == "/users"104         assert request.query == {"page": 2, "q": "hello"}  # typed via TYTX105         assert request.content_type == "application/json"106         assert request.cookies == {"sid": "abc", "theme": "dark"}107         assert request.id == "req-123"108 109     async def test_response_is_bound_back_to_the_request(self) -> None:110         request = await make_request()111         assert isinstance(request.response, Response)112         assert request.response.request is request113 114     async def test_numeric_id_headers_are_coerced_to_str(self) -> None:115         # Header values are TYTX-hydrated: "123" arrives as int 123;116         # the id/external_id contract is str regardless.117         request = await make_request(118             headers=[(b"x-request-id", b"123"), (b"x-external-id", b"456")]119         )120         assert request.id == "123"121         assert request.external_id == "456"122 123 124 class TestHandlerKwargs:125     async def test_json_body_passed_whole_as_body_data(self) -> None:126         request = await make_request(127             method="POST",128             query=b"page=1",129             headers=[(b"content-type", b"application/json")],130             body=b'{"name":"ada","age":36}',131         )132         assert request.data == {"name": "ada", "age": 36}133         assert request.handler_kwargs() == {"page": 1, "body_data": {"name": "ada", "age": 36}}134 135     async def test_urlencoded_body_merges_typed_and_wins_on_clash(self) -> None:136         request = await make_request(137             method="POST",138             query=b"a=9&page=2",139             headers=[(b"content-type", b"application/x-www-form-urlencoded")],140             body=b"a=1&b=hello",141         )142         # The urlencoded body is hydrated via TYTX from_qs: it arrives as a143         # typed dict, not raw bytes.144         assert request.data == {"a": 1, "b": "hello"}145         kwargs = request.handler_kwargs()146         assert kwargs == {"a": 1, "b": "hello", "page": 2}  # body 'a' wins over query 'a'147 148     async def test_opaque_body_passed_as_body_raw(self) -> None:149         request = await make_request(150             method="POST",151             query=b"x=1",152             headers=[(b"content-type", b"application/octet-stream")],153             body=b"\x00\x01\x02",154         )155         assert request.data == b"\x00\x01\x02"156         assert request.handler_kwargs() == {"x": 1, "body_raw": b"\x00\x01\x02"}157 158     async def test_empty_body_yields_query_only(self) -> None:159         request = await make_request(method="GET", query=b"x=1&y=two")160         assert request.data is None161         assert request.handler_kwargs() == {"x": 1, "y": "two"}162 163 164 class TestBodyDecoding:165     async def test_xml_body_hydrated(self) -> None:166         payload = to_tytx({"root": {"attrs": {}, "value": Decimal("100.50")}}, transport="xml")167         request = await make_request(168             method="POST",169             headers=[(b"content-type", b"application/vnd.tytx+xml")],170             body=payload.encode("utf-8"),171         )172         assert request.data == {"root": {"attrs": {}, "value": Decimal("100.50")}}173 174     async def test_msgpack_body_hydrated(self) -> None:175         payload = to_tytx({"price": Decimal("100.50")}, transport="msgpack")176         request = await make_request(177             method="POST",178             headers=[(b"content-type", b"application/vnd.tytx+msgpack")],179             body=payload,180         )181         assert request.data == {"price": Decimal("100.50")}182 183     async def test_standard_json_media_type_hydrates_like_the_tytx_one(self) -> None:184         payload = to_tytx({"price": Decimal("100.50")}, transport="json")185         request = await make_request(186             method="POST",187             headers=[(b"content-type", b"application/json")],188             body=payload.encode("utf-8"),189         )190         assert request.data == {"price": Decimal("100.50")}191 192     async def test_unknown_content_type_stays_raw(self) -> None:193         blob = b"\x89PNG\r\n\x1a\n binary image bytes"194         request = await make_request(195             method="POST", headers=[(b"content-type", b"image/png")], body=blob196         )197         assert request.data == blob198         assert request.handler_kwargs() == {"body_raw": blob}199 200     async def test_body_without_content_type_is_read_and_kept_raw(self) -> None:201         blob = b"orphan payload"202         request = await make_request(method="POST", body=blob)203         assert request.data == blob204         assert request.handler_kwargs() == {"body_raw": blob}205 206     async def test_chunked_body_is_reassembled(self) -> None:207         request = await make_request(208             method="POST",209             headers=[(b"content-type", b"application/json")],210             chunks=[b'{"name":', b'"ada","age"', b":36}"],211         )212         assert request.data == {"name": "ada", "age": 36}213 214     async def test_multi_value_query_becomes_a_list_of_hydrated_values(self) -> None:215         request = await make_request(query=b"tag=100.50%3A%3AN&tag=200.75%3A%3AN")216         assert request.query == {"tag": [Decimal("100.50"), Decimal("200.75")]}217 218     async def test_urlencoded_typed_values_are_hydrated(self) -> None:219         request = await make_request(220             method="POST",221             headers=[(b"content-type", b"application/x-www-form-urlencoded")],222             body=b"n=1::L&price=100.50::N&when=2025-01-15::D",223         )224         assert request.data == {"n": 1, "price": Decimal("100.50"), "when": date(2025, 1, 15)}225 226     async def test_empty_json_body_is_none_and_adds_no_kwargs(self) -> None:227         request = await make_request(228             method="POST",229             query=b"x=1",230             headers=[(b"content-type", b"application/json")],231             body=b"",232         )233         assert request.data is None234         assert request.handler_kwargs() == {"x": 1}235 236 237 MULTIPART_BOUNDARY = "----genroasgi"238 MULTIPART_CONTENT_TYPE = f"multipart/form-data; boundary={MULTIPART_BOUNDARY}".encode()239 240 241 def form_part(242     name: str,243     payload: bytes,244     *,245     filename: str | None = None,246     content_type: str | None = None,247 ) -> bytes:248     """Render one multipart part: content-disposition, optional type, payload."""249     disposition = f'form-data; name="{name}"'250     if filename is not None:251         disposition += f'; filename="{filename}"'252     lines = [f"Content-Disposition: {disposition}".encode()]253     if content_type is not None:254         lines.append(f"Content-Type: {content_type}".encode())255     return b"\r\n".join(lines) + b"\r\n\r\n" + payload256 257 258 async def make_multipart_request(*parts: bytes) -> Request:259     """Build and init a POST request whose body is a multipart form of ``parts``."""260     marker = f"--{MULTIPART_BOUNDARY}".encode()261     body = b"".join(marker + b"\r\n" + part + b"\r\n" for part in parts) + marker + b"--\r\n"262     return await make_request(263         method="POST", headers=[(b"content-type", MULTIPART_CONTENT_TYPE)], body=body264     )265 266 267 class TestMultipart:268     async def test_text_field_and_file_arrive_as_kwargs(self) -> None:269         request = await make_multipart_request(270             form_part("title", b"my doc"),271             form_part("doc", b"file bytes", filename="a.txt", content_type="text/plain"),272         )273         kwargs = request.handler_kwargs()274         assert kwargs["title"] == "my doc"275         uploaded = kwargs["doc"]276         assert isinstance(uploaded, UploadedFile)277         assert uploaded.name == "doc"278         assert uploaded.filename == "a.txt"279         assert uploaded.content_type == "text/plain"280         assert uploaded.data == b"file bytes"281 282     async def test_text_field_is_tytx_hydrated(self) -> None:283         request = await make_multipart_request(form_part("count", b"123"))284         assert request.handler_kwargs() == {"count": 123}285 286     async def test_two_files_under_different_names(self) -> None:287         request = await make_multipart_request(288             form_part("first", b"one", filename="one.txt", content_type="text/plain"),289             form_part("second", b"two", filename="two.txt", content_type="text/plain"),290         )291         kwargs = request.handler_kwargs()292         assert kwargs["first"].filename == "one.txt"293         assert kwargs["second"].filename == "two.txt"294         assert kwargs["first"].data == b"one"295         assert kwargs["second"].data == b"two"296 297     async def test_repeated_name_collects_a_list(self) -> None:298         request = await make_multipart_request(299             form_part("doc", b"one", filename="one.txt", content_type="text/plain"),300             form_part("doc", b"two", filename="two.txt", content_type="text/plain"),301         )302         docs = request.handler_kwargs()["doc"]303         assert [type(item) for item in docs] == [UploadedFile, UploadedFile]304         assert [item.filename for item in docs] == ["one.txt", "two.txt"]305 306     async def test_binary_payload_survives_intact(self) -> None:307         blob = b"\x89PNG\x00\xff\r\n\x1a\n tail"308         request = await make_multipart_request(309             form_part("doc", blob, filename="a.png", content_type="image/png")310         )311         assert request.handler_kwargs()["doc"].data == blob312 313     async def test_data_is_the_decoded_dict(self) -> None:314         request = await make_multipart_request(315             form_part("title", b"my doc"),316             form_part("doc", b"bytes", filename="a.bin", content_type="application/octet-stream"),317         )318         assert isinstance(request.data, dict)319         assert set(request.data) == {"title", "doc"}320         assert request.data["title"] == "my doc"321 322     async def test_each_file_keeps_its_own_content_type(self) -> None:323         request = await make_multipart_request(324             form_part("image", b"png", filename="a.png", content_type="image/png"),325             form_part("blob", b"raw", filename="a.bin", content_type="application/octet-stream"),326         )327         kwargs = request.handler_kwargs()328         assert kwargs["image"].content_type == "image/png"329         assert kwargs["blob"].content_type == "application/octet-stream"330 331 332 class TestIdentityMetadata:333     async def test_request_id_generated_when_header_absent(self) -> None:334         request = await make_request()335         assert uuid.UUID(request.id)  # a valid uuid4, not empty336 337     async def test_external_id_from_header(self) -> None:338         request = await make_request(headers=[(b"x-external-id", b"corr-1")])339         assert request.external_id == "corr-1"340 341     async def test_tytx_mode_detected_from_header(self) -> None:342         request = await make_request(headers=[(b"x-tytx-transport", b"json")])343         assert request.tytx_mode is True344         assert request.tytx_transport == "json"345 346     async def test_no_tytx_header_means_plain_mode(self) -> None:347         request = await make_request()348         assert request.tytx_mode is False349         assert request.tytx_transport is None350 351 352 class TestAuthSessionAccessors:353     async def test_avatar_and_tags_from_scope(self) -> None:354         avatar = Avatar("alice", ["admin", "staff"])355         request = await make_request(scope_extra={"auth": avatar})356         assert request.avatar() is avatar357         assert request.auth_tags == ["admin", "staff"]358 359     async def test_anonymous_when_no_avatar_on_scope(self) -> None:360         request = await make_request()361         assert request.avatar() is None362         assert request.auth_tags == []363 364     async def test_keyed_avatar_delegates_to_the_session(self) -> None:365         session = Session("tok", avatar=Avatar("alice"), ttl=3600)366         session.attach_avatar(Avatar("alice@erp"), "erp")367         request = await make_request(scope_extra={"session": session})368         assert request.avatar("erp").identity == "alice@erp"369 370     async def test_keyed_avatar_without_a_session_is_none(self) -> None:371         request = await make_request()372         assert request.avatar("erp") is None373 374     async def test_session_from_scope(self) -> None:375         session = object()376         request = await make_request(scope_extra={"session": session})377         assert request.session is session378 379     async def test_server_resolved_via_application(self) -> None:380         app = BaseApplication(mount="")381         server = BaseServer(applications=[app])382         request = await make_request(application=app)383         assert request.application is app384         assert request.server is server385 386 387 class FakeDb:388     """A stand-in db handler recording how often it is closed."""389 390     def __init__(self) -> None:391         self.closed = 0392 393     def closeConnection(self) -> None:394         self.closed += 1395 396 397 class DbApp(BaseApplication):398     """Primary app that touches ``request.db`` (twice) and records the handler."""399 400     def __init__(self, **kwargs: Any) -> None:401         self.db_name: str | None = kwargs.pop("db_name", None)402         self.seen: dict[str, Any] = kwargs.pop("seen")403         super().__init__(**kwargs)404 405     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:406         request = Request(scope, receive, application=self)407         await request.init()408         self.seen["db"] = request.db409         self.seen["db_again"] = request.db  # second access is cached410         await request.response(scope, receive, send)411 412 413 class GetDbApp(BaseApplication):414     """Primary app that resolves a db via ``get_db`` (no cleanup registration)."""415 416     def __init__(self, **kwargs: Any) -> None:417         self.seen: dict[str, Any] = kwargs.pop("seen")418         super().__init__(**kwargs)419 420     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:421         request = Request(scope, receive, application=self)422         await request.init()423         self.seen["db"] = request.get_db("default")424         await request.response(scope, receive, send)425 426 427 async def drive(server: BaseServer, path: str = "/") -> None:428     """Drive one http request through the full server dispatch."""429 430     async def receive() -> dict[str, Any]:431         return {"type": "http.request", "body": b"", "more_body": False}432 433     async def send(message: dict[str, Any]) -> None:434         pass435 436     await server(437         {"type": "http", "method": "GET", "path": path, "query_string": b"", "headers": []},438         receive,439         send,440     )441 442 443 class TestDbPreparationLayer:444     async def test_db_returns_default_handler_and_closes_at_request_end(self) -> None:445         seen: dict[str, Any] = {}446         fake = FakeDb()447         server = BaseServer(applications=[DbApp(mount="", seen=seen)])448         server.add_database("default", fake)449 450         await drive(server)451 452         assert seen["db"] is fake453         assert seen["db_again"] is fake  # cached, same object454         assert fake.closed == 1  # cleanup registered once, drained by the server455 456     async def test_db_resolves_named_handler_from_app_db_name(self) -> None:457         seen: dict[str, Any] = {}458         fake = FakeDb()459         server = BaseServer(applications=[DbApp(mount="", db_name="shop", seen=seen)])460         server.add_database("shop", fake)461 462         await drive(server)463 464         assert seen["db"] is fake465         assert fake.closed == 1466 467     async def test_db_is_none_when_code_absent(self) -> None:468         seen: dict[str, Any] = {}469         server = BaseServer(applications=[DbApp(mount="", seen=seen)])  # no database registered470 471         await drive(server)472 473         assert seen["db"] is None474 475     async def test_get_db_does_not_register_cleanup(self) -> None:476         seen: dict[str, Any] = {}477         fake = FakeDb()478         server = BaseServer(applications=[GetDbApp(mount="", seen=seen)])479         server.add_database("default", fake)480 481         await drive(server)482 483         assert seen["db"] is fake484         assert fake.closed == 0  # get_db never queues closeConnection485 486     async def test_get_db_is_none_when_code_absent(self) -> None:487         seen: dict[str, Any] = {}488         server = BaseServer(applications=[GetDbApp(mount="", seen=seen)])  # nothing registered489 490         await drive(server)491 492         assert seen["db"] is None