1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as Lambda from "@distilled.cloud/aws/lambda";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import { fileURLToPath } from "node:url";
8
9const timeoutHandlerPath = fileURLToPath(
10 new URL("./timeout-handler.ts", import.meta.url),
11);
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15test.provider(
16 "syncs event invoke config on function and alias",
17 (stack) =>
18 Effect.gen(function* () {
19 yield* stack.destroy();
20
21 const program = ({
22 functionConfig,
23 alias,
24 }: {
25 functionConfig?: AWS.Lambda.EventInvokeConfig;
26 alias?: {
27 eventInvokeConfig?: AWS.Lambda.EventInvokeConfig;
28 };
29 }) =>
30 Effect.gen(function* () {
31 const queue = yield* AWS.SQS.Queue("FailureQueue", {
32 visibilityTimeout: "30 seconds",
33 });
34
35 const fn = yield* AWS.Lambda.Function("AsyncFn", {
36 main: timeoutHandlerPath,
37 handler: "handler",
38 isExternal: true,
39 functionUrl: false,
40 eventInvokeConfig: functionConfig,
41 });
42
43 yield* fn.bind("AllowEventInvokeDestination", {
44 policyStatements: [
45 {
46 Effect: "Allow",
47 Action: ["sqs:SendMessage"],
48 Resource: [queue.queueArn],
49 },
50 ],
51 });
52
53 const version = yield* AWS.Lambda.Version("AsyncVersion", {
54 function: fn,
55 });
56
57 const live = alias
58 ? yield* AWS.Lambda.Alias("LiveAlias", {
59 version,
60 aliasName: "live",
61 eventInvokeConfig: alias.eventInvokeConfig,
62 })
63 : undefined;
64
65 return { fn, queue, live };
66 });
67
68 // --- create with function-level config ---
69 const created = yield* stack.deploy(
70 program({
71 functionConfig: {
72 maximumRetryAttempts: 0,
73 maximumEventAge: "1 minute",
74 },
75 }),
76 );
77
78 const liveCreated = yield* expectConfig(created.fn.functionName, {
79 maximumRetryAttempts: 0,
80 maximumEventAgeInSeconds: 60,
81 });
82 expect(liveCreated.MaximumRetryAttempts).toBe(0);
83 expect(liveCreated.MaximumEventAgeInSeconds).toBe(60);
84 expect(liveCreated.DestinationConfig?.OnFailure?.Destination).toBe(
85 undefined,
86 );
87
88 // --- update retry behavior and add a failure destination ---
89 const updated = yield* stack.deploy(
90 program({
91 functionConfig: {
92 maximumRetryAttempts: 1,
93 maximumEventAge: "2 minutes",
94 destinationConfig: {
95 OnFailure: {
96 Destination: created.queue.queueArn,
97 },
98 },
99 },
100 }),
101 );
102
103 const liveUpdated = yield* expectConfig(updated.fn.functionName, {
104 maximumRetryAttempts: 1,
105 maximumEventAgeInSeconds: 120,
106 onFailureDestination: created.queue.queueArn,
107 });
108 expect(liveUpdated.MaximumRetryAttempts).toBe(1);
109 expect(liveUpdated.MaximumEventAgeInSeconds).toBe(120);
110 expect(liveUpdated.DestinationConfig?.OnFailure?.Destination).toBe(
111 created.queue.queueArn,
112 );
113
114 // --- omit the prop: the config is deleted, not left behind ---
115 const removed = yield* stack.deploy(program({}));
116 yield* expectNoConfig(removed.fn.functionName);
117
118 // --- alias-scoped config ---
119 const withAlias = yield* stack.deploy(
120 program({
121 alias: {
122 eventInvokeConfig: {
123 maximumRetryAttempts: 2,
124 maximumEventAge: "5 minutes",
125 destinationConfig: {
126 OnFailure: {
127 Destination: created.queue.queueArn,
128 },
129 },
130 },
131 },
132 }),
133 );
134
135 const liveAliasConfig = yield* expectConfig(
136 withAlias.fn.functionName,
137 {
138 maximumRetryAttempts: 2,
139 maximumEventAgeInSeconds: 300,
140 onFailureDestination: created.queue.queueArn,
141 },
142 withAlias.live!.aliasName,
143 );
144 expect(liveAliasConfig.MaximumRetryAttempts).toBe(2);
145 expect(liveAliasConfig.MaximumEventAgeInSeconds).toBe(300);
146 expect(liveAliasConfig.DestinationConfig?.OnFailure?.Destination).toBe(
147 created.queue.queueArn,
148 );
149 // The unqualified function stays unconfigured.
150 yield* expectNoConfig(withAlias.fn.functionName);
151
152 // --- omit the alias prop: the alias-scoped config is deleted ---
153 const aliasCleared = yield* stack.deploy(program({ alias: {} }));
154 yield* expectNoConfig(
155 aliasCleared.fn.functionName,
156 aliasCleared.live!.aliasName,
157 );
158
159 yield* stack.destroy();
160
161 // Out-of-band proof the destroy removed the host function (and with it
162 // the event invoke configs) from the cloud.
163 yield* Lambda.getFunction({
164 FunctionName: aliasCleared.fn.functionName,
165 }).pipe(
166 Effect.flatMap(() =>
167 Effect.fail(
168 new Error(`Function ${aliasCleared.fn.functionName} still exists`),
169 ),
170 ),
171 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
172 Effect.retry({
173 schedule: Schedule.max([
174 Schedule.exponential(500),
175 Schedule.recurs(8),
176 ]),
177 }),
178 );
179 }).pipe(
180 Effect.tap(() => stack.destroy()),
181 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
182 ),
183 {
184 tags: ["provider:aws", "provider:aws:lambda", "provider:aws:sqs", "live"],
185 timeout: 360_000,
186 },
187);
188
189const getConfigOrUndefined = Effect.fn(function* (
190 functionName: string,
191 qualifier?: string,
192) {
193 return yield* Lambda.getFunctionEventInvokeConfig({
194 FunctionName: functionName,
195 Qualifier: qualifier,
196 }).pipe(
197 Effect.catchTag("ResourceNotFoundException", () =>
198 Effect.succeed(undefined),
199 ),
200 );
201});
202
203// Reads until the config matches the expected shape (updates propagate
204// eventually), failing after a bounded number of attempts.
205const expectConfig = Effect.fn(function* (
206 functionName: string,
207 expected: {
208 maximumRetryAttempts: number;
209 maximumEventAgeInSeconds: number;
210 onFailureDestination?: string;
211 },
212 qualifier?: string,
213) {
214 return yield* getConfigOrUndefined(functionName, qualifier).pipe(
215 Effect.filterOrFail(
216 (config): config is Lambda.FunctionEventInvokeConfig =>
217 config !== undefined &&
218 config.MaximumRetryAttempts === expected.maximumRetryAttempts &&
219 config.MaximumEventAgeInSeconds === expected.maximumEventAgeInSeconds &&
220 config.DestinationConfig?.OnFailure?.Destination ===
221 expected.onFailureDestination,
222 () => new Error("Event invoke config update has not propagated yet"),
223 ),
224 Effect.retry({
225 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
226 }),
227 );
228});
229
230const expectNoConfig = Effect.fn(function* (
231 functionName: string,
232 qualifier?: string,
233) {
234 yield* getConfigOrUndefined(functionName, qualifier).pipe(
235 Effect.filterOrFail(
236 (config) => config === undefined,
237 () => new Error("Event invoke config removal has not propagated yet"),
238 ),
239 Effect.retry({
240 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
241 }),
242 );
243});