1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { Region } from "@distilled.cloud/aws/Region";
6import * as sts from "@distilled.cloud/aws/sts";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import TranscribeTestFunctionLive, { TranscribeTestFunction } from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "TranscribeBindings");
18
19// Deterministic fixture names (account-scoped; this suite owns them in the
20// testing account). Never derived from Date.now().
21const FILTER_NAME = "alchemy-test-transcribe-filter";
22const CATEGORY_NAME = "alchemy-test-transcribe-category";
23const BOGUS = "alchemy-nonexistent-transcribe-probe";
24
25// Transcribe reports missing jobs as BadRequestException ("The requested job
26// couldn't be found.") rather than NotFoundException — a documented API
27// quirk. Missing vocabularies/models surface NotFoundException. Both tags
28// are typed; routes below assert the observed one (or either where AWS is
29// inconsistent across families).
30const NOT_FOUND_TAGS = ["NotFoundException", "BadRequestException"];
31
32let baseUrl: string;
33
34class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
35 readonly status: number;
36 readonly body: string;
37}> {}
38
39// The shared Lambda fixture occasionally answers a transient 5xx (cold
40// re-init that the handler's `Effect.orDie` surfaces as a 500). Retry only
41// 5xx; a genuine 4xx surfaces immediately.
42const send = (request: HttpClientRequest.HttpClientRequest) =>
43 HttpClient.execute(request).pipe(
44 Effect.flatMap((response) =>
45 response.status >= 500
46 ? response.text.pipe(
47 Effect.flatMap((body) =>
48 Effect.fail(
49 new TransientUpstream({ status: response.status, body }),
50 ),
51 ),
52 )
53 : Effect.succeed(response),
54 ),
55 Effect.retry({
56 while: (e) => e._tag === "TransientUpstream",
57 schedule: Schedule.max([
58 Schedule.exponential("1 second"),
59 Schedule.recurs(8),
60 ]),
61 }),
62 );
63
64// A `type` (not `interface`) so it carries an implicit index signature and is
65// comparable to the `JsonObject` member of `response.json`'s union.
66type RouteResult = {
67 ok: boolean;
68 error?: string;
69 count?: number;
70 status?: string;
71 state?: string;
72 name?: string;
73 rules?: number;
74 downloadUri?: string;
75};
76
77const getJson = (path: string) =>
78 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
79 Effect.flatMap((r) => r.json),
80 Effect.map((r) => r as RouteResult),
81 );
82
83const postJson = (path: string, body: object) =>
84 send(
85 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
86 HttpClientRequest.bodyJsonUnsafe(body),
87 ),
88 ).pipe(
89 Effect.flatMap((r) => r.json),
90 Effect.map((r) => r as RouteResult),
91 );
92
93// Freshly attached IAM policies (especially the iam:PassRole grants on the
94// data-access role) can take a few seconds to propagate; retry any route
95// that answers with the typed AccessDeniedException, bounded well under a
96// minute.
97const untilAuthorized = <E, R>(effect: Effect.Effect<RouteResult, E, R>) =>
98 effect.pipe(
99 Effect.repeat({
100 schedule: Schedule.spaced("3 seconds"),
101 until: (r): boolean => r.error !== "AccessDeniedException",
102 times: 10,
103 }),
104 );
105
106describe.sequential(
107 "Transcribe Bindings",
108 {
109 tags: [
110 "provider:aws",
111 "provider:aws:iam",
112 "provider:aws:lambda",
113 "provider:aws:transcribe",
114 "live",
115 ],
116 },
117 () => {
118 beforeAll(
119 Effect.gen(function* () {
120 yield* Effect.logInfo(
121 "Transcribe test setup: destroying previous resources",
122 );
123 yield* sharedStack.destroy();
124
125 yield* Effect.logInfo("Transcribe test setup: deploying fixture");
126 const { functionUrl } = yield* sharedStack.deploy(
127 Effect.gen(function* () {
128 return yield* TranscribeTestFunction;
129 }).pipe(Effect.provide(TranscribeTestFunctionLive)),
130 );
131
132 expect(functionUrl).toBeTruthy();
133 baseUrl = functionUrl!.replace(/\/+$/, "");
134
135 // Readiness probe — fresh function URLs take seconds (sometimes over a
136 // minute) to serve 200s.
137 yield* HttpClient.get(`${baseUrl}/jobs`).pipe(
138 Effect.flatMap((response) =>
139 response.status === 200
140 ? Effect.succeed(response)
141 : Effect.fail(
142 new Error(`Function not ready: ${response.status}`),
143 ),
144 ),
145 Effect.retry({
146 schedule: Schedule.max([
147 Schedule.fixed("2 seconds"),
148 Schedule.recurs(75),
149 ]),
150 }),
151 );
152 }),
153 { timeout: 300_000 },
154 );
155 afterAll(sharedStack.destroy(), { timeout: 300_000 });
156
157 describe("List bindings", () => {
158 test.provider(
159 "all nine list bindings answer from the runtime",
160 () =>
161 Effect.gen(function* () {
162 for (const path of [
163 "/jobs",
164 "/callJobs",
165 "/medicalJobs",
166 "/vocabularies",
167 "/medicalVocabularies",
168 "/filters",
169 "/categories",
170 "/languageModels",
171 ]) {
172 const body = yield* getJson(path);
173 expect(body.error, path).toBeUndefined();
174 expect(typeof body.count, path).toBe("number");
175 }
176
177 // AWS HealthScribe is not available in every region (e.g.
178 // us-west-2): unavailable regions answer with a typed
179 // BadRequestException ("Your account isn't authorized to call this
180 // operation"). Either a count or that typed tag proves the binding
181 // + grant end-to-end — an IAM gap would be AccessDeniedException.
182 const scribe = yield* getJson("/scribeJobs");
183 expect(
184 scribe.error === undefined ||
185 scribe.error === "BadRequestException",
186 `/scribeJobs: ${scribe.error}`,
187 ).toBe(true);
188 }),
189 { timeout: 120_000 },
190 );
191 });
192
193 describe("Get bindings (typed not-found tags)", () => {
194 test.provider(
195 "reading nonexistent jobs/vocabularies/models surfaces typed tags",
196 () =>
197 Effect.gen(function* () {
198 // A typed not-found tag (never AccessDenied, never a 500) proves
199 // the grant and the typed error mapping end-to-end per binding.
200 const job = yield* getJson(`/job?name=${BOGUS}`);
201 expect(job.error).toBe("BadRequestException");
202
203 for (const path of [
204 "/callJob",
205 "/medicalJob",
206 "/scribeJob",
207 "/vocabulary",
208 "/medicalVocabulary",
209 "/languageModel",
210 ]) {
211 const body = yield* getJson(`${path}?name=${BOGUS}`);
212 expect(NOT_FOUND_TAGS, path).toContain(body.error);
213 }
214 }),
215 { timeout: 120_000 },
216 );
217 });
218
219 describe("Delete bindings", () => {
220 test.provider(
221 "deleting nonexistent resources is idempotent or surfaces typed tags",
222 () =>
223 Effect.gen(function* () {
224 // Transcribe's delete APIs are inconsistent across families: some
225 // succeed idempotently for a missing resource, others return a
226 // typed not-found tag. Either proves the grant + wire-up; an IAM
227 // gap would surface AccessDeniedException instead.
228 for (const path of [
229 "/job/delete",
230 "/callJob/delete",
231 "/medicalJob/delete",
232 "/scribeJob/delete",
233 "/vocabulary/delete",
234 "/medicalVocabulary/delete",
235 "/languageModel/delete",
236 ]) {
237 const body = yield* postJson(path, { name: BOGUS });
238 expect(
239 body.error === undefined || NOT_FOUND_TAGS.includes(body.error),
240 `${path}: ${body.error}`,
241 ).toBe(true);
242 }
243 }),
244 { timeout: 120_000 },
245 );
246 });
247
248 describe("Start/Create bindings (typed validation, nothing created)", () => {
249 test.provider(
250 "invalid S3 URIs surface typed BadRequestException per family",
251 () =>
252 Effect.gen(function* () {
253 // The invalid `Media`/`VocabularyFileUri` fails Transcribe's
254 // server-side pattern validation: the request is authorized (so an
255 // IAM gap would surface AccessDenied instead) but nothing is
256 // created and nothing is billed. The role-bound bindings
257 // (startCall, startScribe, languageModel/create) additionally
258 // prove the iam:PassRole wiring.
259 for (const path of [
260 "/startJob",
261 "/startCall",
262 "/startMedical",
263 "/startScribe",
264 "/vocabulary/create",
265 "/medicalVocabulary/create",
266 "/languageModel/create",
267 ]) {
268 const body = yield* untilAuthorized(
269 postJson(path, { name: BOGUS }),
270 );
271 expect(body.ok, path).toBe(false);
272 expect(body.error, path).toBe("BadRequestException");
273 }
274
275 // Updating a nonexistent vocabulary surfaces its typed tag.
276 for (const path of [
277 "/vocabulary/update",
278 "/medicalVocabulary/update",
279 ]) {
280 const body = yield* postJson(path, { name: BOGUS });
281 expect(NOT_FOUND_TAGS, path).toContain(body.error);
282 }
283 }),
284 { timeout: 180_000 },
285 );
286 });
287
288 describe("VocabularyFilter lifecycle + tagging", () => {
289 test.provider(
290 "create, get, update, tag, untag, and delete a vocabulary filter",
291 () =>
292 Effect.gen(function* () {
293 // Cleanup leftovers from a crashed prior run (typed tag ignored).
294 yield* postJson("/filter/delete", { name: FILTER_NAME });
295
296 const created = yield* postJson("/filter/create", {
297 name: FILTER_NAME,
298 });
299 expect(created.error).toBeUndefined();
300 expect(created.name).toBe(FILTER_NAME);
301
302 const got = yield* getJson(`/filter?name=${FILTER_NAME}`);
303 expect(got.error).toBeUndefined();
304 expect(got.name).toBe(FILTER_NAME);
305 expect(got.downloadUri).toBeTruthy();
306
307 const updated = yield* postJson("/filter/update", {
308 name: FILTER_NAME,
309 });
310 expect(updated.error).toBeUndefined();
311
312 // Tag ops address Transcribe resources by ARN.
313 const region = yield* yield* Region;
314 const identity = yield* sts.getCallerIdentity({});
315 const arn = `arn:aws:transcribe:${region}:${identity.Account}:vocabulary-filter/${FILTER_NAME}`;
316
317 const tagged = yield* postJson("/tag", { arn });
318 expect(tagged.error).toBeUndefined();
319
320 const tags = yield* getJson(`/tags?arn=${encodeURIComponent(arn)}`);
321 expect(tags.error).toBeUndefined();
322 expect(tags.count).toBeGreaterThanOrEqual(1);
323
324 const untagged = yield* postJson("/untag", { arn });
325 expect(untagged.error).toBeUndefined();
326
327 const deleted = yield* postJson("/filter/delete", {
328 name: FILTER_NAME,
329 });
330 expect(deleted.error).toBeUndefined();
331
332 // Wait until it is really gone (typed tag on the get).
333 const gone = yield* getJson(`/filter?name=${FILTER_NAME}`).pipe(
334 Effect.repeat({
335 schedule: Schedule.spaced("2 seconds"),
336 until: (r): boolean => r.error !== undefined,
337 times: 10,
338 }),
339 );
340 expect(NOT_FOUND_TAGS).toContain(gone.error);
341 }),
342 { timeout: 120_000 },
343 );
344 });
345
346 describe("CallAnalyticsCategory lifecycle", () => {
347 test.provider(
348 "create, get, update, and delete a call analytics category",
349 () =>
350 Effect.gen(function* () {
351 // Cleanup leftovers from a crashed prior run (typed tag ignored).
352 yield* postJson("/category/delete", { name: CATEGORY_NAME });
353
354 const created = yield* postJson("/category/create", {
355 name: CATEGORY_NAME,
356 });
357 expect(created.error).toBeUndefined();
358 expect(created.name).toBe(CATEGORY_NAME);
359
360 const got = yield* getJson(`/category?name=${CATEGORY_NAME}`);
361 expect(got.error).toBeUndefined();
362 expect(got.rules).toBe(1);
363
364 const updated = yield* postJson("/category/update", {
365 name: CATEGORY_NAME,
366 });
367 expect(updated.error).toBeUndefined();
368
369 const deleted = yield* postJson("/category/delete", {
370 name: CATEGORY_NAME,
371 });
372 expect(deleted.error).toBeUndefined();
373
374 const gone = yield* getJson(`/category?name=${CATEGORY_NAME}`).pipe(
375 Effect.repeat({
376 schedule: Schedule.spaced("2 seconds"),
377 until: (r): boolean => r.error !== undefined,
378 times: 10,
379 }),
380 );
381 expect(NOT_FOUND_TAGS).toContain(gone.error);
382 }),
383 { timeout: 120_000 },
384 );
385 });
386
387 describe(
388 "consumeTranscriptionJobEvents",
389 { tags: ["provider:aws:eventbridge"] },
390 () => {
391 test.provider(
392 "created the EventBridge rule for Transcribe job state changes",
393 () =>
394 Effect.gen(function* () {
395 // The rule's physical name embeds the event-source id
396 // (`TranscribeJobEvents`); find it on the default bus with bounded
397 // manual pagination.
398 let rule: eventbridge.Rule | undefined;
399 let nextToken: string | undefined;
400 for (let page = 0; page < 10 && !rule; page++) {
401 const result = yield* eventbridge.listRules({
402 NextToken: nextToken,
403 });
404 rule = (result.Rules ?? []).find((candidate) =>
405 candidate.Name?.includes("TranscribeJobEvents"),
406 );
407 nextToken = result.NextToken;
408 if (!nextToken) break;
409 }
410 expect(rule).toBeDefined();
411 expect(rule?.EventPattern).toContain("aws.transcribe");
412 expect(rule?.EventPattern).toContain(
413 "Transcribe Job State Change",
414 );
415 }),
416 { timeout: 60_000 },
417 );
418 },
419 );
420 },
421);