1import * as AWS from "@/AWS";
2import type { PolicyStatement } from "@/AWS/IAM";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12
13// Deterministic bucket for EDI input/output. B2BI accesses the bucket as the
14// service principal, authorized by the bucket policy below.
15const stageSuffix = process.env.ALCHEMY_TEST_STAGE
16 ? `-${process.env.ALCHEMY_TEST_STAGE.toLowerCase().replaceAll("_", "-")}`
17 : "";
18export const BUCKET = `alchemy-test-b2bi-bindings${stageSuffix}`;
19const BUCKET_ARN = `arn:aws:s3:::${BUCKET}`;
20
21// Deterministic queue name so the test can look up the queue URL out-of-band
22// (sqs.getQueueUrl) and observe delivered transformation events.
23export const EVENTS_QUEUE = `alchemy-b2bi-bindings-events${stageSuffix}`;
24
25// A minimal, valid X12 850 (purchase order), version 4010 — checked in as a
26// constant fixture, never generated at test time.
27export const SAMPLE_850_EDI = [
28 "ISA*00* *00* *ZZ*SENDERID *ZZ*RECEIVERID *210101*1253*U*00401*000000001*0*T*>~",
29 "GS*PO*SENDERID*RECEIVERID*20210101*1253*1*X*004010~",
30 "ST*850*0001~",
31 "BEG*00*SA*XX-1234**20210101~",
32 "REF*DP*038~",
33 "PO1*1*10*EA*9.25*TE*CB*065322-117~",
34 "CTT*1~",
35 "SE*6*0001~",
36 "GE*1*1~",
37 "IEA*1*000000001~",
38 "",
39].join("\n");
40
41const b2biBucketPolicy: PolicyStatement[] = [
42 {
43 Effect: "Allow",
44 Principal: { Service: "b2bi.amazonaws.com" },
45 Action: [
46 "s3:GetObject",
47 "s3:GetObjectAttributes",
48 "s3:PutObject",
49 "s3:DeleteObject",
50 "s3:ListBucket",
51 "s3:GetBucketLocation",
52 "s3:AbortMultipartUpload",
53 ],
54 Resource: [BUCKET_ARN, `${BUCKET_ARN}/*`],
55 },
56];
57
58export class B2biTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
59 "B2biTestFunction",
60) {}
61
62/**
63 * Shared infrastructure for the B2BI bindings fixture: the EDI S3 bucket,
64 * an ACTIVE inbound X12 850 transformer, and an SQS sink queue where the
65 * transformation-event consume loop forwards events so the test can observe
66 * them out-of-band.
67 */
68export class B2biFixtures extends Context.Service<
69 B2biFixtures,
70 {
71 bucket: AWS.S3.Bucket;
72 transformer: AWS.B2BI.Transformer;
73 eventsQueue: AWS.SQS.Queue;
74 }
75>()("B2biFixtures") {}
76
77export const B2biFixturesLive = Layer.effect(
78 B2biFixtures,
79 Effect.gen(function* () {
80 const bucket = yield* AWS.S3.Bucket("B2biData", {
81 bucketName: BUCKET,
82 forceDestroy: true,
83 policy: b2biBucketPolicy,
84 });
85 const transformer = yield* AWS.B2BI.Transformer("BindingsTransformer", {
86 name: `alchemy-b2bi-bindings-transformer${stageSuffix}`,
87 status: "active",
88 inputConversion: {
89 fromFormat: "X12",
90 formatOptions: {
91 x12: { transactionSet: "X12_850", version: "VERSION_4010" },
92 },
93 },
94 mapping: {
95 templateLanguage: "JSONATA",
96 template: '{ "orderId": "test" }',
97 },
98 });
99 const eventsQueue = yield* AWS.SQS.Queue("B2biEventsSink", {
100 queueName: EVENTS_QUEUE,
101 });
102 return { bucket, transformer, eventsQueue };
103 }),
104);
105
106export default B2biTestFunction.make(
107 {
108 main: import.meta.url,
109 functionUrl: true,
110 timeout: Duration.minutes(2),
111 },
112 Effect.gen(function* () {
113 const { bucket, transformer, eventsQueue } = yield* B2biFixtures;
114 const BucketName = yield* bucket.bucketName;
115
116 // --- data-plane bindings under test ---
117 const putObject = yield* AWS.S3.PutObject(bucket);
118 const startTransformerJob =
119 yield* AWS.B2BI.StartTransformerJob(transformer);
120 const getTransformerJob = yield* AWS.B2BI.GetTransformerJob(transformer);
121 const testMapping = yield* AWS.B2BI.TestMapping();
122 const testParsing = yield* AWS.B2BI.TestParsing();
123 const testConversion = yield* AWS.B2BI.TestConversion();
124 const generateMapping = yield* AWS.B2BI.GenerateMapping();
125 const createStarterMappingTemplate =
126 yield* AWS.B2BI.CreateStarterMappingTemplate();
127 const eventsSink = yield* AWS.SQS.QueueSink(eventsQueue);
128
129 // Event source under test: forward every B2BI transformation event to
130 // the sink queue where the test observes it out-of-band.
131 yield* AWS.B2BI.consumeTransformationEvents({}, (events) =>
132 events.pipe(
133 Stream.map((event) => ({
134 MessageBody: JSON.stringify({
135 detailType: event["detail-type"],
136 transformerJobId: event.detail["transformer-job-id"] ?? null,
137 output: event.detail["output-file-s3-attributes"] ?? null,
138 }),
139 })),
140 Stream.run(eventsSink),
141 Effect.orDie,
142 ),
143 );
144
145 const bound = {
146 startTransformerJob,
147 getTransformerJob,
148 testMapping,
149 testParsing,
150 testConversion,
151 generateMapping,
152 createStarterMappingTemplate,
153 putObject,
154 };
155
156 const uploadSampleEdi = (key: string) =>
157 putObject({
158 Key: key,
159 Body: SAMPLE_850_EDI,
160 ContentType: "text/plain",
161 });
162
163 const parseSampleEdi = (key: string) =>
164 uploadSampleEdi(key).pipe(
165 Effect.andThen(BucketName),
166 Effect.flatMap((bucketName) =>
167 testParsing({
168 inputFile: { bucketName, key },
169 fileFormat: "JSON",
170 ediType: {
171 x12Details: {
172 transactionSet: "X12_850",
173 version: "VERSION_4010",
174 },
175 },
176 }),
177 ),
178 );
179
180 return {
181 fetch: Effect.gen(function* () {
182 const request = yield* HttpServerRequest;
183 const url = new URL(request.originalUrl);
184 const pathname = url.pathname;
185
186 if (request.method === "GET" && pathname === "/bindings") {
187 return yield* HttpServerResponse.json({
188 bound: Object.keys(bound),
189 });
190 }
191
192 if (request.method === "POST" && pathname === "/test-mapping") {
193 const result = yield* testMapping({
194 inputFileContent: JSON.stringify({ customer: "acme" }),
195 mappingTemplate: '{ "name": customer }',
196 fileFormat: "JSON",
197 });
198 return yield* HttpServerResponse.json({
199 mappedFileContent: result.mappedFileContent,
200 });
201 }
202
203 if (request.method === "POST" && pathname === "/starter-template") {
204 const result = yield* createStarterMappingTemplate({
205 mappingType: "JSONATA",
206 templateDetails: {
207 x12: { transactionSet: "X12_850", version: "VERSION_4010" },
208 },
209 });
210 return yield* HttpServerResponse.json({
211 templateLength: result.mappingTemplate.length,
212 });
213 }
214
215 if (request.method === "POST" && pathname === "/generate-mapping") {
216 // GenerateMapping invokes Bedrock with the caller's session and
217 // requires Bedrock MODEL ACCESS to be enabled (an account/region
218 // entitlement). In the testing account the call succeeds on some
219 // runs and is denied on others (cross-region model routing), so
220 // the typed Bedrock-model-access rejection is reported explicitly
221 // rather than treated as a binding failure.
222 const result = yield* generateMapping({
223 inputFileContent: JSON.stringify({
224 customer: "acme",
225 city: "portland",
226 }),
227 outputFileContent: JSON.stringify({ name: "acme" }),
228 mappingType: "JSONATA",
229 }).pipe(
230 Effect.map((r) => ({
231 templateLength: r.mappingTemplate.length,
232 accuracy: r.mappingAccuracy ?? null,
233 bedrockAccessDenied: false,
234 })),
235 Effect.catchTag("AccessDeniedException", (e) =>
236 String(e.message).includes("Bedrock")
237 ? Effect.succeed({
238 templateLength: 0,
239 accuracy: null,
240 bedrockAccessDenied: true,
241 })
242 : Effect.fail(e),
243 ),
244 );
245 return yield* HttpServerResponse.json(result);
246 }
247
248 if (request.method === "POST" && pathname === "/test-parsing") {
249 const result = yield* parseSampleEdi("test-parsing/sample-850.edi");
250 return yield* HttpServerResponse.json({
251 parsed: JSON.parse(result.parsedFileContent),
252 validationMessages: result.validationMessages ?? [],
253 });
254 }
255
256 if (request.method === "POST" && pathname === "/test-conversion") {
257 // Round-trip: parse the sample 850 into B2BI's JSON representation,
258 // then convert that JSON back into an X12 document.
259 const parsed = yield* parseSampleEdi(
260 "test-conversion/sample-850.edi",
261 );
262 const result = yield* testConversion({
263 source: {
264 fileFormat: "JSON",
265 inputFile: { fileContent: parsed.parsedFileContent },
266 },
267 target: {
268 fileFormat: "X12",
269 formatDetails: {
270 x12: { transactionSet: "X12_850", version: "VERSION_4010" },
271 },
272 },
273 });
274 return yield* HttpServerResponse.json({
275 convertedLength: result.convertedFileContent.length,
276 startsWithIsa: result.convertedFileContent.startsWith("ISA"),
277 validationMessages: result.validationMessages ?? [],
278 });
279 }
280
281 if (request.method === "POST" && pathname === "/transformer-job") {
282 const bucketName = yield* BucketName;
283 yield* uploadSampleEdi("job-input/sample-850.edi");
284 const started = yield* startTransformerJob({
285 inputFile: { bucketName, key: "job-input/sample-850.edi" },
286 outputLocation: { bucketName, key: "job-output/" },
287 });
288 // A freshly started job can briefly 404; retry the typed tag, then
289 // poll (bounded) until the job leaves `running`.
290 const job = yield* getTransformerJob({
291 transformerJobId: started.transformerJobId,
292 }).pipe(
293 Effect.retry({
294 while: (e) => e._tag === "ResourceNotFoundException",
295 schedule: Schedule.max([
296 Schedule.fixed("2 seconds"),
297 Schedule.recurs(5),
298 ]),
299 }),
300 Effect.repeat({
301 schedule: Schedule.spaced("2 seconds"),
302 until: (j): boolean => j.status !== "running",
303 times: 25,
304 }),
305 );
306 return yield* HttpServerResponse.json({
307 transformerJobId: started.transformerJobId,
308 status: job.status,
309 message: job.message ?? null,
310 outputFiles: job.outputFiles ?? [],
311 });
312 }
313
314 return yield* HttpServerResponse.json(
315 { error: "Not found", method: request.method, pathname },
316 { status: 404 },
317 );
318 }).pipe(
319 // Surface every failure (typed error or defect) to the test as JSON
320 // instead of an opaque 500 — the test asserts `error` is absent.
321 Effect.catchCause((cause) =>
322 HttpServerResponse.json({ error: Cause.pretty(cause) }),
323 ),
324 ),
325 };
326 }).pipe(
327 Effect.provide(
328 Layer.provideMerge(
329 Layer.mergeAll(
330 AWS.Lambda.EventSource,
331 AWS.S3.PutObjectHttp,
332 AWS.SQS.QueueSinkHttp,
333 AWS.B2BI.StartTransformerJobHttp,
334 AWS.B2BI.GetTransformerJobHttp,
335 AWS.B2BI.TestMappingHttp,
336 AWS.B2BI.TestParsingHttp,
337 AWS.B2BI.TestConversionHttp,
338 AWS.B2BI.GenerateMappingHttp,
339 AWS.B2BI.CreateStarterMappingTemplateHttp,
340 B2biFixturesLive,
341 ),
342 Layer.mergeAll(AWS.SQS.SendMessageBatchHttp),
343 ),
344 ),
345 ),
346);