1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as sqs from "@distilled.cloud/aws/sqs";
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 B2biTestFunctionLive, {
12 B2biTestFunction,
13 EVENTS_QUEUE,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "B2biBindings");
19
20// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
21// over 60s on a fresh deploy.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under load
35// (cold re-init, IAM propagation on the freshly attached policy that the
36// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
37// 4xx/assertion failure surfaces immediately.
38const send = (request: HttpClientRequest.HttpClientRequest) =>
39 HttpClient.execute(request).pipe(
40 Effect.flatMap((response) =>
41 response.status >= 500
42 ? response.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(
45 new TransientUpstream({ status: response.status, body }),
46 ),
47 ),
48 )
49 : Effect.succeed(response),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "TransientUpstream",
53 schedule: Schedule.max([
54 Schedule.exponential("2 seconds"),
55 Schedule.recurs(6),
56 ]),
57 }),
58 );
59
60class TransformationEventNotObserved extends Data.TaggedError(
61 "TransformationEventNotObserved",
62)<{ readonly transformerJobId: string }> {
63 override get message() {
64 return `No B2BI transformation event was observed for job ${this.transformerJobId}`;
65 }
66}
67
68const runTransformerJob = Effect.fn(function* () {
69 const response = (yield* send(
70 HttpClientRequest.post(`${baseUrl}/transformer-job`),
71 ).pipe(Effect.flatMap((r) => r.json))) as {
72 transformerJobId: string;
73 status: string;
74 message: string | null;
75 outputFiles: { bucketName?: string; key?: string }[];
76 error?: string;
77 };
78 expect(response.error).toBeUndefined();
79 expect(response.transformerJobId).toBeTruthy();
80 expect(response.status).toBe("succeeded");
81 expect(response.outputFiles.length).toBeGreaterThan(0);
82 yield* Effect.logInfo(
83 `B2BI job ${response.transformerJobId}: ${response.status}; ${response.message ?? "no message"}`,
84 );
85 return response;
86});
87
88describe.sequential(
89 "B2BI Bindings",
90 {
91 tags: [
92 "provider:aws",
93 "provider:aws:b2bi",
94 "provider:aws:iam",
95 "provider:aws:lambda",
96 "provider:aws:s3",
97 "provider:aws:sqs",
98 "live",
99 ],
100 },
101 () => {
102 beforeAll(
103 Effect.gen(function* () {
104 yield* Effect.logInfo("B2BI test setup: destroying previous resources");
105 yield* sharedStack.destroy();
106
107 yield* Effect.logInfo("B2BI test setup: deploying fixture");
108 const { functionUrl } = yield* sharedStack.deploy(
109 Effect.gen(function* () {
110 return yield* B2biTestFunction;
111 }).pipe(Effect.provide(B2biTestFunctionLive)),
112 );
113
114 expect(functionUrl).toBeTruthy();
115 baseUrl = functionUrl!.replace(/\/+$/, "");
116
117 const readinessUrl = `${baseUrl}/bindings`;
118 yield* Effect.logInfo(
119 `B2BI test setup: probing readiness at ${readinessUrl}`,
120 );
121 yield* HttpClient.get(readinessUrl).pipe(
122 Effect.flatMap((response) =>
123 response.status === 200
124 ? Effect.succeed(response)
125 : Effect.fail(
126 new Error(`Function not ready: ${response.status}`),
127 ),
128 ),
129 Effect.tapError((error) =>
130 Effect.logWarning(
131 `B2BI test setup: fixture not ready yet (${String(error)})`,
132 ),
133 ),
134 Effect.retry({ schedule: readinessPolicy }),
135 );
136 }),
137 { timeout: 300_000 },
138 );
139
140 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
141 timeout: 180_000,
142 });
143
144 describe("binding registration", () => {
145 test.provider("all 8 capabilities initialize in the runtime", (_stack) =>
146 Effect.gen(function* () {
147 const response = yield* send(
148 HttpClientRequest.get(`${baseUrl}/bindings`),
149 ).pipe(Effect.flatMap((r) => r.json));
150 expect((response as any).bound).toHaveLength(8);
151 }),
152 );
153 });
154
155 describe("TestMapping", () => {
156 test.provider("maps JSON content with a JSONATA template", (_stack) =>
157 Effect.gen(function* () {
158 const response = (yield* send(
159 HttpClientRequest.post(`${baseUrl}/test-mapping`),
160 ).pipe(Effect.flatMap((r) => r.json))) as {
161 mappedFileContent: string;
162 };
163 // B2BI returns the mapped output as text (observed live: a JSON
164 // string literal of the pretty-printed document) — unwrap however
165 // many string layers it arrives in.
166 const parseDeep = (value: unknown): unknown =>
167 typeof value === "string" ? parseDeep(JSON.parse(value)) : value;
168 expect(parseDeep(response.mappedFileContent)).toEqual({
169 name: "acme",
170 });
171 }),
172 );
173 });
174
175 describe("CreateStarterMappingTemplate", () => {
176 test.provider(
177 "scaffolds a JSONATA template for X12 850",
178 (_stack) =>
179 Effect.gen(function* () {
180 const response = (yield* send(
181 HttpClientRequest.post(`${baseUrl}/starter-template`),
182 ).pipe(Effect.flatMap((r) => r.json))) as {
183 templateLength: number;
184 error?: string;
185 };
186 expect(response.error).toBeUndefined();
187 expect(response.templateLength).toBeGreaterThan(0);
188 }),
189 { timeout: 60_000 },
190 );
191 });
192
193 describe("GenerateMapping", () => {
194 test.provider(
195 "generates a mapping template from sample documents",
196 (_stack) =>
197 Effect.gen(function* () {
198 const response = (yield* send(
199 HttpClientRequest.post(`${baseUrl}/generate-mapping`),
200 ).pipe(Effect.flatMap((r) => r.json))) as {
201 templateLength: number;
202 bedrockAccessDenied: boolean;
203 error?: string;
204 };
205 expect(response.error).toBeUndefined();
206 if (response.bedrockAccessDenied) {
207 // Bedrock model access for B2BI's mapping model is only
208 // intermittently entitled in the testing account (the same call
209 // succeeds on other runs — cross-region model routing). The
210 // typed rejection ("AccessDeniedException: Access denied when
211 // invoking Bedrock's InvokeModel API") still proves the
212 // b2bi:GenerateMapping + bedrock:InvokeModel grants end-to-end —
213 // an IAM gap would deny b2bi:GenerateMapping itself.
214 expect(response.templateLength).toBe(0);
215 } else {
216 expect(response.templateLength).toBeGreaterThan(0);
217 }
218 }),
219 { timeout: 120_000 },
220 );
221 });
222
223 describe("TestParsing", () => {
224 test.provider(
225 "parses an X12 850 from S3 into JSON",
226 (_stack) =>
227 Effect.gen(function* () {
228 const response = (yield* send(
229 HttpClientRequest.post(`${baseUrl}/test-parsing`),
230 ).pipe(Effect.flatMap((r) => r.json))) as {
231 parsed: any;
232 error?: string;
233 };
234 expect(response.error).toBeUndefined();
235 // The parsed representation carries the interchange/transaction data.
236 expect(JSON.stringify(response.parsed)).toContain("850");
237 }),
238 { timeout: 60_000 },
239 );
240 });
241
242 describe("TestConversion", () => {
243 test.provider(
244 "converts parsed JSON back into an X12 document",
245 (_stack) =>
246 Effect.gen(function* () {
247 const response = (yield* send(
248 HttpClientRequest.post(`${baseUrl}/test-conversion`),
249 ).pipe(Effect.flatMap((r) => r.json))) as {
250 convertedLength: number;
251 startsWithIsa: boolean;
252 error?: string;
253 };
254 expect(response.error).toBeUndefined();
255 expect(response.convertedLength).toBeGreaterThan(0);
256 expect(response.startsWithIsa).toBe(true);
257 }),
258 { timeout: 60_000 },
259 );
260 });
261
262 describe("StartTransformerJob + GetTransformerJob", () => {
263 test.provider(
264 "runs a transformer job to completion",
265 () => runTransformerJob(),
266 { timeout: 120_000 },
267 );
268 });
269
270 describe("consumeTransformationEvents", () => {
271 test.provider(
272 "delivers the completed job's EventBridge event to the sink queue",
273 () =>
274 Effect.gen(function* () {
275 const response = yield* runTransformerJob();
276 const { QueueUrl } = yield* sqs.getQueueUrl({
277 QueueName: EVENTS_QUEUE,
278 });
279 const event = yield* sqs
280 .receiveMessage({
281 QueueUrl: QueueUrl!,
282 WaitTimeSeconds: 5,
283 MaxNumberOfMessages: 10,
284 })
285 .pipe(
286 Effect.flatMap((result) => {
287 const event = (result.Messages ?? [])
288 .map(
289 (message) =>
290 JSON.parse(message.Body ?? "{}") as {
291 detailType?: string;
292 transformerJobId?: string;
293 },
294 )
295 .find(
296 (body) =>
297 body.transformerJobId === response.transformerJobId,
298 );
299 return event
300 ? Effect.succeed(event)
301 : Effect.fail(
302 new TransformationEventNotObserved({
303 transformerJobId: response.transformerJobId,
304 }),
305 );
306 }),
307 Effect.retry({
308 while: (error) =>
309 error._tag === "TransformationEventNotObserved",
310 // Ten five-second long polls plus nine one-second delays.
311 schedule: Schedule.spaced("1 second"),
312 times: 9,
313 }),
314 );
315 expect(event.detailType).toBe("Transformation Completed");
316 }),
317 { timeout: 240_000 },
318 );
319 });
320 },
321);