1import * as AWS from "@/AWS";
2import { Capability, Transformer } from "@/AWS/B2BI";
3import type { PolicyStatement } from "@/AWS/IAM";
4import { Bucket } from "@/AWS/S3";
5import * as Test from "@/Test/Alchemy";
6import * as b2bi from "@distilled.cloud/aws/b2bi";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Deterministic bucket for the capability's EDI input/output locations.
14// B2BI accesses the bucket as the service principal, authorized by policy.
15const BUCKET = "alchemy-test-b2bi-capability";
16const BUCKET_ARN = `arn:aws:s3:::${BUCKET}`;
17
18const b2biBucketPolicy: PolicyStatement[] = [
19 {
20 Effect: "Allow",
21 Principal: { Service: "b2bi.amazonaws.com" },
22 Action: [
23 "s3:GetObject",
24 "s3:PutObject",
25 "s3:DeleteObject",
26 "s3:ListBucket",
27 "s3:GetBucketLocation",
28 "s3:AbortMultipartUpload",
29 ],
30 Resource: [BUCKET_ARN, `${BUCKET_ARN}/*`],
31 },
32];
33
34const capabilityProgram = (direction: "INBOUND" | "OUTBOUND") =>
35 Effect.gen(function* () {
36 const bucket = yield* Bucket("CapabilityBucket", {
37 bucketName: BUCKET,
38 forceDestroy: true,
39 policy: b2biBucketPolicy,
40 });
41 const transformer = yield* Transformer("OrdersTransformer", {
42 name: "alchemy-b2bi-cap-transformer",
43 status: "active",
44 inputConversion: {
45 fromFormat: "X12",
46 formatOptions: {
47 x12: { transactionSet: "X12_850", version: "VERSION_4010" },
48 },
49 },
50 mapping: {
51 templateLanguage: "JSONATA",
52 template: '{ "orderId": "test" }',
53 },
54 });
55 return yield* Capability("Orders", {
56 name: "alchemy-b2bi-capability",
57 configuration: {
58 edi: {
59 capabilityDirection: direction,
60 type: {
61 x12Details: {
62 transactionSet: "X12_850",
63 version: "VERSION_4010",
64 },
65 },
66 inputLocation: { bucketName: bucket.bucketName, key: "inbound/" },
67 outputLocation: {
68 bucketName: bucket.bucketName,
69 key: "processed/",
70 },
71 transformerId: transformer.transformerId,
72 },
73 },
74 });
75 });
76
77const assertCapabilityGone = (capabilityId: string) =>
78 Effect.gen(function* () {
79 const result = yield* b2bi.getCapability({ capabilityId }).pipe(
80 Effect.map(() => "present" as const),
81 Effect.catchTag("ResourceNotFoundException", () =>
82 Effect.succeed("gone" as const),
83 ),
84 );
85 if (result === "present") {
86 return yield* Effect.fail(
87 new Error(`Capability '${capabilityId}' still exists`),
88 );
89 }
90 }).pipe(
91 Effect.retry({
92 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(8)]),
93 }),
94 );
95
96// Capabilities are credential-free (bucket + transformer are self-service),
97// so the full lifecycle runs ungated.
98test.provider(
99 "create, update, and destroy a B2BI capability",
100 (stack) =>
101 Effect.gen(function* () {
102 yield* stack.destroy();
103
104 // Create an inbound EDI capability.
105 const created = yield* stack.deploy(capabilityProgram("INBOUND"));
106 expect(created.capabilityId).toMatch(/^ca-/);
107 expect(created.name).toBe("alchemy-b2bi-capability");
108 expect(created.type).toBe("edi");
109
110 // Out-of-band verification.
111 const described = yield* b2bi.getCapability({
112 capabilityId: created.capabilityId,
113 });
114 expect(described.configuration.edi?.capabilityDirection).toBe("INBOUND");
115 expect(described.configuration.edi?.inputLocation.key).toBe("inbound/");
116
117 // No-op redeploy keeps the same id.
118 const noop = yield* stack.deploy(capabilityProgram("INBOUND"));
119 expect(noop.capabilityId).toBe(created.capabilityId);
120
121 // Destroy and verify (capability, transformer, bucket all removed).
122 yield* stack.destroy();
123 yield* assertCapabilityGone(created.capabilityId);
124 }),
125 {
126 tags: ["provider:aws", "provider:aws:b2bi", "provider:aws:s3", "live"],
127 timeout: 150_000,
128 },
129);