1import { makeFunctionHttpHandler } from "@/AWS/Lambda/HttpServer";
2import type {
3 LambdaFunctionURLEvent,
4 LambdaFunctionURLResult,
5} from "aws-lambda";
6import * as Effect from "effect/Effect";
7import type { Scope } from "effect/Scope";
8import * as HttpServerRequest from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import { describe, expect, it } from "alchemy-test";
11import { TestHttpEffect } from "./HttpServer.fixture";
12
13describe(
14 "AWS.Lambda.HttpServer",
15 { tags: ["unit", "provider:aws", "provider:aws:lambda", "local"] },
16 () => {
17 it("maps a Function URL event into HttpServerRequest", async () => {
18 const result = asStructuredResult(
19 await invoke(
20 makeEvent({
21 rawPath: "/inspect",
22 rawQueryString: "jobId=job-123&trace=1",
23 headers: {
24 "x-forwarded-proto": "https",
25 "x-request-id": "req-123",
26 },
27 cookies: ["session=abc", "theme=dark"],
28 requestContext: {
29 http: {
30 method: "GET",
31 path: "/inspect",
32 sourceIp: "203.0.113.42",
33 },
34 } as LambdaFunctionURLEvent["requestContext"],
35 }),
36 ),
37 );
38
39 expect(result.statusCode).toBe(200);
40 expect(result.headers?.["content-type"]).toContain("application/json");
41 expect(JSON.parse(result.body ?? "")).toEqual({
42 method: "GET",
43 url: "https://example.lambda-url.us-east-1.on.aws/inspect?jobId=job-123&trace=1",
44 originalUrl:
45 "https://example.lambda-url.us-east-1.on.aws/inspect?jobId=job-123&trace=1",
46 host: "example.lambda-url.us-east-1.on.aws",
47 protocol: "https",
48 requestId: "req-123",
49 remoteAddress: "203.0.113.42",
50 query: {
51 jobId: "job-123",
52 trace: "1",
53 },
54 cookies: {
55 session: "abc",
56 theme: "dark",
57 },
58 });
59 });
60
61 it("maps HttpServerResponse into a Function URL result", async () => {
62 const result = asStructuredResult(
63 await invoke(
64 makeEvent({
65 rawPath: "/jobs",
66 headers: {
67 "content-type": "application/json",
68 },
69 body: JSON.stringify({
70 content: "ship it",
71 }),
72 requestContext: {
73 http: {
74 method: "POST",
75 path: "/jobs",
76 },
77 } as LambdaFunctionURLEvent["requestContext"],
78 }),
79 ),
80 );
81
82 expect(result.statusCode).toBe(201);
83 expect(result.headers).toMatchObject({
84 "content-type": "application/json",
85 "x-handler": "lambda-http",
86 });
87 expect(result.cookies).toHaveLength(1);
88 expect(result.cookies?.[0]).toContain("job-session=created");
89 expect(JSON.parse(result.body ?? "")).toEqual({
90 method: "POST",
91 url: "https://example.lambda-url.us-east-1.on.aws/jobs",
92 payload: {
93 content: "ship it",
94 },
95 });
96 expect(result.body).not.toContain("HttpServerResponse");
97 });
98
99 it("base64 encodes binary responses", async () => {
100 const result = asStructuredResult(
101 await invoke(
102 makeEvent({
103 rawPath: "/binary",
104 requestContext: {
105 http: {
106 method: "GET",
107 path: "/binary",
108 },
109 } as LambdaFunctionURLEvent["requestContext"],
110 }),
111 ),
112 );
113
114 expect(result.statusCode).toBe(200);
115 expect(result.headers?.["content-type"]).toBe("application/octet-stream");
116 expect(result.isBase64Encoded).toBe(true);
117 expect(Buffer.from(result.body ?? "", "base64").toString("utf8")).toBe(
118 "alchemy",
119 );
120 });
121
122 it("detects an API Gateway v2 HTTP API (payload 2.0) event and maps it like a Function URL event", async () => {
123 // Same wire shape as a Function URL event (APIGatewayProxyEventV2), but
124 // with an execute-api domain, a real routeKey, and a named apiId.
125 const result = asStructuredResult(
126 await invoke(
127 makeEvent({
128 version: "2.0",
129 routeKey: "GET /inspect",
130 rawPath: "/inspect",
131 rawQueryString: "jobId=job-123&trace=1",
132 headers: {
133 host: "abc123.execute-api.us-west-2.amazonaws.com",
134 "x-forwarded-proto": "https",
135 "x-request-id": "req-apigw-v2",
136 },
137 cookies: ["session=abc"],
138 requestContext: {
139 apiId: "abc123",
140 domainName: "abc123.execute-api.us-west-2.amazonaws.com",
141 domainPrefix: "abc123",
142 routeKey: "GET /inspect",
143 stage: "$default",
144 http: {
145 method: "GET",
146 path: "/inspect",
147 sourceIp: "203.0.113.99",
148 },
149 } as LambdaFunctionURLEvent["requestContext"],
150 }),
151 ),
152 );
153
154 expect(result.statusCode).toBe(200);
155 expect(JSON.parse(result.body ?? "")).toEqual({
156 method: "GET",
157 url: "https://abc123.execute-api.us-west-2.amazonaws.com/inspect?jobId=job-123&trace=1",
158 originalUrl:
159 "https://abc123.execute-api.us-west-2.amazonaws.com/inspect?jobId=job-123&trace=1",
160 host: "abc123.execute-api.us-west-2.amazonaws.com",
161 protocol: "https",
162 requestId: "req-apigw-v2",
163 remoteAddress: "203.0.113.99",
164 query: {
165 jobId: "job-123",
166 trace: "1",
167 },
168 cookies: {
169 session: "abc",
170 },
171 });
172 });
173
174 it("keeps the stage prefix in rawPath for named-stage API Gateway v2 events", async () => {
175 // On the default execute-api endpoint with a named (non-$default) stage,
176 // API Gateway includes the stage in rawPath (e.g. /prod/inspect). We use
177 // rawPath as-is — consistent with the v1 branch using event.path as-is —
178 // so handlers see the stage-prefixed path.
179 const result = asStructuredResult(
180 await invoke(
181 makeEvent({
182 version: "2.0",
183 routeKey: "GET /inspect",
184 rawPath: "/prod/inspect",
185 headers: {
186 host: "abc123.execute-api.us-west-2.amazonaws.com",
187 },
188 requestContext: {
189 routeKey: "GET /inspect",
190 stage: "prod",
191 http: {
192 method: "GET",
193 path: "/prod/inspect",
194 },
195 } as LambdaFunctionURLEvent["requestContext"],
196 }),
197 ),
198 );
199
200 // Fixture only serves /inspect; the stage-prefixed path 404s, proving the
201 // prefix is preserved rather than stripped.
202 expect(result.statusCode).toBe(404);
203 });
204
205 it("uses shared Http error handling for defects", async () => {
206 const result = asStructuredResult(
207 await invoke(
208 makeEvent({
209 rawPath: "/boom",
210 requestContext: {
211 http: {
212 method: "GET",
213 path: "/boom",
214 },
215 } as LambdaFunctionURLEvent["requestContext"],
216 }),
217 Effect.fail({ message: "Boom" } as any).pipe(Effect.orDie),
218 ),
219 );
220
221 // Defects render effect's native `causeResponse` fallback — an empty
222 // `500` with no body or content-type (the same wire shape effect's own
223 // servers produce) — instead of alchemy's former hand-rolled
224 // "Internal Server Error" text response. The cause is reported/logged
225 // server-side, never echoed to the client.
226 expect(result.statusCode).toBe(500);
227 expect(result.headers?.["content-type"]).toBeUndefined();
228 expect(result.body).toBeUndefined();
229 });
230 },
231);
232
233const invoke = async (
234 event: LambdaFunctionURLEvent,
235 handler: Effect.Effect<
236 HttpServerResponse.HttpServerResponse,
237 any,
238 HttpServerRequest.HttpServerRequest | Scope
239 > = TestHttpEffect,
240): Promise<LambdaFunctionURLResult> => {
241 const out = makeFunctionHttpHandler(handler)(event);
242 if (!Effect.isEffect(out)) {
243 throw new Error("Expected Effect from Function URL handler");
244 }
245 return Effect.runPromise(out);
246};
247
248const makeEvent = (
249 overrides: Partial<LambdaFunctionURLEvent> = {},
250): LambdaFunctionURLEvent => {
251 const event: LambdaFunctionURLEvent = {
252 version: "2.0",
253 routeKey: "$default",
254 rawPath: "/",
255 rawQueryString: "",
256 cookies: undefined,
257 headers: {
258 host: "example.lambda-url.us-east-1.on.aws",
259 "x-forwarded-proto": "https",
260 },
261 queryStringParameters: undefined,
262 requestContext: {
263 accountId: "123456789012",
264 apiId: "api-id",
265 domainName: "example.lambda-url.us-east-1.on.aws",
266 domainPrefix: "example",
267 http: {
268 method: "GET",
269 path: "/",
270 protocol: "HTTP/1.1",
271 sourceIp: "203.0.113.10",
272 userAgent: "vitest",
273 },
274 requestId: "request-id",
275 routeKey: "$default",
276 stage: "$default",
277 time: "09/Mar/2026:00:00:00 +0000",
278 timeEpoch: 1741478400000,
279 },
280 body: undefined,
281 pathParameters: undefined,
282 isBase64Encoded: false,
283 stageVariables: undefined,
284 };
285
286 return {
287 ...event,
288 ...overrides,
289 headers: {
290 ...event.headers,
291 ...overrides.headers,
292 },
293 requestContext: {
294 ...event.requestContext,
295 ...overrides.requestContext,
296 http: {
297 ...event.requestContext.http,
298 ...overrides.requestContext?.http,
299 },
300 },
301 };
302};
303
304const asStructuredResult = (
305 result: LambdaFunctionURLResult,
306): Exclude<LambdaFunctionURLResult, string> => {
307 if (typeof result === "string") {
308 throw new Error(`Expected a structured Lambda response, got: ${result}`);
309 }
310
311 return result;
312};