from __future__ import annotations import http.client import json import threading import unittest from contextlib import contextmanager from http.server import ThreadingHTTPServer from typing import Any, Iterator, Mapping from arr_web.contracts import MAX_UPLOAD_BYTES, Response, success from arr_web.server import handler_for class RecordingApplication: def __init__(self) -> None: self.calls: list[dict[str, Any]] = [] def handle( self, method: str, path: str, headers: Mapping[str, str], body: bytes, *, client_id: str, ) -> Response: self.calls.append( { "method": method, "path": path, "headers": dict(headers), "body": body, "client_id": client_id, } ) return Response.json(200, success({"method": method})) @contextmanager def running_server( application: RecordingApplication, ) -> Iterator[tuple[str, int]]: server = ThreadingHTTPServer(("127.0.0.1", 0), handler_for(application)) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: host, port = server.server_address yield str(host), int(port) finally: server.shutdown() server.server_close() thread.join(timeout=5) class PortalHttpAdapterTests(unittest.TestCase): def test_patch_and_delete_forward_json_bodies_to_application(self) -> None: application = RecordingApplication() with running_server(application) as (host, port): connection = http.client.HTTPConnection(host, port, timeout=5) try: patch_body = json.dumps( {"draft_id": "bookingdraft-test", "room_type": "U-TWN", "quantity": 2} ).encode("utf-8") connection.request( "PATCH", "/api/company-reports/source/draft/items/7", body=patch_body, headers={"Content-Type": "application/json"}, ) patch_response = connection.getresponse() self.assertEqual(patch_response.status, 200) self.assertEqual(patch_response.getheader("X-Content-Type-Options"), "nosniff") patch_response.read() delete_body = b'{"draft_id":"bookingdraft-test"}' connection.request( "DELETE", "/api/company-reports/source/draft/items/7", body=delete_body, headers={"Content-Type": "application/json"}, ) delete_response = connection.getresponse() self.assertEqual(delete_response.status, 200) delete_response.read() batch_delete_body = b'{"draft_id":"bookingdraft-test","item_ids":[7,8]}' connection.request( "DELETE", "/api/company-reports/source/draft/items", body=batch_delete_body, headers={"Content-Type": "application/json"}, ) batch_delete_response = connection.getresponse() self.assertEqual(batch_delete_response.status, 200) batch_delete_response.read() finally: connection.close() self.assertEqual( [(call["method"], call["body"]) for call in application.calls], [ ("PATCH", patch_body), ("DELETE", delete_body), ("DELETE", batch_delete_body), ], ) self.assertEqual( [call["path"] for call in application.calls], [ "/api/company-reports/source/draft/items/7", "/api/company-reports/source/draft/items/7", "/api/company-reports/source/draft/items", ], ) def test_mutation_methods_reject_missing_or_oversized_content_length(self) -> None: application = RecordingApplication() with running_server(application) as (host, port): missing = http.client.HTTPConnection(host, port, timeout=5) try: missing.putrequest("PATCH", "/api/company-reports/source/draft/items/7") missing.endheaders() missing_response = missing.getresponse() self.assertEqual(missing_response.status, 411) self.assertEqual( json.loads(missing_response.read())["error"]["code"], "CONTENT_LENGTH_REQUIRED", ) finally: missing.close() oversized = http.client.HTTPConnection(host, port, timeout=5) try: oversized.putrequest("DELETE", "/api/company-reports/source/draft") oversized.putheader("Content-Length", str(MAX_UPLOAD_BYTES + 1)) oversized.endheaders() oversized_response = oversized.getresponse() self.assertEqual(oversized_response.status, 413) self.assertEqual( json.loads(oversized_response.read())["error"]["code"], "REQUEST_TOO_LARGE", ) finally: oversized.close() self.assertEqual(application.calls, []) if __name__ == "__main__": unittest.main()