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 AppConfigTestFunctionLive, {
11 AppConfigTestFunction,
12} from "./fixtures/handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "AppConfigBindings");
17
18let baseUrl: string;
19
20class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
21 readonly status: number;
22 readonly body: string;
23}> {}
24
25// Fresh Lambda role + AppConfig permissions propagate eventually — the first
26// fetches can 500 with AccessDenied under the handler's `Effect.orDie`. Retry
27// 5xx only; a genuine 4xx fails immediately.
28const send = (request: HttpClientRequest.HttpClientRequest) =>
29 HttpClient.execute(request).pipe(
30 Effect.flatMap((response) =>
31 response.status >= 500
32 ? response.text.pipe(
33 Effect.flatMap((body) =>
34 Effect.fail(
35 new TransientUpstream({ status: response.status, body }),
36 ),
37 ),
38 )
39 : Effect.succeed(response),
40 ),
41 Effect.retry({
42 while: (e) => e._tag === "TransientUpstream",
43 schedule: Schedule.max([
44 Schedule.exponential("1 second"),
45 Schedule.recurs(8),
46 ]),
47 }),
48 );
49
50// Sequential: the tests share one Lambda + one AppConfig environment, and
51// AppConfig allows only one in-flight deployment per environment. Concurrent
52// execution races the v2 rollout against the v1 `/config` assertion.
53describe.sequential(
54 "AppConfig Bindings",
55 {
56 tags: [
57 "provider:aws",
58 "provider:aws:appconfig",
59 "provider:aws:lambda",
60 "live",
61 ],
62 },
63 () => {
64 beforeAll(
65 Effect.gen(function* () {
66 yield* Effect.logInfo(
67 "AppConfig test setup: destroying previous resources",
68 );
69 yield* sharedStack.destroy();
70
71 yield* Effect.logInfo(
72 "AppConfig test setup: deploying app -> env -> profile -> version -> deployment -> Lambda",
73 );
74 const { functionUrl } = yield* sharedStack.deploy(
75 Effect.gen(function* () {
76 return yield* AppConfigTestFunction;
77 }).pipe(Effect.provide(AppConfigTestFunctionLive)),
78 );
79
80 expect(functionUrl).toBeTruthy();
81 baseUrl = functionUrl!.replace(/\/+$/, "");
82
83 // Readiness probe — fresh function URLs take seconds to serve 200s.
84 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
85 Effect.flatMap((response) =>
86 response.status === 200
87 ? Effect.succeed(response)
88 : Effect.fail(
89 new Error(`Function not ready: ${response.status}`),
90 ),
91 ),
92 Effect.retry({
93 schedule: Schedule.max([
94 Schedule.fixed("2 seconds"),
95 Schedule.recurs(75),
96 ]),
97 }),
98 );
99
100 // Authorization gate — control-plane grants on a freshly created role
101 // can take up to a minute to become effective. Wait once here so every
102 // test runs against a fully authorized Lambda.
103 yield* HttpClient.execute(
104 HttpClientRequest.post(`${baseUrl}/validate`).pipe(
105 HttpClientRequest.bodyJsonUnsafe({ version: "1" }),
106 ),
107 ).pipe(
108 Effect.flatMap((response) =>
109 response.status === 200
110 ? Effect.succeed(response)
111 : Effect.fail(
112 new Error(
113 `management bindings not yet authorized: ${response.status}`,
114 ),
115 ),
116 ),
117 Effect.retry({
118 schedule: Schedule.max([
119 Schedule.spaced("5 seconds"),
120 Schedule.recurs(24),
121 ]),
122 }),
123 );
124 }),
125 { timeout: 360_000 },
126 );
127 afterAll(sharedStack.destroy(), { timeout: 300_000 });
128
129 describe("GetConfiguration", () => {
130 test.provider(
131 "the Lambda fetches its deployed configuration through the binding",
132 () =>
133 Effect.gen(function* () {
134 // The data plane may briefly serve an empty body right after the
135 // deployment completes; retry until the content is present.
136 const body = yield* send(
137 HttpClientRequest.get(`${baseUrl}/config`),
138 ).pipe(
139 Effect.flatMap((response) => response.json),
140 Effect.map(
141 (json) => json as { content?: string; contentType?: string },
142 ),
143 Effect.filterOrFail(
144 (json) =>
145 typeof json.content === "string" && json.content.length > 0,
146 () => new Error("configuration not yet available"),
147 ),
148 Effect.retry({
149 schedule: Schedule.max([
150 Schedule.spaced("3 seconds"),
151 Schedule.recurs(20),
152 ]),
153 }),
154 );
155
156 expect(body.contentType).toContain("application/json");
157 expect(JSON.parse(body.content!)).toEqual({
158 featureX: true,
159 limit: 42,
160 });
161 }),
162 { timeout: 180_000 },
163 );
164 });
165
166 describe("CreateHostedConfigurationVersion + StartDeployment + GetDeployment", () => {
167 test.provider(
168 "the Lambda writes a new version, rolls it out, and serves it",
169 () =>
170 Effect.gen(function* () {
171 const newContent = JSON.stringify({ featureX: false, limit: 7 });
172
173 // 1. Write a new hosted configuration version through the binding.
174 const version = yield* send(
175 HttpClientRequest.post(`${baseUrl}/version`).pipe(
176 HttpClientRequest.bodyJsonUnsafe({ content: newContent }),
177 ),
178 ).pipe(
179 Effect.flatMap((response) => response.json),
180 Effect.map((json) => json as { versionNumber?: number }),
181 );
182 expect(version.versionNumber).toBeGreaterThan(1);
183
184 // 2. Roll it out with the all-at-once strategy.
185 const started = yield* send(
186 HttpClientRequest.post(`${baseUrl}/deploy`).pipe(
187 HttpClientRequest.bodyJsonUnsafe({
188 version: String(version.versionNumber),
189 }),
190 ),
191 ).pipe(
192 Effect.flatMap((response) => response.json),
193 Effect.map(
194 (json) => json as { deploymentNumber?: number; state?: string },
195 ),
196 );
197 expect(started.deploymentNumber).toBeGreaterThan(0);
198
199 // 3. Poll the deployment through the binding until it completes.
200 const settled = yield* send(
201 HttpClientRequest.get(
202 `${baseUrl}/deployment?number=${started.deploymentNumber}`,
203 ),
204 ).pipe(
205 Effect.flatMap((response) => response.json),
206 Effect.map((json) => json as { state?: string }),
207 Effect.repeat({
208 schedule: Schedule.spaced("3 seconds"),
209 until: (json): boolean => json.state === "COMPLETE",
210 times: 30,
211 }),
212 );
213 expect(settled.state).toBe("COMPLETE");
214
215 // 4. The data plane now serves the new content.
216 const body = yield* send(
217 HttpClientRequest.get(`${baseUrl}/config`),
218 ).pipe(
219 Effect.flatMap((response) => response.json),
220 Effect.map((json) => json as { content?: string }),
221 Effect.filterOrFail(
222 (json) => json.content === newContent,
223 () => new Error("new configuration not yet served"),
224 ),
225 Effect.retry({
226 schedule: Schedule.max([
227 Schedule.spaced("3 seconds"),
228 Schedule.recurs(20),
229 ]),
230 }),
231 );
232 expect(JSON.parse(body.content!)).toEqual({
233 featureX: false,
234 limit: 7,
235 });
236 }),
237 { timeout: 300_000 },
238 );
239 });
240
241 describe("ValidateConfiguration", () => {
242 test.provider(
243 "the Lambda validates a hosted version against the profile's validators",
244 () =>
245 Effect.gen(function* () {
246 const body = yield* send(
247 HttpClientRequest.post(`${baseUrl}/validate`).pipe(
248 HttpClientRequest.bodyJsonUnsafe({ version: "1" }),
249 ),
250 ).pipe(
251 Effect.flatMap((response) => response.json),
252 Effect.map((json) => json as { valid?: boolean }),
253 );
254 expect(body.valid).toBe(true);
255 }),
256 { timeout: 120_000 },
257 );
258 });
259
260 describe("StopDeployment", () => {
261 test.provider(
262 "the Lambda stops a slow in-flight rollout",
263 () =>
264 Effect.gen(function* () {
265 // Start a rollout that takes 20 minutes so it is stoppable.
266 const started = yield* send(
267 HttpClientRequest.post(`${baseUrl}/deploy`).pipe(
268 HttpClientRequest.bodyJsonUnsafe({ version: "1", slow: true }),
269 ),
270 ).pipe(
271 Effect.flatMap((response) => response.json),
272 Effect.map(
273 (json) => json as { deploymentNumber?: number; state?: string },
274 ),
275 );
276 expect(started.deploymentNumber).toBeGreaterThan(0);
277
278 const stopped = yield* send(
279 HttpClientRequest.post(`${baseUrl}/stop`).pipe(
280 HttpClientRequest.bodyJsonUnsafe({
281 number: started.deploymentNumber,
282 }),
283 ),
284 ).pipe(
285 Effect.flatMap((response) => response.json),
286 Effect.map((json) => json as { state?: string }),
287 );
288 expect(stopped.state).toBe("ROLLED_BACK");
289 }),
290 { timeout: 180_000 },
291 );
292 });
293 },
294);