1import * as AWS from "@/AWS";
2import { EmailContact } from "@/AWS/NotificationsContacts";
3import * as Test from "@/Test/Alchemy";
4import * as contacts from "@distilled.cloud/aws/notificationscontacts";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const CONTACT_NAME = "alchemy-test-email-contact";
13const EMAIL_A = "sam+alchemy-test-contact-a@alchemy.run";
14const EMAIL_B = "sam+alchemy-test-contact-b@alchemy.run";
15
16const unwrap = (value: string | Redacted.Redacted<string>): string =>
17 typeof value === "string" ? value : Redacted.value(value);
18
19const assertContactGone = (arn: string) =>
20 contacts.getEmailContact({ arn }).pipe(
21 Effect.flatMap(() => Effect.fail(new Error(`contact ${arn} still exists`))),
22 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
23 Effect.retry({
24 while: (e) => e instanceof Error,
25 schedule: Schedule.max([
26 Schedule.fixed("2 seconds"),
27 Schedule.recurs(10),
28 ]),
29 }),
30 );
31
32describe(
33 "AWS.NotificationsContacts.EmailContact",
34 { tags: ["provider:aws", "provider:aws:notificationscontacts", "live"] },
35 () => {
36 test.provider(
37 "email contact lifecycle (unverified create, tag update, replacement)",
38 (stack) =>
39 Effect.gen(function* () {
40 yield* stack.destroy();
41
42 const deployContact = (props: {
43 emailAddress: string;
44 tags: Record<string, string>;
45 }) =>
46 stack.deploy(
47 Effect.gen(function* () {
48 const contact = yield* EmailContact("OnCall", {
49 name: CONTACT_NAME,
50 emailAddress: props.emailAddress,
51 tags: props.tags,
52 });
53 return {
54 arn: contact.emailContactArn,
55 name: contact.name,
56 emailAddress: contact.emailAddress,
57 status: contact.status,
58 };
59 }),
60 );
61
62 // CREATE — the contact exists in the unverified state (activation
63 // is a human email-confirmation loop; AWS reports it as
64 // `inactive` until the address owner confirms).
65 const created = yield* deployContact({
66 emailAddress: EMAIL_A,
67 tags: { purpose: "alchemy-test" },
68 });
69 expect(created.arn).toContain(":emailcontact/");
70 expect(created.name).toBe(CONTACT_NAME);
71 expect(created.emailAddress).toBe(EMAIL_A);
72 expect(created.status).toBe("inactive");
73
74 // Out-of-band: the contact and its tags are live.
75 const observed = yield* contacts.getEmailContact({
76 arn: created.arn,
77 });
78 expect(unwrap(observed.emailContact.address)).toBe(EMAIL_A);
79 expect(observed.emailContact.status).toBe("inactive");
80 const tags = yield* contacts.listTagsForResource({
81 arn: created.arn,
82 });
83 expect(tags.tags?.purpose).toBe("alchemy-test");
84 expect(tags.tags?.["alchemy::id"]).toBe("OnCall");
85
86 // UPDATE — tags are the only mutable aspect; the ARN is stable.
87 const updated = yield* deployContact({
88 emailAddress: EMAIL_A,
89 tags: { purpose: "alchemy-test", updated: "true" },
90 });
91 expect(updated.arn).toBe(created.arn);
92 const tagsAfterUpdate = yield* contacts.listTagsForResource({
93 arn: created.arn,
94 });
95 expect(tagsAfterUpdate.tags?.updated).toBe("true");
96
97 // REPLACE — contacts have no update API, so changing the address
98 // replaces the contact (new ARN, old one deleted).
99 const replaced = yield* deployContact({
100 emailAddress: EMAIL_B,
101 tags: { purpose: "alchemy-test" },
102 });
103 expect(replaced.arn).not.toBe(created.arn);
104 expect(replaced.emailAddress).toBe(EMAIL_B);
105 yield* assertContactGone(created.arn);
106
107 // DESTROY — verified out-of-band with a typed wait-until-gone.
108 yield* stack.destroy();
109 yield* assertContactGone(replaced.arn);
110 }),
111 { timeout: 240_000 },
112 );
113 },
114);