1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import SmsVoicePhoneTestFunctionLive, {
11 SmsVoicePhoneTestFunction,
12} from "./fixtures/phone-handler.ts";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "SmsVoicePhoneBindings");
17
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(60),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30const post = (path: string) =>
31 HttpClient.execute(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
32 Effect.flatMap((response) =>
33 response.status >= 500
34 ? response.text.pipe(
35 Effect.flatMap((body) =>
36 Effect.fail(
37 new TransientUpstream({ status: response.status, body }),
38 ),
39 ),
40 )
41 : Effect.succeed(response),
42 ),
43 Effect.retry({
44 while: (e) => e._tag === "TransientUpstream",
45 schedule: Schedule.max([
46 Schedule.exponential("1 second"),
47 Schedule.recurs(5),
48 ]),
49 }),
50 );
51
52// Leasing even a SIMULATOR number incurs a (small) monthly fee and
53// consumes account quota, so this fixture is gated behind
54// AWS_TEST_PINPOINT_SMS=1 like the PhoneNumber lifecycle test. The suite
55// skips clean without the flag.
56describe.skipIf(!process.env.AWS_TEST_PINPOINT_SMS)(
57 "PinpointSMSVoiceV2 PhoneNumber Bindings",
58 {
59 tags: [
60 "provider:aws",
61 "provider:aws:lambda",
62 "provider:aws:pinpointsmsvoicev2",
63 "live",
64 ],
65 },
66 () => {
67 beforeAll(
68 Effect.gen(function* () {
69 yield* Effect.logInfo(
70 "SmsVoice phone bindings setup: destroying previous resources",
71 );
72 yield* sharedStack.destroy();
73
74 yield* Effect.logInfo(
75 "SmsVoice phone bindings setup: deploying fixture",
76 );
77 const { functionUrl } = yield* sharedStack.deploy(
78 Effect.gen(function* () {
79 return yield* SmsVoicePhoneTestFunction;
80 }).pipe(Effect.provide(SmsVoicePhoneTestFunctionLive)),
81 );
82
83 expect(functionUrl).toBeTruthy();
84 baseUrl = functionUrl!.replace(/\/+$/, "");
85 const readinessUrl = `${baseUrl}/ping`;
86
87 yield* Effect.logInfo(
88 `SmsVoice phone bindings setup: probing readiness at ${readinessUrl}`,
89 );
90 yield* HttpClient.get(readinessUrl).pipe(
91 Effect.flatMap((response) =>
92 response.status === 200
93 ? Effect.succeed(response)
94 : Effect.fail(
95 new Error(`Function not ready: ${response.status}`),
96 ),
97 ),
98 Effect.tapError((error) =>
99 Effect.logWarning(
100 `SmsVoice phone bindings setup: fixture not ready yet (${String(error)})`,
101 ),
102 ),
103 Effect.retry({ schedule: readinessPolicy }),
104 );
105 }),
106 { timeout: 240_000 },
107 );
108
109 afterAll(sharedStack.destroy(), { timeout: 240_000 });
110
111 describe("PinpointSMSVoiceV2.SendTextMessage", () => {
112 test.provider(
113 "sends an SMS to the simulator destination",
114 (_stack) =>
115 Effect.gen(function* () {
116 const response = (yield* post("/send-text").pipe(
117 Effect.flatMap((r) => r.json),
118 )) as { messageId?: string };
119
120 expect(typeof response.messageId).toBe("string");
121 expect(response.messageId!.length).toBeGreaterThan(0);
122 }),
123 { timeout: 120_000 },
124 );
125 });
126
127 describe("PinpointSMSVoiceV2.SendVoiceMessage", () => {
128 test.provider(
129 "the grant allows the send (typed capability verdict otherwise)",
130 (_stack) =>
131 Effect.gen(function* () {
132 const response = (yield* post("/send-voice").pipe(
133 Effect.flatMap((r) => r.json),
134 )) as {
135 ok: boolean;
136 messageId?: string;
137 tag?: string;
138 message?: string;
139 };
140
141 // The SIMULATOR number is SMS-only; a typed capability
142 // rejection still proves the binding + IAM grant.
143 expect(response.tag, JSON.stringify(response)).not.toBe(
144 "AccessDeniedException",
145 );
146 if (response.ok) {
147 expect(response.messageId).toBeTruthy();
148 }
149 }),
150 { timeout: 120_000 },
151 );
152 });
153
154 describe("PinpointSMSVoiceV2.SendMediaMessage", () => {
155 test.provider(
156 "the grant allows the send (typed capability verdict otherwise)",
157 (_stack) =>
158 Effect.gen(function* () {
159 const response = (yield* post("/send-media").pipe(
160 Effect.flatMap((r) => r.json),
161 )) as {
162 ok: boolean;
163 messageId?: string;
164 tag?: string;
165 message?: string;
166 };
167
168 expect(response.tag, JSON.stringify(response)).not.toBe(
169 "AccessDeniedException",
170 );
171 if (response.ok) {
172 expect(response.messageId).toBeTruthy();
173 }
174 }),
175 { timeout: 120_000 },
176 );
177 });
178
179 describe("PinpointSMSVoiceV2 Keywords", () => {
180 test.provider(
181 "put, list, and delete a keyword on the number",
182 (_stack) =>
183 Effect.gen(function* () {
184 const put = (yield* post("/keyword-put").pipe(
185 Effect.flatMap((r) => r.json),
186 )) as { keyword?: string; message?: string };
187 expect(put.keyword).toBe("INFO");
188 expect(put.message).toContain("alchemy.run");
189
190 const list = (yield* post("/keyword-list").pipe(
191 Effect.flatMap((r) => r.json),
192 )) as { keywords: string[] };
193 expect(list.keywords).toContain("INFO");
194
195 const deleted = (yield* post("/keyword-delete").pipe(
196 Effect.flatMap((r) => r.json),
197 )) as { deleted?: string };
198 expect(deleted.deleted).toBe("INFO");
199
200 const after = (yield* post("/keyword-list").pipe(
201 Effect.flatMap((r) => r.json),
202 )) as { keywords: string[] };
203 expect(after.keywords).not.toContain("INFO");
204 }),
205 { timeout: 120_000 },
206 );
207 });
208 },
209);