1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as lambda from "@distilled.cloud/aws/lambda";
5import * as socialmessaging from "@distilled.cloud/aws/socialmessaging";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import SocialMessagingBindingsFunctionLive, {
12 SocialMessagingBindingsFunction,
13} from "./bindings-handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17
18// Every SocialMessaging binding operates on a WhatsApp Business Account
19// that can only be linked through the Meta embedded-signup flow in the AWS
20// console, so the live Lambda E2E is gated behind AWS_TEST_SOCIALMESSAGING=1
21// + AWS_TEST_SOCIALMESSAGING_WABA_ID. The ungated probes below prove the
22// distilled error unions the bindings surface are typed on every account.
23const RUN_LIVE =
24 !!process.env.AWS_TEST_SOCIALMESSAGING &&
25 !!process.env.AWS_TEST_SOCIALMESSAGING_WABA_ID;
26
27// Well-formed but nonexistent identifiers.
28const BOGUS_WABA_ID = "waba-0123456789abcdef0123456789abcdef";
29const BOGUS_PHONE_ID = "phone-number-id-0123456789abcdef0123456789abcdef";
30
31test.provider(
32 "listWhatsAppMessageTemplates on a nonexistent WABA fails with a typed tag",
33 () =>
34 Effect.gen(function* () {
35 const error = yield* Effect.flip(
36 socialmessaging.listWhatsAppMessageTemplates({ id: BOGUS_WABA_ID }),
37 );
38 expect([
39 "ResourceNotFoundException",
40 "InvalidParametersException",
41 "AccessDeniedByMetaException",
42 "DependencyException",
43 ]).toContain(error._tag);
44 }),
45 { tags: ["provider:aws", "provider:aws:socialmessaging", "live"] },
46);
47
48test.provider(
49 "listWhatsAppFlows on a nonexistent WABA fails with a typed tag",
50 () =>
51 Effect.gen(function* () {
52 const error = yield* Effect.flip(
53 socialmessaging.listWhatsAppFlows({ id: BOGUS_WABA_ID }),
54 );
55 expect([
56 "ResourceNotFoundException",
57 "InvalidParametersException",
58 "AccessDeniedByMetaException",
59 "DependencyException",
60 ]).toContain(error._tag);
61 }),
62 { tags: ["provider:aws", "provider:aws:socialmessaging", "live"] },
63);
64
65test.provider(
66 "getLinkedWhatsAppBusinessAccountPhoneNumber on a nonexistent phone number fails with a typed tag",
67 () =>
68 Effect.gen(function* () {
69 const error = yield* Effect.flip(
70 socialmessaging.getLinkedWhatsAppBusinessAccountPhoneNumber({
71 id: BOGUS_PHONE_ID,
72 }),
73 );
74 expect([
75 "ResourceNotFoundException",
76 "InvalidParametersException",
77 ]).toContain(error._tag);
78 }),
79 { tags: ["provider:aws", "provider:aws:socialmessaging", "live"] },
80);
81
82test.provider(
83 "sendWhatsAppMessage from a nonexistent phone number fails with a typed tag",
84 () =>
85 Effect.gen(function* () {
86 const error = yield* Effect.flip(
87 socialmessaging.sendWhatsAppMessage({
88 originationPhoneNumberId: BOGUS_PHONE_ID,
89 metaApiVersion: "v20.0",
90 message: new TextEncoder().encode(
91 JSON.stringify({
92 messaging_product: "whatsapp",
93 to: "+10000000000",
94 type: "text",
95 text: { body: "alchemy-socialmessaging-probe" },
96 }),
97 ),
98 }),
99 );
100 expect([
101 "ResourceNotFoundException",
102 "InvalidParametersException",
103 "AccessDeniedByMetaException",
104 "DependencyException",
105 ]).toContain(error._tag);
106 }),
107 { tags: ["provider:aws", "provider:aws:socialmessaging", "live"] },
108);
109
110test.provider(
111 "getWhatsAppMessageMedia on a nonexistent phone number fails with a typed tag",
112 () =>
113 Effect.gen(function* () {
114 const error = yield* Effect.flip(
115 socialmessaging.getWhatsAppMessageMedia({
116 mediaId: "alchemy-nonexistent-media-id",
117 originationPhoneNumberId: BOGUS_PHONE_ID,
118 metadataOnly: true,
119 }),
120 );
121 expect([
122 "ResourceNotFoundException",
123 "InvalidParametersException",
124 "AccessDeniedByMetaException",
125 "DependencyException",
126 // media ids are validated before the phone-number lookup
127 "ValidationException",
128 ]).toContain(error._tag);
129 }),
130 { tags: ["provider:aws", "provider:aws:socialmessaging", "live"] },
131);
132
133const sharedStack = Core.scratchStack(testOptions, "SocialMessagingBindings");
134
135let baseUrl: string;
136let functionArn: string | undefined;
137
138class FunctionStillExists extends Data.TaggedError("FunctionStillExists") {}
139
140const get = (path: string) =>
141 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
142const post = (path: string) =>
143 HttpClient.post(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
144
145// Live Lambda E2E — requires a WhatsApp Business Account already linked via
146// the AWS End User Messaging Social console (Meta embedded signup). NOTE:
147// the final stack.destroy() disassociates the WABA from the AWS account;
148// re-running afterwards requires redoing the console signup.
149describe(
150 "SocialMessaging Bindings (E2E)",
151 {
152 tags: [
153 "provider:aws",
154 "provider:aws:lambda",
155 "provider:aws:socialmessaging",
156 "live",
157 ],
158 },
159 () => {
160 beforeAll(
161 Effect.gen(function* () {
162 if (!RUN_LIVE) return;
163 yield* Effect.logInfo("SocialMessaging E2E setup: destroying previous");
164 yield* sharedStack.destroy();
165
166 yield* Effect.logInfo("SocialMessaging E2E setup: deploying Lambda");
167 const deployed = yield* sharedStack.deploy(
168 Effect.gen(function* () {
169 return yield* SocialMessagingBindingsFunction;
170 }).pipe(Effect.provide(SocialMessagingBindingsFunctionLive)),
171 );
172 functionArn = deployed.functionArn;
173
174 expect(deployed.functionUrl).toBeTruthy();
175 baseUrl = deployed.functionUrl!.replace(/\/+$/, "");
176
177 // Readiness probe — fresh function URLs take seconds to serve 200s.
178 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
179 Effect.flatMap((response) =>
180 response.status === 200
181 ? Effect.succeed(response)
182 : Effect.fail(
183 new Error(`Function not ready: ${response.status}`),
184 ),
185 ),
186 Effect.retry({
187 schedule: Schedule.max([
188 Schedule.fixed("2 seconds"),
189 Schedule.recurs(60),
190 ]),
191 }),
192 );
193 }),
194 { timeout: 300_000 },
195 );
196 afterAll(
197 Effect.gen(function* () {
198 if (!RUN_LIVE) return;
199 yield* sharedStack.destroy();
200 // Assert gone (skipped when beforeAll never got far enough to deploy):
201 // the fixture Lambda answers with the typed not-found tag out-of-band.
202 // afterAll runs outside `test.provider`'s layer, so raw distilled calls
203 // need the provider layer (credentials, region) supplied explicitly.
204 if (functionArn) {
205 yield* Core.withProviders(
206 lambda.getFunction({ FunctionName: functionArn }).pipe(
207 Effect.flatMap(() => Effect.fail(new FunctionStillExists())),
208 Effect.retry({
209 while: (error) => error._tag === "FunctionStillExists",
210 schedule: Schedule.exponential("500 millis"),
211 times: 8,
212 }),
213 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
214 ),
215 testOptions,
216 sharedStack.name,
217 );
218 }
219 }),
220 { timeout: 300_000 },
221 );
222
223 test.provider.skipIf(!RUN_LIVE)(
224 "all 23 capabilities initialize in the runtime",
225 () =>
226 Effect.gen(function* () {
227 const response = (yield* get("/bindings")) as any;
228 expect(response.bound).toHaveLength(23);
229 }),
230 );
231
232 test.provider.skipIf(!RUN_LIVE)(
233 "template + flow list bindings round-trip against the linked WABA",
234 () =>
235 Effect.gen(function* () {
236 const templates = (yield* get("/templates")) as any;
237 expect(typeof templates.count).toBe("number");
238 const flows = (yield* get("/flows")) as any;
239 expect(typeof flows.count).toBe("number");
240 const library = (yield* get("/library")) as any;
241 expect(typeof library.count).toBe("number");
242 }),
243 );
244
245 test.provider.skipIf(!RUN_LIVE)(
246 "read bindings surface typed errors on bogus identifiers",
247 () =>
248 Effect.gen(function* () {
249 const phone = (yield* get("/phone/typed-not-found")) as any;
250 expect(phone.typed).toBe(true);
251 const template = (yield* get("/template/typed-not-found")) as any;
252 expect(template.typed).toBe(true);
253 const flow = (yield* get("/flow/typed-not-found")) as any;
254 expect(flow.typed).toBe(true);
255 }),
256 );
257
258 test.provider.skipIf(!RUN_LIVE)(
259 "message + media mutation bindings surface typed errors without side effects",
260 () =>
261 Effect.gen(function* () {
262 const media = (yield* post("/media/typed-not-found")) as any;
263 expect(media.get).toBe(true);
264 expect(media.del).toBe(true);
265 const send = (yield* post("/send/typed-invalid")) as any;
266 expect(send.typed).toBe(true);
267 }),
268 );
269 },
270);