1import * as AWS from "@/AWS";
2import { ContactList } from "@/AWS/SES";
3import * as Test from "@/Test/Alchemy";
4import * as sesv2 from "@distilled.cloud/aws/sesv2";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// SES allows only one contact list per AWS account, so every test that creates
13// one is `exclusive` — two contact-list suites running concurrently would
14// collide on the account-wide singleton.
15
16class ContactListStillExists extends Data.TaggedError(
17 "ContactListStillExists",
18)<{
19 readonly name: string;
20}> {}
21
22const assertContactListDeleted = (name: string) =>
23 sesv2.getContactList({ ContactListName: name }).pipe(
24 Effect.flatMap(() => Effect.fail(new ContactListStillExists({ name }))),
25 Effect.catchTag("NotFoundException", () => Effect.void),
26 Effect.retry({
27 while: (e) => e._tag === "ContactListStillExists",
28 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
29 }),
30 );
31
32test.provider(
33 "contact list lifecycle: create, update topics/description/tags, delete",
34 (stack) =>
35 Effect.gen(function* () {
36 yield* stack.destroy();
37
38 const list = yield* stack.deploy(
39 Effect.gen(function* () {
40 return yield* ContactList("Newsletter", {
41 description: "Weekly newsletter",
42 topics: [
43 {
44 TopicName: "product-updates",
45 DisplayName: "Product Updates",
46 DefaultSubscriptionStatus: "OPT_IN",
47 },
48 ],
49 tags: { Environment: "test" },
50 });
51 }),
52 );
53
54 expect(list.contactListName).toBeDefined();
55 expect(list.contactListArn).toContain(":contact-list/");
56
57 // out-of-band verification via distilled
58 const observed = yield* sesv2.getContactList({
59 ContactListName: list.contactListName,
60 });
61 expect(observed.Description).toBe("Weekly newsletter");
62 expect(observed.Topics?.[0]?.TopicName).toBe("product-updates");
63 const tags = Object.fromEntries(
64 (observed.Tags ?? []).map((t) => [t.Key, t.Value]),
65 );
66 expect(tags.Environment).toBe("test");
67 expect(tags["alchemy::id"]).toBe("Newsletter");
68
69 // update description, add a topic, add a tag
70 yield* stack.deploy(
71 Effect.gen(function* () {
72 return yield* ContactList("Newsletter", {
73 description: "Weekly product newsletter",
74 topics: [
75 {
76 TopicName: "product-updates",
77 DisplayName: "Product Updates",
78 DefaultSubscriptionStatus: "OPT_IN",
79 },
80 {
81 TopicName: "promotions",
82 DisplayName: "Promotions",
83 DefaultSubscriptionStatus: "OPT_OUT",
84 },
85 ],
86 tags: { Environment: "test", Extra: "1" },
87 });
88 }),
89 );
90 const updated = yield* sesv2.getContactList({
91 ContactListName: list.contactListName,
92 });
93 expect(updated.Description).toBe("Weekly product newsletter");
94 expect((updated.Topics ?? []).length).toBe(2);
95 const updatedTags = Object.fromEntries(
96 (updated.Tags ?? []).map((t) => [t.Key, t.Value]),
97 );
98 expect(updatedTags.Extra).toBe("1");
99
100 yield* stack.destroy();
101 yield* assertContactListDeleted(list.contactListName);
102 }),
103 {
104 tags: ["provider:aws", "provider:aws:ses", "live"],
105 timeout: 120_000,
106 exclusive: true,
107 },
108);
109
110test.provider(
111 "custom name replaces on rename",
112 (stack) =>
113 Effect.gen(function* () {
114 yield* stack.destroy();
115
116 const first = yield* stack.deploy(
117 Effect.gen(function* () {
118 return yield* ContactList("Named", {
119 contactListName: "alchemy-test-contact-list-a",
120 });
121 }),
122 );
123 expect(first.contactListName).toBe("alchemy-test-contact-list-a");
124
125 // The rename replaces delete-first: creating -b before deleting -a would
126 // exceed the one-contact-list-per-account limit.
127 const second = yield* stack.deploy(
128 Effect.gen(function* () {
129 return yield* ContactList("Named", {
130 contactListName: "alchemy-test-contact-list-b",
131 });
132 }),
133 );
134 expect(second.contactListName).toBe("alchemy-test-contact-list-b");
135 yield* assertContactListDeleted("alchemy-test-contact-list-a");
136
137 yield* stack.destroy();
138 yield* assertContactListDeleted("alchemy-test-contact-list-b");
139 }),
140 {
141 tags: ["provider:aws", "provider:aws:ses", "live"],
142 timeout: 120_000,
143 exclusive: true,
144 },
145);