1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
4import { expect } from "alchemy-test";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import HttpApiTestFunctionLive, { HttpApiTestFunction } from "./http-handler";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14// Route + permission propagation after the first deploy takes a few
15// seconds; cap the exponential backoff so we poll steadily.
16const edgePropagationRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
17 effect.pipe(
18 Effect.retry({
19 schedule: Schedule.max([
20 Schedule.exponential(500).pipe(
21 Schedule.modifyDelay(({ duration }) =>
22 Effect.succeed(
23 Duration.isGreaterThan(duration, Duration.seconds(5))
24 ? Duration.seconds(5)
25 : duration,
26 ),
27 ),
28 ),
29 Schedule.recurs(20),
30 ]),
31 }),
32 );
33
34test.provider(
35 "HTTP API proxies to Lambda (payload 2.0 end-to-end)",
36 (stack) =>
37 Effect.gen(function* () {
38 yield* stack.destroy();
39
40 const out = yield* stack.deploy(
41 Effect.gen(function* () {
42 const fn = yield* HttpApiTestFunction.pipe(
43 Effect.provide(HttpApiTestFunctionLive),
44 );
45 const { api, stage, url } = yield* AWS.ApiGatewayV2.HttpApi(
46 "TestHttpApi",
47 { handler: fn, timeout: "29 seconds" },
48 );
49 return {
50 url,
51 apiId: api.apiId,
52 stageName: stage.stageName,
53 };
54 }),
55 );
56
57 expect(out.url).toContain(out.apiId);
58 expect(out.stageName).toBe("$default");
59 const baseUrl = out.url.replace(/\/+$/, "");
60
61 // The `timeout: "29 seconds"` Duration.Input lands on the wire as
62 // whole milliseconds (out-of-band verification via distilled).
63 const integrations = yield* agw2.getIntegrations({ ApiId: out.apiId });
64 expect(integrations.Items?.[0]?.TimeoutInMillis).toBe(29_000);
65
66 // 1. Routing + query strings (retries through edge propagation).
67 const echo = yield* edgePropagationRetry(
68 HttpClient.get(`${baseUrl}/echo?foo=bar&baz=qux`).pipe(
69 Effect.flatMap((response) =>
70 response.status === 200
71 ? response.json
72 : Effect.fail(new Error(`echo returned ${response.status}`)),
73 ),
74 ),
75 );
76 expect(echo).toEqual({
77 method: "GET",
78 path: "/echo",
79 query: { foo: "bar", baz: "qux" },
80 });
81
82 // 2. Path parameters. Route propagation is independent, so the echo
83 // route becoming live does not guarantee this route is visible yet.
84 const item = yield* edgePropagationRetry(
85 HttpClient.get(`${baseUrl}/items/widget-42`).pipe(
86 Effect.flatMap((response) =>
87 response.status === 200
88 ? response.json
89 : Effect.fail(
90 new Error(`GET /items/widget-42 returned ${response.status}`),
91 ),
92 ),
93 ),
94 );
95 expect(item).toEqual({ id: "widget-42" });
96
97 // 3. POST body round-trip. This handler is side-effect-free, so retrying
98 // the request while AWS propagates the POST route is safe.
99 const created = yield* edgePropagationRetry(
100 HttpClient.execute(
101 HttpClientRequest.post(`${baseUrl}/items`).pipe(
102 HttpClientRequest.bodyJsonUnsafe({ name: "widget", count: 2 }),
103 ),
104 ).pipe(
105 Effect.flatMap((response) =>
106 response.status === 201
107 ? response.json
108 : Effect.fail(
109 new Error(`POST /items returned ${response.status}`),
110 ),
111 ),
112 ),
113 );
114 expect(created).toEqual({
115 received: { name: "widget", count: 2 },
116 });
117
118 // 4. Unknown routes fall through to the handler's 404.
119 const missing = yield* HttpClient.get(`${baseUrl}/nope`);
120 expect(missing.status).toBe(404);
121
122 yield* stack.destroy();
123 }),
124 {
125 tags: [
126 "provider:aws",
127 "provider:aws:apigatewayv2",
128 "provider:aws:lambda",
129 "live",
130 ],
131 timeout: 600_000,
132 },
133);