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 AppConfigEventsTestFunctionLive, {
11 AppConfigEventsTestFunction,
12} from "./fixtures/events-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "AppConfigEvents");
17
18let baseUrl: string;
19
20interface EventBody {
21 event: {
22 InvocationId?: string;
23 Type?: string;
24 DeploymentNumber?: number;
25 Environment?: { Id?: string };
26 } | null;
27}
28
29class DeploymentRequestFailed extends Data.TaggedError(
30 "DeploymentRequestFailed",
31)<{
32 readonly status: number;
33 readonly body: string;
34}> {}
35
36class DeploymentEventPending extends Data.TaggedError(
37 "DeploymentEventPending",
38)<{
39 readonly deploymentNumber: number;
40}> {}
41
42const startDeployment = Effect.suspend(() =>
43 HttpClient.execute(
44 HttpClientRequest.post(`${baseUrl}/deploy`).pipe(
45 HttpClientRequest.bodyJsonUnsafe({ version: "1" }),
46 ),
47 ).pipe(
48 Effect.flatMap((response) =>
49 response.status === 200
50 ? response.json
51 : response.text.pipe(
52 Effect.flatMap((body) =>
53 Effect.fail(
54 new DeploymentRequestFailed({ status: response.status, body }),
55 ),
56 ),
57 ),
58 ),
59 Effect.map((json) => json as { deploymentNumber?: number; state?: string }),
60 // Lambda URL cold starts and fresh execution-role permissions can surface
61 // as transient 5xx responses. Retry those only; genuine 4xx responses are
62 // actionable test failures.
63 Effect.retry({
64 while: (error) =>
65 error._tag === "DeploymentRequestFailed" && error.status >= 500,
66 schedule: Schedule.exponential("1 second"),
67 times: 6,
68 }),
69 ),
70);
71
72const awaitCompleteEvent = (deploymentNumber: number) =>
73 HttpClient.get(
74 `${baseUrl}/event?number=${deploymentNumber}&type=OnDeploymentComplete`,
75 ).pipe(
76 Effect.flatMap((response) => response.json),
77 Effect.map((json) => json as unknown as EventBody),
78 Effect.flatMap((body) =>
79 body.event === null
80 ? Effect.fail(new DeploymentEventPending({ deploymentNumber }))
81 : Effect.succeed(body.event),
82 ),
83 Effect.retry({
84 while: (error) => error._tag === "DeploymentEventPending",
85 schedule: Schedule.fixed("3 seconds"),
86 times: 5,
87 }),
88 );
89
90describe(
91 "AppConfig DeploymentEventSource",
92 {
93 tags: [
94 "provider:aws",
95 "provider:aws:appconfig",
96 "provider:aws:lambda",
97 "provider:aws:s3",
98 "live",
99 ],
100 },
101 () => {
102 beforeAll(
103 Effect.gen(function* () {
104 yield* Effect.logInfo(
105 "AppConfig event source setup: destroying previous resources",
106 );
107 yield* sharedStack.destroy();
108
109 yield* Effect.logInfo(
110 "AppConfig event source setup: deploying app -> env -> extension -> Lambda",
111 );
112 const { functionUrl } = yield* sharedStack.deploy(
113 Effect.gen(function* () {
114 return yield* AppConfigEventsTestFunction;
115 }).pipe(Effect.provide(AppConfigEventsTestFunctionLive)),
116 );
117
118 expect(functionUrl).toBeTruthy();
119 baseUrl = functionUrl!.replace(/\/+$/, "");
120
121 // Readiness probe — fresh function URLs take seconds to serve 200s.
122 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
123 Effect.flatMap((response) =>
124 response.status === 200
125 ? Effect.succeed(response)
126 : Effect.fail(
127 new Error(`Function not ready: ${response.status}`),
128 ),
129 ),
130 Effect.retry({
131 schedule: Schedule.max([
132 Schedule.fixed("2 seconds"),
133 Schedule.recurs(75),
134 ]),
135 }),
136 );
137 }),
138 { timeout: 300_000 },
139 );
140 afterAll(sharedStack.destroy(), { timeout: 300_000 });
141
142 test.provider(
143 "deployment notifications invoke the Lambda through the extension",
144 () =>
145 Effect.gen(function* () {
146 // AppConfig notifications are fire-and-forget. The first invocation
147 // can be lost while the freshly-created AppConfig invoke role is
148 // propagating; waiting longer for that same notification cannot make
149 // it reappear. Retry the whole bounded start+observe cycle so each
150 // attempt emits a fresh action-point notification.
151 const observed = yield* Effect.gen(function* () {
152 const started = yield* startDeployment;
153 const deploymentNumber = started.deploymentNumber;
154 if (deploymentNumber === undefined) {
155 return yield* Effect.fail(
156 new DeploymentRequestFailed({
157 status: 200,
158 body: "StartDeployment response omitted DeploymentNumber",
159 }),
160 );
161 }
162 return {
163 deploymentNumber,
164 event: yield* awaitCompleteEvent(deploymentNumber),
165 };
166 }).pipe(
167 Effect.retry({
168 while: (error) => error._tag === "DeploymentEventPending",
169 schedule: Schedule.fixed("5 seconds"),
170 times: 3,
171 }),
172 );
173
174 expect(observed.deploymentNumber).toBeGreaterThan(0);
175 expect(observed.event.InvocationId).toBeTruthy();
176 expect(observed.event.DeploymentNumber).toBe(
177 observed.deploymentNumber,
178 );
179 }),
180 { timeout: 120_000 },
181 );
182 },
183);