1import * as AWS from "@/AWS";
2import { Contact, 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
12class ContactStillExists extends Data.TaggedError("ContactStillExists")<{
13 readonly contactListName: string;
14 readonly emailAddress: string;
15}> {}
16
17const assertContactDeleted = (contactListName: string, emailAddress: string) =>
18 sesv2
19 .getContact({
20 ContactListName: contactListName,
21 EmailAddress: emailAddress,
22 })
23 .pipe(
24 Effect.flatMap(() =>
25 Effect.fail(new ContactStillExists({ contactListName, emailAddress })),
26 ),
27 // A deleted contact list takes its contacts with it.
28 Effect.catchTag("NotFoundException", () => Effect.void),
29 Effect.retry({
30 while: (e) => e._tag === "ContactStillExists",
31 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
32 }),
33 );
34
35test.provider(
36 "contact lifecycle: create with preferences, update, delete",
37 (stack) =>
38 Effect.gen(function* () {
39 yield* stack.destroy();
40
41 const { list, contact } = yield* stack.deploy(
42 Effect.gen(function* () {
43 const list = yield* ContactList("ContactHostList", {
44 topics: [
45 {
46 TopicName: "product-updates",
47 DisplayName: "Product Updates",
48 DefaultSubscriptionStatus: "OPT_OUT",
49 },
50 ],
51 });
52 const contact = yield* Contact("Subscriber", {
53 contactListName: list.contactListName,
54 emailAddress: "reader@alchemy-test.example.com",
55 topicPreferences: [
56 { TopicName: "product-updates", SubscriptionStatus: "OPT_IN" },
57 ],
58 attributes: { plan: "free" },
59 });
60 return { list, contact };
61 }),
62 );
63
64 expect(contact.contactListName).toBe(list.contactListName);
65 expect(contact.emailAddress).toBe("reader@alchemy-test.example.com");
66
67 // out-of-band verification via distilled
68 const observed = yield* sesv2.getContact({
69 ContactListName: list.contactListName,
70 EmailAddress: "reader@alchemy-test.example.com",
71 });
72 expect(observed.TopicPreferences?.[0]?.SubscriptionStatus).toBe("OPT_IN");
73 expect(observed.AttributesData).toContain("free");
74
75 // update preferences and unsubscribe-all in place
76 yield* stack.deploy(
77 Effect.gen(function* () {
78 const list = yield* ContactList("ContactHostList", {
79 topics: [
80 {
81 TopicName: "product-updates",
82 DisplayName: "Product Updates",
83 DefaultSubscriptionStatus: "OPT_OUT",
84 },
85 ],
86 });
87 yield* Contact("Subscriber", {
88 contactListName: list.contactListName,
89 emailAddress: "reader@alchemy-test.example.com",
90 topicPreferences: [
91 { TopicName: "product-updates", SubscriptionStatus: "OPT_OUT" },
92 ],
93 unsubscribeAll: true,
94 });
95 }),
96 );
97 const updated = yield* sesv2.getContact({
98 ContactListName: list.contactListName,
99 EmailAddress: "reader@alchemy-test.example.com",
100 });
101 expect(updated.UnsubscribeAll).toBe(true);
102
103 yield* stack.destroy();
104 yield* assertContactDeleted(
105 list.contactListName,
106 "reader@alchemy-test.example.com",
107 );
108 }),
109 {
110 tags: ["provider:aws", "provider:aws:ses", "live"],
111 timeout: 120_000,
112 exclusive: true,
113 },
114);
115
116test.provider(
117 "changing the email address replaces the contact within the same list",
118 (stack) =>
119 Effect.gen(function* () {
120 yield* stack.destroy();
121
122 // The list stays deployed across the replacement so the engine never
123 // removes a dependency of the resource being replaced.
124 const first = yield* stack.deploy(
125 Effect.gen(function* () {
126 const list = yield* ContactList("EmailChangeList", {});
127 const contact = yield* Contact("Subscriber", {
128 contactListName: list.contactListName,
129 emailAddress: "first@alchemy-test.example.com",
130 });
131 return {
132 contactListName: list.contactListName,
133 emailAddress: contact.emailAddress,
134 };
135 }),
136 );
137 expect(first.emailAddress).toBe("first@alchemy-test.example.com");
138
139 const second = yield* stack.deploy(
140 Effect.gen(function* () {
141 const list = yield* ContactList("EmailChangeList", {});
142 const contact = yield* Contact("Subscriber", {
143 contactListName: list.contactListName,
144 emailAddress: "second@alchemy-test.example.com",
145 });
146 return {
147 contactListName: list.contactListName,
148 emailAddress: contact.emailAddress,
149 };
150 }),
151 );
152 expect(second.emailAddress).toBe("second@alchemy-test.example.com");
153 yield* assertContactDeleted(
154 first.contactListName,
155 "first@alchemy-test.example.com",
156 );
157
158 yield* stack.destroy();
159 yield* assertContactDeleted(
160 second.contactListName,
161 "second@alchemy-test.example.com",
162 );
163 }),
164 {
165 tags: ["provider:aws", "provider:aws:ses", "live"],
166 timeout: 120_000,
167 exclusive: true,
168 },
169);