1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as smsvoice from "@distilled.cloud/aws/pinpoint-sms-voice-v2";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import SmsVoiceOptOutTestFunctionLive, {
12 SmsVoiceOptOutTestFunction,
13 TEST_DESTINATION,
14} from "./fixtures/optout-handler.ts";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "SmsVoiceBindings");
19
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(60),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32const post = (path: string) =>
33 HttpClient.execute(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
34 Effect.flatMap((response) =>
35 response.status >= 500
36 ? response.text.pipe(
37 Effect.flatMap((body) =>
38 Effect.fail(
39 new TransientUpstream({ status: response.status, body }),
40 ),
41 ),
42 )
43 : Effect.succeed(response),
44 ),
45 Effect.retry({
46 while: (e) => e._tag === "TransientUpstream",
47 schedule: Schedule.max([
48 Schedule.exponential("1 second"),
49 Schedule.recurs(5),
50 ]),
51 }),
52 );
53
54// Ungated typed-error probes: prove the distilled error unions carry the
55// tags the bindings and tests depend on.
56test.provider(
57 "describeKeywords on a nonexistent origination identity fails with ResourceNotFoundException",
58 () =>
59 Effect.gen(function* () {
60 const error = yield* Effect.flip(
61 smsvoice.describeKeywords({
62 OriginationIdentity: "phone-ffffffffffffffffffffffffffffffff",
63 }),
64 );
65 expect(error._tag).toBe("ResourceNotFoundException");
66 }),
67 {
68 tags: ["provider:aws", "provider:aws:pinpointsmsvoicev2", "live"],
69 timeout: 60_000,
70 },
71);
72
73test.provider(
74 "putOptedOutNumber on a nonexistent opt-out list fails with ResourceNotFoundException",
75 () =>
76 Effect.gen(function* () {
77 const error = yield* Effect.flip(
78 smsvoice.putOptedOutNumber({
79 OptOutListName: "alchemy-nonexistent-opt-out-list",
80 OptedOutNumber: TEST_DESTINATION,
81 }),
82 );
83 expect(error._tag).toBe("ResourceNotFoundException");
84 }),
85 {
86 tags: [
87 "provider:aws",
88 "provider:aws:lambda",
89 "provider:aws:pinpointsmsvoicev2",
90 "live",
91 ],
92 timeout: 60_000,
93 },
94);
95
96describe(
97 "PinpointSMSVoiceV2 Bindings",
98 {
99 tags: [
100 "provider:aws",
101 "provider:aws:lambda",
102 "provider:aws:pinpointsmsvoicev2",
103 "live",
104 ],
105 },
106 () => {
107 beforeAll(
108 Effect.gen(function* () {
109 yield* Effect.logInfo(
110 "SmsVoice bindings setup: destroying previous resources",
111 );
112 yield* sharedStack.destroy();
113
114 yield* Effect.logInfo("SmsVoice bindings setup: deploying fixture");
115 const { functionUrl } = yield* sharedStack.deploy(
116 Effect.gen(function* () {
117 return yield* SmsVoiceOptOutTestFunction;
118 }).pipe(Effect.provide(SmsVoiceOptOutTestFunctionLive)),
119 );
120
121 expect(functionUrl).toBeTruthy();
122 baseUrl = functionUrl!.replace(/\/+$/, "");
123 const readinessUrl = `${baseUrl}/ping`;
124
125 yield* Effect.logInfo(
126 `SmsVoice bindings setup: probing readiness at ${readinessUrl}`,
127 );
128 yield* HttpClient.get(readinessUrl).pipe(
129 Effect.flatMap((response) =>
130 response.status === 200
131 ? Effect.succeed(response)
132 : Effect.fail(
133 new Error(`Function not ready: ${response.status}`),
134 ),
135 ),
136 Effect.tapError((error) =>
137 Effect.logWarning(
138 `SmsVoice bindings setup: fixture not ready yet (${String(error)})`,
139 ),
140 ),
141 Effect.retry({ schedule: readinessPolicy }),
142 );
143
144 // Freshly attached IAM role policies take a few seconds to
145 // propagate — hold the tests until the account-level grant works
146 // (the probe returns AccessDeniedException until then).
147 yield* post("/feedback-probe").pipe(
148 Effect.flatMap((r) => r.json),
149 Effect.repeat({
150 schedule: Schedule.spaced("3 seconds"),
151 until: (body): boolean =>
152 (body as { tag?: string }).tag !== "AccessDeniedException",
153 times: 20,
154 }),
155 );
156 }),
157 { timeout: 240_000 },
158 );
159
160 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
161 timeout: 120_000,
162 });
163
164 describe("PinpointSMSVoiceV2.PutOptedOutNumber", () => {
165 test.provider(
166 "opts a destination number out",
167 (_stack) =>
168 Effect.gen(function* () {
169 const response = (yield* post("/opt-out").pipe(
170 Effect.flatMap((r) => r.json),
171 )) as { optedOutNumber?: string; endUserOptedOut?: boolean };
172
173 expect(response.optedOutNumber).toBe(TEST_DESTINATION);
174 // Manually opted out (by the API), not by the end user.
175 expect(response.endUserOptedOut).toBe(false);
176 }),
177 { timeout: 120_000 },
178 );
179 });
180
181 describe("PinpointSMSVoiceV2.DescribeOptedOutNumbers", () => {
182 test.provider(
183 "finds the opted-out number",
184 (_stack) =>
185 Effect.gen(function* () {
186 // /opt-out is idempotent for a manually-opted-out number's
187 // presence — ensure it exists, then check.
188 yield* post("/opt-out");
189 const response = (yield* post("/opt-out-check").pipe(
190 Effect.flatMap((r) => r.json),
191 )) as { count: number; numbers: string[] };
192
193 expect(response.count).toBe(1);
194 expect(response.numbers).toContain(TEST_DESTINATION);
195 }),
196 { timeout: 120_000 },
197 );
198 });
199
200 describe("PinpointSMSVoiceV2.DeleteOptedOutNumber", () => {
201 test.provider(
202 "opts the number back in",
203 (_stack) =>
204 Effect.gen(function* () {
205 yield* post("/opt-out");
206 const deleted = (yield* post("/opt-out-delete").pipe(
207 Effect.flatMap((r) => r.json),
208 )) as {
209 ok: boolean;
210 deleted?: string;
211 tag?: string;
212 message?: string;
213 };
214 expect(deleted.ok, JSON.stringify(deleted)).toBe(true);
215 expect(deleted.deleted).toBe(TEST_DESTINATION);
216
217 const after = (yield* post("/opt-out-check").pipe(
218 Effect.flatMap((r) => r.json),
219 )) as { count: number };
220 expect(after.count).toBe(0);
221 }),
222 { timeout: 120_000 },
223 );
224 });
225
226 describe("PinpointSMSVoiceV2.CarrierLookup", () => {
227 test.provider(
228 "the grant allows the lookup",
229 (_stack) =>
230 Effect.gen(function* () {
231 const response = (yield* post("/carrier-lookup").pipe(
232 Effect.flatMap((r) => r.json),
233 )) as {
234 ok: boolean;
235 e164PhoneNumber?: string;
236 phoneNumberType?: string;
237 tag?: string;
238 message?: string;
239 };
240
241 // The simulator number may be rejected as unsupported by the
242 // lookup provider — the binding is proven as long as IAM let
243 // the call through.
244 expect(response.tag, JSON.stringify(response)).not.toBe(
245 "AccessDeniedException",
246 );
247 if (response.ok) {
248 expect(response.e164PhoneNumber).toBe(TEST_DESTINATION);
249 expect(response.phoneNumberType).toBeTruthy();
250 }
251 }),
252 { timeout: 120_000 },
253 );
254 });
255
256 describe("PinpointSMSVoiceV2.PutMessageFeedback", () => {
257 test.provider(
258 "surfaces the typed ResourceNotFoundException for an unknown message",
259 (_stack) =>
260 Effect.gen(function* () {
261 const response = (yield* post("/feedback-probe").pipe(
262 Effect.flatMap((r) => r.json),
263 )) as { ok: boolean; tag?: string; message?: string };
264
265 // The grant is on `*`; an unknown MessageId must surface the
266 // typed not-found tag (AccessDenied would mean a broken grant).
267 expect(response.ok).toBe(false);
268 expect(response.tag, JSON.stringify(response)).toBe(
269 "ResourceNotFoundException",
270 );
271 }),
272 { timeout: 120_000 },
273 );
274 });
275 },
276);