1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as Transcribe from "@/AWS/Transcribe";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14// An S3 URI that fails Transcribe's server-side pattern validation — drives
15// every Start*/Create* binding through its typed BadRequestException path
16// without actually starting (and paying for) a job or leaving anything
17// behind to clean up.
18const INVALID_S3_URI = "invalid-uri";
19
20export class TranscribeTestFunction extends Lambda.Function<Lambda.Function>()(
21 "TranscribeTestFunction",
22) {}
23
24export default TranscribeTestFunction.make(
25 {
26 main,
27 functionUrl: true,
28 // Transcribe control-plane calls routinely take a couple of seconds;
29 // AWS's default 3s Lambda timeout is too tight under cold starts.
30 timeout: Duration.seconds(30),
31 },
32 Effect.gen(function* () {
33 // The role Amazon Transcribe assumes to read S3 media for the role-bound
34 // bindings (StartCallAnalyticsJob, StartMedicalScribeJob,
35 // CreateLanguageModel). Requests here never get past validation, so the
36 // role needs no real S3 grants — only the transcribe.amazonaws.com trust.
37 const dataAccessRole = yield* IAM.Role("TranscribeDataAccessRole", {
38 assumeRolePolicyDocument: {
39 Version: "2012-10-17",
40 Statement: [
41 {
42 Effect: "Allow",
43 Principal: { Service: "transcribe.amazonaws.com" },
44 Action: ["sts:AssumeRole"],
45 },
46 ],
47 },
48 });
49
50 // --- batch transcription jobs ---
51 const startJob = yield* Transcribe.StartTranscriptionJob();
52 const getJob = yield* Transcribe.GetTranscriptionJob();
53 const listJobs = yield* Transcribe.ListTranscriptionJobs();
54 const deleteJob = yield* Transcribe.DeleteTranscriptionJob();
55
56 // --- call analytics jobs ---
57 const startCallJob =
58 yield* Transcribe.StartCallAnalyticsJob(dataAccessRole);
59 const getCallJob = yield* Transcribe.GetCallAnalyticsJob();
60 const listCallJobs = yield* Transcribe.ListCallAnalyticsJobs();
61 const deleteCallJob = yield* Transcribe.DeleteCallAnalyticsJob();
62
63 // --- medical transcription jobs ---
64 const startMedicalJob = yield* Transcribe.StartMedicalTranscriptionJob();
65 const getMedicalJob = yield* Transcribe.GetMedicalTranscriptionJob();
66 const listMedicalJobs = yield* Transcribe.ListMedicalTranscriptionJobs();
67 const deleteMedicalJob = yield* Transcribe.DeleteMedicalTranscriptionJob();
68
69 // --- medical scribe jobs ---
70 const startScribeJob =
71 yield* Transcribe.StartMedicalScribeJob(dataAccessRole);
72 const getScribeJob = yield* Transcribe.GetMedicalScribeJob();
73 const listScribeJobs = yield* Transcribe.ListMedicalScribeJobs();
74 const deleteScribeJob = yield* Transcribe.DeleteMedicalScribeJob();
75
76 // --- custom vocabularies ---
77 const createVocabulary = yield* Transcribe.CreateVocabulary();
78 const getVocabulary = yield* Transcribe.GetVocabulary();
79 const updateVocabulary = yield* Transcribe.UpdateVocabulary();
80 const deleteVocabulary = yield* Transcribe.DeleteVocabulary();
81 const listVocabularies = yield* Transcribe.ListVocabularies();
82
83 // --- vocabulary filters ---
84 const createFilter = yield* Transcribe.CreateVocabularyFilter();
85 const getFilter = yield* Transcribe.GetVocabularyFilter();
86 const updateFilter = yield* Transcribe.UpdateVocabularyFilter();
87 const deleteFilter = yield* Transcribe.DeleteVocabularyFilter();
88 const listFilters = yield* Transcribe.ListVocabularyFilters();
89
90 // --- medical vocabularies ---
91 const createMedicalVocabulary = yield* Transcribe.CreateMedicalVocabulary();
92 const getMedicalVocabulary = yield* Transcribe.GetMedicalVocabulary();
93 const updateMedicalVocabulary = yield* Transcribe.UpdateMedicalVocabulary();
94 const deleteMedicalVocabulary = yield* Transcribe.DeleteMedicalVocabulary();
95 const listMedicalVocabularies = yield* Transcribe.ListMedicalVocabularies();
96
97 // --- call analytics categories ---
98 const createCategory = yield* Transcribe.CreateCallAnalyticsCategory();
99 const getCategory = yield* Transcribe.GetCallAnalyticsCategory();
100 const updateCategory = yield* Transcribe.UpdateCallAnalyticsCategory();
101 const deleteCategory = yield* Transcribe.DeleteCallAnalyticsCategory();
102 const listCategories = yield* Transcribe.ListCallAnalyticsCategories();
103
104 // --- custom language models ---
105 const createLanguageModel =
106 yield* Transcribe.CreateLanguageModel(dataAccessRole);
107 const describeLanguageModel = yield* Transcribe.DescribeLanguageModel();
108 const listLanguageModels = yield* Transcribe.ListLanguageModels();
109 const deleteLanguageModel = yield* Transcribe.DeleteLanguageModel();
110
111 // --- tagging ---
112 const tagResource = yield* Transcribe.TagResource();
113 const untagResource = yield* Transcribe.UntagResource();
114 const listTagsForResource = yield* Transcribe.ListTagsForResource();
115
116 // Deploy-time: creates the EventBridge rule (default bus, source
117 // aws.transcribe) targeting this Function. The test verifies the rule
118 // deploys; runtime firing would require a real transcription over S3
119 // media.
120 yield* Transcribe.consumeTranscriptionJobEvents(
121 { statuses: ["COMPLETED", "FAILED"] },
122 (events) =>
123 Stream.runForEach(events, (event) =>
124 Effect.log(
125 `transcribe job event: ${event.detail.TranscriptionJobName} -> ${event.detail.TranscriptionJobStatus}`,
126 ),
127 ),
128 );
129
130 // Normalize every binding call to { ok, error?, ...extra } so routes can
131 // assert typed tags without 500s.
132 const result = <A, E extends { _tag: string }>(
133 effect: Effect.Effect<A, E>,
134 extract?: (a: A) => Record<string, unknown>,
135 ) =>
136 effect.pipe(
137 Effect.map((a) => ({
138 ok: true,
139 error: undefined as string | undefined,
140 ...(extract ? extract(a) : {}),
141 })),
142 Effect.catch((e) =>
143 Effect.succeed({ ok: false, error: e._tag as string }),
144 ),
145 );
146
147 return {
148 fetch: Effect.gen(function* () {
149 const request = yield* HttpServerRequest;
150 const url = new URL(request.originalUrl);
151 const route = `${request.method} ${url.pathname}`;
152 const name = url.searchParams.get("name") ?? "";
153 const body =
154 request.method === "POST"
155 ? ((yield* request.json) as Record<string, string>)
156 : {};
157
158 const respond = (
159 effect: Effect.Effect<Record<string, unknown>, never>,
160 ) => effect.pipe(Effect.flatMap(HttpServerResponse.json));
161
162 switch (route) {
163 // --- lists (count proves grant + wire-up) ---
164 case "GET /jobs":
165 return yield* respond(
166 result(listJobs({ MaxResults: 5 }), (r) => ({
167 count: r.TranscriptionJobSummaries?.length ?? 0,
168 })),
169 );
170 case "GET /callJobs":
171 return yield* respond(
172 result(listCallJobs({ MaxResults: 5 }), (r) => ({
173 count: r.CallAnalyticsJobSummaries?.length ?? 0,
174 })),
175 );
176 case "GET /medicalJobs":
177 return yield* respond(
178 result(listMedicalJobs({ MaxResults: 5 }), (r) => ({
179 count: r.MedicalTranscriptionJobSummaries?.length ?? 0,
180 })),
181 );
182 case "GET /scribeJobs":
183 return yield* respond(
184 result(listScribeJobs({ MaxResults: 5 }), (r) => ({
185 count: r.MedicalScribeJobSummaries?.length ?? 0,
186 })),
187 );
188 case "GET /vocabularies":
189 return yield* respond(
190 result(listVocabularies({ MaxResults: 5 }), (r) => ({
191 count: r.Vocabularies?.length ?? 0,
192 })),
193 );
194 case "GET /medicalVocabularies":
195 return yield* respond(
196 result(listMedicalVocabularies({ MaxResults: 5 }), (r) => ({
197 count: r.Vocabularies?.length ?? 0,
198 })),
199 );
200 case "GET /filters":
201 return yield* respond(
202 result(listFilters({ MaxResults: 5 }), (r) => ({
203 count: r.VocabularyFilters?.length ?? 0,
204 })),
205 );
206 case "GET /categories":
207 return yield* respond(
208 result(listCategories({ MaxResults: 5 }), (r) => ({
209 count: r.Categories?.length ?? 0,
210 })),
211 );
212 case "GET /languageModels":
213 return yield* respond(
214 result(listLanguageModels({ MaxResults: 5 }), (r) => ({
215 count: r.Models?.length ?? 0,
216 })),
217 );
218
219 // --- reads (typed not-found tags for bogus names) ---
220 case "GET /job":
221 return yield* respond(
222 result(getJob({ TranscriptionJobName: name }), (r) => ({
223 status: r.TranscriptionJob?.TranscriptionJobStatus,
224 })),
225 );
226 case "GET /callJob":
227 return yield* respond(
228 result(getCallJob({ CallAnalyticsJobName: name }), (r) => ({
229 status: r.CallAnalyticsJob?.CallAnalyticsJobStatus,
230 })),
231 );
232 case "GET /medicalJob":
233 return yield* respond(
234 result(
235 getMedicalJob({ MedicalTranscriptionJobName: name }),
236 (r) => ({
237 status: r.MedicalTranscriptionJob?.TranscriptionJobStatus,
238 }),
239 ),
240 );
241 case "GET /scribeJob":
242 return yield* respond(
243 result(getScribeJob({ MedicalScribeJobName: name }), (r) => ({
244 status: r.MedicalScribeJob?.MedicalScribeJobStatus,
245 })),
246 );
247 case "GET /vocabulary":
248 return yield* respond(
249 result(getVocabulary({ VocabularyName: name }), (r) => ({
250 state: r.VocabularyState,
251 })),
252 );
253 case "GET /medicalVocabulary":
254 return yield* respond(
255 result(getMedicalVocabulary({ VocabularyName: name }), (r) => ({
256 state: r.VocabularyState,
257 })),
258 );
259 case "GET /languageModel":
260 return yield* respond(
261 result(describeLanguageModel({ ModelName: name }), (r) => ({
262 status: r.LanguageModel?.ModelStatus,
263 })),
264 );
265
266 // --- deletes (typed tags for bogus names; real delete for fixtures) ---
267 case "POST /job/delete":
268 return yield* respond(
269 result(deleteJob({ TranscriptionJobName: body.name! })),
270 );
271 case "POST /callJob/delete":
272 return yield* respond(
273 result(deleteCallJob({ CallAnalyticsJobName: body.name! })),
274 );
275 case "POST /medicalJob/delete":
276 return yield* respond(
277 result(
278 deleteMedicalJob({ MedicalTranscriptionJobName: body.name! }),
279 ),
280 );
281 case "POST /scribeJob/delete":
282 return yield* respond(
283 result(deleteScribeJob({ MedicalScribeJobName: body.name! })),
284 );
285 case "POST /vocabulary/delete":
286 return yield* respond(
287 result(deleteVocabulary({ VocabularyName: body.name! })),
288 );
289 case "POST /medicalVocabulary/delete":
290 return yield* respond(
291 result(deleteMedicalVocabulary({ VocabularyName: body.name! })),
292 );
293 case "POST /languageModel/delete":
294 return yield* respond(
295 result(deleteLanguageModel({ ModelName: body.name! })),
296 );
297
298 // --- starts/creates driven through typed validation failures
299 // (invalid S3 URI: nothing is created, nothing is billed) ---
300 case "POST /startJob":
301 return yield* respond(
302 result(
303 startJob({
304 TranscriptionJobName: body.name!,
305 LanguageCode: "en-US",
306 Media: { MediaFileUri: INVALID_S3_URI },
307 }),
308 ),
309 );
310 case "POST /startCall":
311 return yield* respond(
312 result(
313 startCallJob({
314 CallAnalyticsJobName: body.name!,
315 Media: { MediaFileUri: INVALID_S3_URI },
316 }),
317 ),
318 );
319 case "POST /startMedical":
320 return yield* respond(
321 result(
322 startMedicalJob({
323 MedicalTranscriptionJobName: body.name!,
324 LanguageCode: "en-US",
325 Media: { MediaFileUri: INVALID_S3_URI },
326 OutputBucketName: "alchemy-nonexistent-output",
327 Specialty: "PRIMARYCARE",
328 Type: "DICTATION",
329 }),
330 ),
331 );
332 case "POST /startScribe":
333 return yield* respond(
334 result(
335 startScribeJob({
336 MedicalScribeJobName: body.name!,
337 Media: { MediaFileUri: INVALID_S3_URI },
338 OutputBucketName: "alchemy-nonexistent-output",
339 Settings: { ShowSpeakerLabels: true, MaxSpeakerLabels: 2 },
340 }),
341 ),
342 );
343 case "POST /vocabulary/create":
344 return yield* respond(
345 result(
346 createVocabulary({
347 VocabularyName: body.name!,
348 LanguageCode: "en-US",
349 VocabularyFileUri: INVALID_S3_URI,
350 }),
351 ),
352 );
353 case "POST /vocabulary/update":
354 return yield* respond(
355 result(
356 updateVocabulary({
357 VocabularyName: body.name!,
358 LanguageCode: "en-US",
359 Phrases: ["alchemy"],
360 }),
361 ),
362 );
363 case "POST /medicalVocabulary/create":
364 return yield* respond(
365 result(
366 createMedicalVocabulary({
367 VocabularyName: body.name!,
368 LanguageCode: "en-US",
369 VocabularyFileUri: INVALID_S3_URI,
370 }),
371 ),
372 );
373 case "POST /medicalVocabulary/update":
374 return yield* respond(
375 result(
376 updateMedicalVocabulary({
377 VocabularyName: body.name!,
378 LanguageCode: "en-US",
379 VocabularyFileUri: INVALID_S3_URI,
380 }),
381 ),
382 );
383 case "POST /languageModel/create":
384 return yield* respond(
385 result(
386 createLanguageModel({
387 ModelName: body.name!,
388 BaseModelName: "NarrowBand",
389 LanguageCode: "en-US",
390 InputDataConfig: { S3Uri: INVALID_S3_URI },
391 }),
392 ),
393 );
394
395 // --- vocabulary filter lifecycle (real create/get/update/delete) ---
396 case "POST /filter/create":
397 return yield* respond(
398 result(
399 createFilter({
400 VocabularyFilterName: body.name!,
401 LanguageCode: "en-US",
402 Words: ["alchemyzz", "distilledzz"],
403 }),
404 (r) => ({ name: r.VocabularyFilterName }),
405 ),
406 );
407 case "GET /filter":
408 return yield* respond(
409 result(getFilter({ VocabularyFilterName: name }), (r) => ({
410 name: r.VocabularyFilterName,
411 downloadUri: r.DownloadUri,
412 })),
413 );
414 case "POST /filter/update":
415 return yield* respond(
416 result(
417 updateFilter({
418 VocabularyFilterName: body.name!,
419 Words: ["alchemyzz", "distilledzz", "workerdzz"],
420 }),
421 ),
422 );
423 case "POST /filter/delete":
424 return yield* respond(
425 result(deleteFilter({ VocabularyFilterName: body.name! })),
426 );
427
428 // --- call analytics category lifecycle ---
429 case "POST /category/create":
430 return yield* respond(
431 result(
432 createCategory({
433 CategoryName: body.name!,
434 Rules: [{ NonTalkTimeFilter: { Threshold: 30000 } }],
435 }),
436 (r) => ({ name: r.CategoryProperties?.CategoryName }),
437 ),
438 );
439 case "GET /category":
440 return yield* respond(
441 result(getCategory({ CategoryName: name }), (r) => ({
442 rules: r.CategoryProperties?.Rules?.length ?? 0,
443 })),
444 );
445 case "POST /category/update":
446 return yield* respond(
447 result(
448 updateCategory({
449 CategoryName: body.name!,
450 Rules: [{ NonTalkTimeFilter: { Threshold: 60000 } }],
451 }),
452 (r) => ({ rules: r.CategoryProperties?.Rules?.length ?? 0 }),
453 ),
454 );
455 case "POST /category/delete":
456 return yield* respond(
457 result(deleteCategory({ CategoryName: body.name! })),
458 );
459
460 // --- tagging (ARN supplied by the test) ---
461 case "POST /tag":
462 return yield* respond(
463 result(
464 tagResource({
465 ResourceArn: body.arn!,
466 Tags: [{ Key: "alchemy-test", Value: "1" }],
467 }),
468 ),
469 );
470 case "GET /tags":
471 return yield* respond(
472 result(
473 listTagsForResource({
474 ResourceArn: url.searchParams.get("arn") ?? "",
475 }),
476 (r) => ({ count: r.Tags?.length ?? 0 }),
477 ),
478 );
479 case "POST /untag":
480 return yield* respond(
481 result(
482 untagResource({
483 ResourceArn: body.arn!,
484 TagKeys: ["alchemy-test"],
485 }),
486 ),
487 );
488
489 default:
490 return yield* HttpServerResponse.json(
491 { error: "Not found", route },
492 { status: 404 },
493 );
494 }
495 }).pipe(Effect.orDie),
496 };
497 }).pipe(
498 Effect.provide(
499 Layer.mergeAll(
500 Lambda.EventSource,
501 Layer.mergeAll(
502 Transcribe.StartTranscriptionJobHttp,
503 Transcribe.GetTranscriptionJobHttp,
504 Transcribe.ListTranscriptionJobsHttp,
505 Transcribe.DeleteTranscriptionJobHttp,
506 Transcribe.StartCallAnalyticsJobHttp,
507 Transcribe.GetCallAnalyticsJobHttp,
508 Transcribe.ListCallAnalyticsJobsHttp,
509 Transcribe.DeleteCallAnalyticsJobHttp,
510 Transcribe.StartMedicalTranscriptionJobHttp,
511 Transcribe.GetMedicalTranscriptionJobHttp,
512 Transcribe.ListMedicalTranscriptionJobsHttp,
513 Transcribe.DeleteMedicalTranscriptionJobHttp,
514 Transcribe.StartMedicalScribeJobHttp,
515 Transcribe.GetMedicalScribeJobHttp,
516 Transcribe.ListMedicalScribeJobsHttp,
517 Transcribe.DeleteMedicalScribeJobHttp,
518 ),
519 Layer.mergeAll(
520 Transcribe.CreateVocabularyHttp,
521 Transcribe.GetVocabularyHttp,
522 Transcribe.UpdateVocabularyHttp,
523 Transcribe.DeleteVocabularyHttp,
524 Transcribe.ListVocabulariesHttp,
525 Transcribe.CreateVocabularyFilterHttp,
526 Transcribe.GetVocabularyFilterHttp,
527 Transcribe.UpdateVocabularyFilterHttp,
528 Transcribe.DeleteVocabularyFilterHttp,
529 Transcribe.ListVocabularyFiltersHttp,
530 Transcribe.CreateMedicalVocabularyHttp,
531 Transcribe.GetMedicalVocabularyHttp,
532 Transcribe.UpdateMedicalVocabularyHttp,
533 Transcribe.DeleteMedicalVocabularyHttp,
534 Transcribe.ListMedicalVocabulariesHttp,
535 ),
536 Layer.mergeAll(
537 Transcribe.CreateCallAnalyticsCategoryHttp,
538 Transcribe.GetCallAnalyticsCategoryHttp,
539 Transcribe.UpdateCallAnalyticsCategoryHttp,
540 Transcribe.DeleteCallAnalyticsCategoryHttp,
541 Transcribe.ListCallAnalyticsCategoriesHttp,
542 Transcribe.CreateLanguageModelHttp,
543 Transcribe.DescribeLanguageModelHttp,
544 Transcribe.ListLanguageModelsHttp,
545 Transcribe.DeleteLanguageModelHttp,
546 Transcribe.TagResourceHttp,
547 Transcribe.UntagResourceHttp,
548 Transcribe.ListTagsForResourceHttp,
549 ),
550 ),
551 ),
552 ),
553);