@@ -915,3 +915,303 @@ def get_image():
915915 # THEN it decodes base64 and returns binary data
916916 assert captured ["status_code" ] == 200
917917 assert captured ["body" ] == binary_data
918+
919+
920+ @pytest .mark .asyncio
921+ async def test_asgi_duplicate_headers ():
922+ # GIVEN an ASGI request with duplicate headers
923+ app = HttpResolverAlpha ()
924+
925+ @app .get ("/headers" )
926+ def get_headers ():
927+ # Return the accept header which has duplicates
928+ accept = app .current_event .headers .get ("accept" , "" )
929+ return {"accept" : accept }
930+
931+ scope = {
932+ "type" : "http" ,
933+ "method" : "GET" ,
934+ "path" : "/headers" ,
935+ "query_string" : b"" ,
936+ "headers" : [
937+ (b"accept" , b"text/html" ),
938+ (b"accept" , b"application/json" ), # Duplicate header
939+ ],
940+ }
941+
942+ receive = make_asgi_receive ()
943+ send , captured = make_asgi_send ()
944+
945+ # WHEN called via ASGI interface
946+ await app (scope , receive , send )
947+
948+ # THEN duplicate headers are joined with comma
949+ assert captured ["status_code" ] == 200
950+ body = json .loads (captured ["body" ])
951+ assert body ["accept" ] == "text/html, application/json"
952+
953+
954+ @pytest .mark .asyncio
955+ async def test_asgi_with_cookies ():
956+ # GIVEN an app that sets cookies
957+ from aws_lambda_powertools .shared .cookies import Cookie
958+
959+ app = HttpResolverAlpha ()
960+
961+ @app .get ("/set-cookie" )
962+ def set_cookie ():
963+ cookie = Cookie (name = "session" , value = "abc123" )
964+ return Response (
965+ status_code = 200 ,
966+ content_type = "application/json" ,
967+ body = {"message" : "Cookie set" },
968+ cookies = [cookie ],
969+ )
970+
971+ scope = {
972+ "type" : "http" ,
973+ "method" : "GET" ,
974+ "path" : "/set-cookie" ,
975+ "query_string" : b"" ,
976+ "headers" : [],
977+ }
978+
979+ receive = make_asgi_receive ()
980+ captured_headers : list [tuple [bytes , bytes ]] = []
981+
982+ async def send (message : dict [str , Any ]) -> None :
983+ await asyncio .sleep (0 )
984+ if message ["type" ] == "http.response.start" :
985+ captured_headers .extend (message .get ("headers" , []))
986+
987+ # WHEN called via ASGI interface
988+ await app (scope , receive , send )
989+
990+ # THEN Set-Cookie header is present
991+ cookie_headers = [h for h in captured_headers if h [0 ] == b"set-cookie" ]
992+ assert len (cookie_headers ) == 1
993+ assert b"session=abc123" in cookie_headers [0 ][1 ]
994+
995+
996+ @pytest .mark .asyncio
997+ async def test_async_middleware ():
998+ # GIVEN an app with async middleware
999+ app = HttpResolverAlpha ()
1000+ order : list [str ] = []
1001+
1002+ async def async_middleware (app , next_middleware ):
1003+ order .append ("async_before" )
1004+ await asyncio .sleep (0.001 )
1005+ response = await next_middleware (app )
1006+ order .append ("async_after" )
1007+ return response
1008+
1009+ app .use ([async_middleware ])
1010+
1011+ @app .get ("/test" )
1012+ async def test_route ():
1013+ order .append ("handler" )
1014+ return {"ok" : True }
1015+
1016+ scope = {
1017+ "type" : "http" ,
1018+ "method" : "GET" ,
1019+ "path" : "/test" ,
1020+ "query_string" : b"" ,
1021+ "headers" : [],
1022+ }
1023+
1024+ receive = make_asgi_receive ()
1025+ send , captured = make_asgi_send ()
1026+
1027+ # WHEN called via ASGI interface
1028+ await app (scope , receive , send )
1029+
1030+ # THEN async middleware executes correctly
1031+ assert captured ["status_code" ] == 200
1032+ assert order == ["async_before" , "handler" , "async_after" ]
1033+
1034+
1035+ def test_unhandled_exception_raises ():
1036+ # GIVEN an app without exception handler for ValueError
1037+ app = HttpResolverAlpha ()
1038+
1039+ @app .get ("/error" )
1040+ def raise_error ():
1041+ raise ValueError ("Unhandled error" )
1042+
1043+ event = {
1044+ "httpMethod" : "GET" ,
1045+ "path" : "/error" ,
1046+ "headers" : {},
1047+ "queryStringParameters" : {},
1048+ "body" : None ,
1049+ }
1050+
1051+ # WHEN the route raises an unhandled exception
1052+ # THEN it propagates up
1053+ with pytest .raises (ValueError , match = "Unhandled error" ):
1054+ app .resolve (event , MockLambdaContext ())
1055+
1056+
1057+ def test_default_not_found_without_custom_handler ():
1058+ # GIVEN an app WITHOUT custom not_found handler
1059+ app = HttpResolverAlpha ()
1060+
1061+ @app .get ("/exists" )
1062+ def exists ():
1063+ return {"exists" : True }
1064+
1065+ event = {
1066+ "httpMethod" : "GET" ,
1067+ "path" : "/unknown" ,
1068+ "headers" : {},
1069+ "queryStringParameters" : {},
1070+ "body" : None ,
1071+ }
1072+
1073+ # WHEN requesting unknown route
1074+ result = app .resolve (event , MockLambdaContext ())
1075+
1076+ # THEN default 404 response is returned
1077+ assert result ["statusCode" ] == 404
1078+ body = json .loads (result ["body" ])
1079+ assert body ["message" ] == "Not found"
1080+
1081+
1082+ def test_method_not_matching_continues_search ():
1083+ # GIVEN an app with routes for different methods on same path
1084+ app = HttpResolverAlpha ()
1085+
1086+ @app .get ("/resource" )
1087+ def get_resource ():
1088+ return {"method" : "GET" }
1089+
1090+ @app .post ("/resource" )
1091+ def post_resource ():
1092+ return {"method" : "POST" }
1093+
1094+ # WHEN requesting with POST
1095+ event = {
1096+ "httpMethod" : "POST" ,
1097+ "path" : "/resource" ,
1098+ "headers" : {},
1099+ "queryStringParameters" : {},
1100+ "body" : None ,
1101+ }
1102+ result = app .resolve (event , MockLambdaContext ())
1103+
1104+ # THEN it finds the POST handler (skipping GET)
1105+ assert result ["statusCode" ] == 200
1106+ body = json .loads (result ["body" ])
1107+ assert body ["method" ] == "POST"
1108+
1109+
1110+ def test_list_headers_serialization ():
1111+ # GIVEN an app that returns list headers
1112+ app = HttpResolverAlpha ()
1113+
1114+ @app .get ("/multi-header" )
1115+ def multi_header ():
1116+ return Response (
1117+ status_code = 200 ,
1118+ content_type = "application/json" ,
1119+ body = {"ok" : True },
1120+ headers = {"X-Custom" : ["value1" , "value2" ]},
1121+ )
1122+
1123+ event = {
1124+ "httpMethod" : "GET" ,
1125+ "path" : "/multi-header" ,
1126+ "headers" : {},
1127+ "queryStringParameters" : {},
1128+ "body" : None ,
1129+ }
1130+
1131+ # WHEN the route is resolved
1132+ result = app .resolve (event , MockLambdaContext ())
1133+
1134+ # THEN list headers are joined with comma
1135+ assert result ["statusCode" ] == 200
1136+ assert result ["headers" ]["X-Custom" ] == "value1, value2"
1137+
1138+
1139+ def test_string_body_in_event ():
1140+ # GIVEN an event with string body (not bytes)
1141+ app = HttpResolverAlpha ()
1142+
1143+ @app .post ("/echo" )
1144+ def echo ():
1145+ return {"body" : app .current_event .body }
1146+
1147+ # Body is already a string, not bytes
1148+ event = {
1149+ "httpMethod" : "POST" ,
1150+ "path" : "/echo" ,
1151+ "headers" : {"content-type" : "text/plain" },
1152+ "queryStringParameters" : {},
1153+ "body" : "plain text body" ,
1154+ }
1155+
1156+ # WHEN the route is resolved
1157+ result = app .resolve (event , MockLambdaContext ())
1158+
1159+ # THEN string body is handled correctly
1160+ assert result ["statusCode" ] == 200
1161+ body = json .loads (result ["body" ])
1162+ assert body ["body" ] == "plain text body"
1163+
1164+
1165+ @pytest .mark .asyncio
1166+ async def test_asgi_default_not_found ():
1167+ # GIVEN an app WITHOUT custom not_found handler
1168+ app = HttpResolverAlpha ()
1169+
1170+ @app .get ("/exists" )
1171+ def exists ():
1172+ return {"exists" : True }
1173+
1174+ scope = {
1175+ "type" : "http" ,
1176+ "method" : "GET" ,
1177+ "path" : "/unknown-route" ,
1178+ "query_string" : b"" ,
1179+ "headers" : [],
1180+ }
1181+
1182+ receive = make_asgi_receive ()
1183+ send , captured = make_asgi_send ()
1184+
1185+ # WHEN requesting unknown route via ASGI
1186+ await app (scope , receive , send )
1187+
1188+ # THEN default 404 is returned
1189+ assert captured ["status_code" ] == 404
1190+ body = json .loads (captured ["body" ])
1191+ assert body ["message" ] == "Not found"
1192+
1193+
1194+ @pytest .mark .asyncio
1195+ async def test_asgi_unhandled_exception_raises ():
1196+ # GIVEN an app without exception handler for ValueError
1197+ app = HttpResolverAlpha ()
1198+
1199+ @app .get ("/error" )
1200+ async def raise_error ():
1201+ raise ValueError ("Async unhandled error" )
1202+
1203+ scope = {
1204+ "type" : "http" ,
1205+ "method" : "GET" ,
1206+ "path" : "/error" ,
1207+ "query_string" : b"" ,
1208+ "headers" : [],
1209+ }
1210+
1211+ receive = make_asgi_receive ()
1212+ send , _ = make_asgi_send ()
1213+
1214+ # WHEN the route raises an unhandled exception
1215+ # THEN it propagates up
1216+ with pytest .raises (ValueError , match = "Async unhandled error" ):
1217+ await app (scope , receive , send )
0 commit comments