1import * as AWS from "@/AWS";
2import {
3 ChannelAssociation,
4 EventRule,
5 NotificationConfiguration,
6 NotificationHub,
7} from "@/AWS/Notifications";
8import { EmailContact } from "@/AWS/NotificationsContacts";
9import { pinNotificationsRegion } from "@/AWS/Notifications/internal.ts";
10import * as Test from "@/Test/Alchemy";
11import * as notifications from "@distilled.cloud/aws/notifications";
12import { describe, expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19const CONFIG_NAME = "alchemy-test-notif-config";
20const CONFIG_NAME_RENAMED = "alchemy-test-notif-config-renamed";
21
22const getConfig = (arn: string) =>
23 pinNotificationsRegion(notifications.getNotificationConfiguration({ arn }));
24
25const getRule = (arn: string) =>
26 pinNotificationsRegion(notifications.getEventRule({ arn }));
27
28const assertConfigGone = (arn: string) =>
29 pinNotificationsRegion(
30 notifications.getNotificationConfiguration({ arn }).pipe(
31 Effect.flatMap(() =>
32 Effect.fail(new Error(`configuration ${arn} still exists`)),
33 ),
34 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
35 Effect.retry({
36 while: (e) => e instanceof Error,
37 schedule: Schedule.max([
38 Schedule.fixed("2 seconds"),
39 Schedule.recurs(10),
40 ]),
41 }),
42 ),
43 );
44
45const assertRuleGone = (arn: string) =>
46 pinNotificationsRegion(
47 notifications.getEventRule({ arn }).pipe(
48 Effect.flatMap(() => Effect.fail(new Error(`rule ${arn} still exists`))),
49 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
50 Effect.retry({
51 while: (e) => e instanceof Error,
52 schedule: Schedule.max([
53 Schedule.fixed("2 seconds"),
54 Schedule.recurs(10),
55 ]),
56 }),
57 ),
58 );
59
60const listHubRegions = pinNotificationsRegion(
61 notifications.listNotificationHubs.items({}).pipe(
62 Stream.runCollect,
63 Effect.map((chunk) =>
64 Array.from(chunk).map((hub) => hub.notificationHubRegion),
65 ),
66 ),
67);
68
69describe(
70 "AWS.Notifications",
71 { tags: ["provider:aws", "provider:aws:notifications", "live"] },
72 () => {
73 test.provider(
74 "notification configuration + event rule lifecycle",
75 (stack) =>
76 Effect.gen(function* () {
77 yield* stack.destroy();
78
79 const deploy = (props: {
80 name: string;
81 description: string;
82 aggregationDuration: "LONG" | "SHORT" | "NONE";
83 tags: Record<string, string>;
84 rule: {
85 eventType: string;
86 eventPattern?: Record<string, any>;
87 regions: string[];
88 };
89 }) =>
90 stack.deploy(
91 Effect.gen(function* () {
92 const config = yield* NotificationConfiguration("Config", {
93 name: props.name,
94 description: props.description,
95 aggregationDuration: props.aggregationDuration,
96 tags: props.tags,
97 });
98 const rule = yield* EventRule("Rule", {
99 notificationConfigurationArn:
100 config.notificationConfigurationArn,
101 source: "aws.s3",
102 eventType: props.rule.eventType,
103 eventPattern: props.rule.eventPattern,
104 regions: props.rule.regions,
105 });
106 return {
107 configArn: config.notificationConfigurationArn,
108 configName: config.name,
109 configStatus: config.status,
110 ruleArn: rule.eventRuleArn,
111 ruleRegions: rule.regions,
112 };
113 }),
114 );
115
116 // CREATE
117 const created = yield* deploy({
118 name: CONFIG_NAME,
119 description: "created by alchemy test",
120 aggregationDuration: "SHORT",
121 tags: { purpose: "alchemy-test" },
122 rule: { eventType: "Object Created", regions: ["us-west-2"] },
123 });
124 expect(created.configArn).toContain(":configuration/");
125 expect(created.configName).toBe(CONFIG_NAME);
126 expect(created.ruleArn).toContain("/rule/");
127
128 // Out-of-band: config, rule, and tags are live.
129 const observed = yield* getConfig(created.configArn);
130 expect(observed.name).toBe(CONFIG_NAME);
131 expect(observed.description).toBe("created by alchemy test");
132 expect(observed.aggregationDuration).toBe("SHORT");
133 const observedRule = yield* getRule(created.ruleArn);
134 expect(observedRule.source).toBe("aws.s3");
135 expect(observedRule.eventType).toBe("Object Created");
136 expect(observedRule.regions).toEqual(["us-west-2"]);
137 const tags = yield* pinNotificationsRegion(
138 notifications.listTagsForResource({ arn: created.configArn }),
139 );
140 expect(tags.tags?.purpose).toBe("alchemy-test");
141 expect(tags.tags?.["alchemy::id"]).toBe("Config");
142
143 // UPDATE — rename the config in place, change aggregation, widen the
144 // rule with a pattern and a second region. ARNs must be stable.
145 const updated = yield* deploy({
146 name: CONFIG_NAME_RENAMED,
147 description: "updated by alchemy test",
148 aggregationDuration: "LONG",
149 tags: { purpose: "alchemy-test", updated: "true" },
150 rule: {
151 eventType: "Object Created",
152 eventPattern: { detail: { bucket: { name: ["alchemy-test"] } } },
153 regions: ["us-west-2", "us-east-2"],
154 },
155 });
156 expect(updated.configArn).toBe(created.configArn);
157 expect(updated.ruleArn).toBe(created.ruleArn);
158 const afterUpdate = yield* getConfig(created.configArn);
159 expect(afterUpdate.name).toBe(CONFIG_NAME_RENAMED);
160 expect(afterUpdate.description).toBe("updated by alchemy test");
161 expect(afterUpdate.aggregationDuration).toBe("LONG");
162 const ruleAfterUpdate = yield* getRule(created.ruleArn);
163 expect([...ruleAfterUpdate.regions].sort()).toEqual([
164 "us-east-2",
165 "us-west-2",
166 ]);
167 expect(ruleAfterUpdate.eventPattern).toContain("alchemy-test");
168 const tagsAfterUpdate = yield* pinNotificationsRegion(
169 notifications.listTagsForResource({ arn: created.configArn }),
170 );
171 expect(tagsAfterUpdate.tags?.updated).toBe("true");
172
173 // REPLACE — changing the event type replaces the rule (new ARN);
174 // the config is untouched.
175 const replaced = yield* deploy({
176 name: CONFIG_NAME_RENAMED,
177 description: "updated by alchemy test",
178 aggregationDuration: "LONG",
179 tags: { purpose: "alchemy-test", updated: "true" },
180 rule: { eventType: "Object Deleted", regions: ["us-west-2"] },
181 });
182 expect(replaced.configArn).toBe(created.configArn);
183 expect(replaced.ruleArn).not.toBe(created.ruleArn);
184 const replacedRule = yield* getRule(replaced.ruleArn);
185 expect(replacedRule.eventType).toBe("Object Deleted");
186 yield* assertRuleGone(created.ruleArn);
187
188 // DESTROY — everything gone, verified out-of-band.
189 yield* stack.destroy();
190 yield* assertRuleGone(replaced.ruleArn);
191 yield* assertConfigGone(created.configArn);
192 }),
193 { timeout: 240_000 },
194 );
195
196 test.provider(
197 "channel association lifecycle",
198 (stack) =>
199 Effect.gen(function* () {
200 yield* stack.destroy();
201
202 const deploy = () =>
203 stack.deploy(
204 Effect.gen(function* () {
205 const config = yield* NotificationConfiguration(
206 "ChannelConfig",
207 {
208 name: "alchemy-test-notif-channel-config",
209 description: "channel association test",
210 },
211 );
212 const contact = yield* EmailContact("Contact", {
213 name: "alchemy-test-notif-contact",
214 emailAddress: "alchemy-test-notif-channel@example.com",
215 });
216 const association = yield* ChannelAssociation("Assoc", {
217 notificationConfigurationArn:
218 config.notificationConfigurationArn,
219 channelArn: contact.emailContactArn,
220 });
221 return {
222 configArn: config.notificationConfigurationArn,
223 contactArn: contact.emailContactArn,
224 associatedChannel: association.channelArn,
225 };
226 }),
227 );
228
229 // CREATE — the association's channel is the contact.
230 const created = yield* deploy();
231 expect(created.associatedChannel).toBe(created.contactArn);
232
233 // Out-of-band: the channel is associated with the configuration.
234 const channels = yield* pinNotificationsRegion(
235 notifications.listChannels({
236 notificationConfigurationArn: created.configArn,
237 }),
238 );
239 expect(channels.channels).toContain(created.contactArn);
240
241 // Idempotent re-deploy (no change).
242 const again = yield* deploy();
243 expect(again.associatedChannel).toBe(created.contactArn);
244
245 // DESTROY — association, contact and configuration all gone.
246 yield* stack.destroy();
247 yield* assertConfigGone(created.configArn);
248 }),
249 { tags: ["provider:aws:notificationscontacts"], timeout: 240_000 },
250 );
251
252 test.provider(
253 "notification hub lifecycle",
254 (stack) =>
255 Effect.gen(function* () {
256 yield* stack.destroy();
257
258 // AWS refuses to deregister the last ACTIVE hub, so keep a baseline
259 // hub (us-west-2) registered outside the stack. Registration is an
260 // idempotent upsert, so this is a no-op when it already exists.
261 yield* pinNotificationsRegion(
262 notifications.registerNotificationHub({
263 notificationHubRegion: "us-west-2",
264 }),
265 );
266
267 const deployHub = (region: string) =>
268 stack.deploy(
269 Effect.gen(function* () {
270 const hub = yield* NotificationHub("Hub", { region });
271 return {
272 region: hub.notificationHubRegion,
273 status: hub.status,
274 };
275 }),
276 );
277
278 const created = yield* deployHub("us-east-2");
279 expect(created.region).toBe("us-east-2");
280 expect(created.status).toBe("ACTIVE");
281
282 // Out-of-band: the hub is registered.
283 expect(yield* listHubRegions).toContain("us-east-2");
284
285 // Idempotent re-deploy (no change).
286 const again = yield* deployHub("us-east-2");
287 expect(again.region).toBe("us-east-2");
288
289 // DESTROY — the stack hub deregisters; the baseline hub remains.
290 yield* stack.destroy();
291 const regions = yield* listHubRegions.pipe(
292 Effect.repeat({
293 schedule: Schedule.fixed("2 seconds"),
294 until: (rs) => !rs.includes("us-east-2"),
295 times: 15,
296 }),
297 );
298 expect(regions).not.toContain("us-east-2");
299 expect(regions).toContain("us-west-2");
300 }),
301 { timeout: 180_000 },
302 );
303 },
304);