1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
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 GaTestFunctionLive, { GaTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "GABindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// Retry only 5xx (cold re-init, IAM propagation, and Global Accelerator's
31// per-accelerator transaction serialization surfacing as
32// TransactionInProgressException through the handler's `Effect.orDie`); a
33// genuine 4xx/assertion failure surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.tapError((e) =>
48 e._tag === "TransientUpstream"
49 ? Effect.logWarning(
50 `transient upstream ${e.status}: ${e.body.slice(0, 500)}`,
51 )
52 : Effect.void,
53 ),
54 Effect.retry({
55 while: (e): boolean => e._tag === "TransientUpstream",
56 schedule: Schedule.max([
57 Schedule.exponential("500 millis"),
58 Schedule.recurs(8),
59 ]),
60 }),
61 );
62
63const getJson = (path: string) =>
64 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68const postJson = (path: string) =>
69 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
70 Effect.flatMap((r) => r.json),
71 );
72
73describe.sequential(
74 "GlobalAccelerator Bindings",
75 {
76 tags: [
77 "provider:aws",
78 "provider:aws:ec2",
79 "provider:aws:globalaccelerator",
80 "provider:aws:lambda",
81 "live",
82 ],
83 },
84 () => {
85 beforeAll(
86 Effect.gen(function* () {
87 yield* Effect.logInfo("GA test setup: destroying previous resources");
88 yield* sharedStack.destroy();
89
90 yield* Effect.logInfo("GA test setup: deploying fixture");
91 const attrs = yield* sharedStack.deploy(
92 Effect.gen(function* () {
93 return yield* GaTestFunction;
94 }).pipe(Effect.provide(GaTestFunctionLive)),
95 );
96
97 expect(attrs.functionUrl).toBeTruthy();
98 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
99
100 const readinessUrl = `${baseUrl}/bindings`;
101 yield* Effect.logInfo(
102 `GA test setup: probing readiness at ${readinessUrl}`,
103 );
104 yield* HttpClient.get(readinessUrl).pipe(
105 Effect.flatMap((response) =>
106 response.status === 200
107 ? Effect.succeed(response)
108 : Effect.fail(
109 new Error(`Function not ready: ${response.status}`),
110 ),
111 ),
112 Effect.tapError((error) =>
113 Effect.logWarning(
114 `GA test setup: fixture not ready yet (${String(error)})`,
115 ),
116 ),
117 Effect.retry({ schedule: readinessPolicy }),
118 );
119 }),
120 { timeout: 240_000 },
121 );
122
123 afterAll(sharedStack.destroy(), { timeout: 300_000 });
124
125 describe("binding registration", () => {
126 test.provider(
127 "all four capabilities initialize in the runtime",
128 (_stack) =>
129 Effect.gen(function* () {
130 const response = (yield* getJson("/bindings")) as {
131 bound: string[];
132 };
133 expect(response.bound).toHaveLength(4);
134 }),
135 );
136 });
137
138 describe("DescribeAccelerator", () => {
139 test.provider(
140 "reads the bound accelerator's state (injected ARN)",
141 (_stack) =>
142 Effect.gen(function* () {
143 const response = (yield* getJson("/accelerator")) as {
144 name: string;
145 status: string;
146 dnsName: string;
147 enabled: boolean;
148 };
149 expect(response.dnsName).toContain(".awsglobalaccelerator.com");
150 expect(response.enabled).toBe(true);
151 expect(["DEPLOYED", "IN_PROGRESS"]).toContain(response.status);
152 }),
153 );
154 });
155
156 describe("DescribeEndpointGroup", () => {
157 test.provider(
158 "reads the bound endpoint group's state (injected ARN)",
159 (_stack) =>
160 Effect.gen(function* () {
161 const response = (yield* getJson("/group")) as {
162 region: string;
163 endpoints: { endpointId: string; healthState: string }[];
164 };
165 expect(response.region).toEqual("us-west-2");
166 }),
167 );
168 });
169
170 describe("AddEndpoints / RemoveEndpoints", () => {
171 test.provider(
172 "registers and deregisters an Elastic IP endpoint at runtime",
173 (_stack) =>
174 Effect.gen(function* () {
175 // Global Accelerator serializes changes per accelerator: mutating
176 // while the create/update transaction from the deploy is still
177 // IN_PROGRESS fails with TransactionInProgressException. Wait for
178 // DEPLOYED (bounded) before driving Add/RemoveEndpoints.
179 const accelerator = yield* getJson("/accelerator").pipe(
180 Effect.map((a) => a as { status: string }),
181 Effect.repeat({
182 schedule: Schedule.spaced("5 seconds"),
183 until: (a): boolean => a.status === "DEPLOYED",
184 times: 36,
185 }),
186 );
187 expect(accelerator.status).toEqual("DEPLOYED");
188
189 const { allocationId } = (yield* getJson("/context")) as {
190 allocationId: string;
191 };
192 expect(allocationId).toMatch(/^eipalloc-/);
193
194 // Register — the response echoes the endpoint back.
195 const added = (yield* postJson("/endpoints/add")) as {
196 added: string[];
197 };
198 expect(added.added, JSON.stringify(added)).toContain(allocationId);
199
200 // The group's observed state now includes the Elastic IP.
201 const group = yield* getJson("/group").pipe(
202 Effect.map((g) => g as { endpoints: { endpointId: string }[] }),
203 Effect.repeat({
204 schedule: Schedule.spaced("3 seconds"),
205 until: (g): boolean =>
206 g.endpoints.some(
207 (endpoint) => endpoint.endpointId === allocationId,
208 ),
209 times: 10,
210 }),
211 );
212 expect(
213 group.endpoints.map((endpoint) => endpoint.endpointId),
214 ).toContain(allocationId);
215
216 // Deregister and observe it gone.
217 const removed = (yield* postJson("/endpoints/remove")) as {
218 removed: string;
219 };
220 expect(removed.removed, JSON.stringify(removed)).toEqual(
221 allocationId,
222 );
223
224 const drained = yield* getJson("/group").pipe(
225 Effect.map((g) => g as { endpoints: { endpointId: string }[] }),
226 Effect.repeat({
227 schedule: Schedule.spaced("3 seconds"),
228 until: (g): boolean =>
229 g.endpoints.every(
230 (endpoint) => endpoint.endpointId !== allocationId,
231 ),
232 times: 10,
233 }),
234 );
235 expect(
236 drained.endpoints.map((endpoint) => endpoint.endpointId),
237 ).not.toContain(allocationId);
238 }),
239 // Covers the (bounded) wait for the accelerator's deploy transaction
240 // to reach DEPLOYED plus the add/observe/remove/observe cycle.
241 { timeout: 300_000 },
242 );
243 });
244 },
245);