1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as sts from "@distilled.cloud/aws/sts";
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 BdaTestFunctionLive, { BdaTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "BdaBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy under parallel-suite load.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let profileArn: string | undefined;
26
27// The cross-region data automation profile ARN is account + geography
28// scoped. Credentials are only provided inside `test.provider` contexts, so
29// this is computed lazily by the first test that needs it.
30const getProfileArn = Effect.gen(function* () {
31 if (profileArn !== undefined) return profileArn;
32 const { Account } = yield* sts.getCallerIdentity({});
33 const region = yield* Effect.sync(
34 () => process.env.AWS_REGION ?? "us-west-2",
35 );
36 const geo = region.startsWith("eu-")
37 ? "eu"
38 : region.startsWith("ap-")
39 ? "apac"
40 : "us";
41 profileArn = `arn:aws:bedrock:${region}:${Account}:data-automation-profile/${geo}.data-automation-v1`;
42 return profileArn;
43});
44
45class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
46 readonly status: number;
47 readonly body: string;
48}> {}
49
50// The shared Lambda fixture occasionally answers a transient 5xx under load
51// (cold re-init, IAM propagation on the freshly attached policies that the
52// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
53// 4xx/assertion failure surfaces immediately.
54const send = (request: HttpClientRequest.HttpClientRequest) =>
55 HttpClient.execute(request).pipe(
56 Effect.flatMap((response) =>
57 response.status >= 500
58 ? response.text.pipe(
59 Effect.flatMap((body) =>
60 Effect.fail(
61 new TransientUpstream({ status: response.status, body }),
62 ),
63 ),
64 )
65 : Effect.succeed(response),
66 ),
67 Effect.retry({
68 while: (e) => e._tag === "TransientUpstream",
69 schedule: Schedule.max([
70 Schedule.exponential("500 millis"),
71 Schedule.recurs(6),
72 ]),
73 }),
74 );
75
76describe.sequential(
77 "BedrockDataAutomation Bindings",
78 {
79 tags: [
80 "provider:aws",
81 "provider:aws:bedrockdataautomation",
82 "provider:aws:lambda",
83 "provider:aws:s3",
84 "live",
85 ],
86 },
87 () => {
88 beforeAll(
89 Effect.gen(function* () {
90 yield* Effect.logInfo("BDA test setup: destroying previous resources");
91 yield* sharedStack.destroy();
92
93 yield* Effect.logInfo("BDA test setup: deploying fixture");
94 const { functionUrl } = yield* sharedStack.deploy(
95 Effect.gen(function* () {
96 return yield* BdaTestFunction;
97 }).pipe(Effect.provide(BdaTestFunctionLive)),
98 );
99
100 expect(functionUrl).toBeTruthy();
101 baseUrl = functionUrl!.replace(/\/+$/, "");
102
103 const readinessUrl = `${baseUrl}/bindings`;
104 yield* Effect.logInfo(
105 `BDA test setup: probing readiness at ${readinessUrl}`,
106 );
107 yield* HttpClient.get(readinessUrl).pipe(
108 Effect.flatMap((response) =>
109 response.status === 200
110 ? Effect.succeed(response)
111 : Effect.fail(
112 new Error(`Function not ready: ${response.status}`),
113 ),
114 ),
115 Effect.tapError((error) =>
116 Effect.logWarning(
117 `BDA test setup: fixture not ready yet (${String(error)})`,
118 ),
119 ),
120 Effect.retry({ schedule: readinessPolicy }),
121 );
122 }),
123 { timeout: 300_000 },
124 );
125
126 afterAll(sharedStack.destroy(), { timeout: 180_000 });
127
128 describe("binding registration", () => {
129 test.provider("all 14 capabilities initialize in the runtime", (_stack) =>
130 Effect.gen(function* () {
131 const response = yield* send(
132 HttpClientRequest.get(`${baseUrl}/bindings`),
133 ).pipe(Effect.flatMap((r) => r.json));
134 expect((response as any).bound).toHaveLength(14);
135 }),
136 );
137 });
138
139 describe("InvokeDataAutomationAsync + GetDataAutomationStatus", () => {
140 test.provider(
141 "submits an async job against the bound project and polls its status",
142 (_stack) =>
143 Effect.gen(function* () {
144 const arn = yield* getProfileArn;
145 const invoked = (yield* send(
146 HttpClientRequest.post(
147 `${baseUrl}/invoke-async?profileArn=${encodeURIComponent(arn)}`,
148 ),
149 ).pipe(Effect.flatMap((r) => r.json))) as {
150 invocationArn?: string;
151 error?: string;
152 message?: string;
153 };
154 // Proves project injection + bedrock:InvokeDataAutomationAsync +
155 // the caller-permission S3 grants end-to-end. On failure the
156 // fixture reports the typed tag + message here.
157 expect(
158 invoked.error === undefined
159 ? "none"
160 : `${invoked.error}: ${invoked.message}`,
161 ).toBe("none");
162 const invocationArn = invoked.invocationArn;
163 expect(invocationArn).toContain(":data-automation-invocation/");
164 if (invocationArn === undefined) {
165 // Unreachable — the expect above throws on undefined; this
166 // narrows the type for the status poll below.
167 return yield* Effect.die(new Error("invocationArn missing"));
168 }
169
170 // Immediate status poll — the job settles asynchronously, so any
171 // lifecycle status proves bedrock:GetDataAutomationStatus.
172 const status = (yield* send(
173 HttpClientRequest.get(
174 `${baseUrl}/status?invocationArn=${encodeURIComponent(invocationArn)}`,
175 ),
176 ).pipe(Effect.flatMap((r) => r.json))) as { status: string };
177 expect([
178 "Created",
179 "InProgress",
180 "Success",
181 "ServiceError",
182 "ClientError",
183 ]).toContain(status.status);
184 }),
185 { timeout: 120_000 },
186 );
187 });
188
189 describe("Library ingestion jobs + entities", () => {
190 test.provider(
191 "starts an inline ingestion job, polls it, and lists jobs + entities",
192 (_stack) =>
193 Effect.gen(function* () {
194 // InvokeDataAutomationLibraryIngestionJob — inline vocabulary
195 // upsert against the bound library.
196 const ingested = (yield* send(
197 HttpClientRequest.post(`${baseUrl}/library-ingest`),
198 ).pipe(Effect.flatMap((r) => r.json))) as {
199 jobArn?: string;
200 error?: string;
201 message?: string;
202 };
203 expect(
204 ingested.error === undefined
205 ? "none"
206 : `${ingested.error}: ${ingested.message}`,
207 ).toBe("none");
208 expect(ingested.jobArn).toBeTruthy();
209
210 // GetDataAutomationLibraryIngestionJob — any lifecycle status
211 // proves the grant + library injection.
212 const job = (yield* send(
213 HttpClientRequest.get(
214 `${baseUrl}/library-ingestion-job?jobArn=${encodeURIComponent(ingested.jobArn!)}`,
215 ),
216 ).pipe(Effect.flatMap((r) => r.json))) as { status?: string };
217 expect([
218 "IN_PROGRESS",
219 "COMPLETED",
220 "COMPLETED_WITH_ERRORS",
221 "FAILED",
222 ]).toContain(job.status);
223
224 // ListDataAutomationLibraryIngestionJobs — the job we just
225 // started must be visible.
226 const jobs = (yield* send(
227 HttpClientRequest.get(`${baseUrl}/library-ingestion-jobs`),
228 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
229 expect(jobs.count).toBeGreaterThanOrEqual(1);
230
231 // ListDataAutomationLibraryEntities — entity visibility lags the
232 // async job, so only the successful (authorized) empty-or-more
233 // page is asserted.
234 const entities = (yield* send(
235 HttpClientRequest.get(`${baseUrl}/library-entities`),
236 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
237 expect(entities.count).toBeGreaterThanOrEqual(0);
238
239 // GetDataAutomationLibraryEntity — typed not-found path proves
240 // the grant (an IAM gap would surface AccessDeniedException).
241 const missing = (yield* send(
242 HttpClientRequest.get(`${baseUrl}/library-entity-missing`),
243 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
244 expect([
245 "ResourceNotFoundException",
246 "ValidationException",
247 ]).toContain(missing.tag);
248 }),
249 { timeout: 120_000 },
250 );
251 });
252
253 describe("Blueprint management", () => {
254 test.provider(
255 "creates a blueprint version and drives copy-stage's typed error path",
256 (_stack) =>
257 Effect.gen(function* () {
258 // CreateBlueprintVersion — snapshots the fixture blueprint.
259 const version = (yield* send(
260 HttpClientRequest.post(`${baseUrl}/blueprint-version`),
261 ).pipe(Effect.flatMap((r) => r.json))) as {
262 version?: string;
263 error?: string;
264 message?: string;
265 };
266 expect(
267 version.error === undefined
268 ? "none"
269 : `${version.error}: ${version.message}`,
270 ).toBe("none");
271 expect(version.version).toBeTruthy();
272
273 // CopyBlueprintStage — the fixture blueprint has no DEVELOPMENT
274 // stage, so the typed error tag proves the grant + injection
275 // without creating a stage copy.
276 const copied = (yield* send(
277 HttpClientRequest.post(`${baseUrl}/copy-stage-validation`),
278 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
279 expect([
280 "ResourceNotFoundException",
281 "ValidationException",
282 ]).toContain(copied.tag);
283 }),
284 { timeout: 120_000 },
285 );
286 });
287
288 describe("Blueprint optimization", () => {
289 test.provider(
290 "drives the typed error paths of optimize + status polling",
291 (_stack) =>
292 Effect.gen(function* () {
293 // InvokeBlueprintOptimizationAsync — an empty samples list is
294 // invalid, so the typed ValidationException proves the grant +
295 // blueprint injection without paying for a real optimization job.
296 const arn = yield* getProfileArn;
297 const optimize = (yield* send(
298 HttpClientRequest.post(
299 `${baseUrl}/optimize-validation?profileArn=${encodeURIComponent(arn)}`,
300 ),
301 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
302 expect(optimize.tag).toBe("ValidationException");
303
304 // GetBlueprintOptimizationStatus — typed not-found on a
305 // well-formed but nonexistent invocation ARN.
306 const { Account } = yield* sts.getCallerIdentity({});
307 const region = yield* Effect.sync(
308 () => process.env.AWS_REGION ?? "us-west-2",
309 );
310 const missingArn = `arn:aws:bedrock:${region}:${Account}:blueprint-optimization-invocation/00000000-0000-0000-0000-000000000000`;
311 const status = (yield* send(
312 HttpClientRequest.get(
313 `${baseUrl}/optimization-status-missing?invocationArn=${encodeURIComponent(missingArn)}`,
314 ),
315 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
316 expect([
317 "ResourceNotFoundException",
318 "ValidationException",
319 ]).toContain(status.tag);
320 }),
321 { timeout: 120_000 },
322 );
323 });
324
325 describe("InvokeDataAutomation", () => {
326 // The sync API needs a SYNC project and a non-empty input; the fixture
327 // drives the binding through its typed ValidationException path — an IAM
328 // gap would surface AccessDeniedException (a 500) instead of the tag.
329 test.provider(
330 "surfaces the typed ValidationException for an empty input",
331 (_stack) =>
332 Effect.gen(function* () {
333 const arn = yield* getProfileArn;
334 const response = (yield* send(
335 HttpClientRequest.post(
336 `${baseUrl}/invoke-sync-validation?profileArn=${encodeURIComponent(arn)}`,
337 ),
338 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
339 expect(response.tag).toBe("ValidationException");
340 }),
341 { timeout: 120_000 },
342 );
343 });
344 },
345);