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 ComprehendMedicalTestFunctionLive, {
12 ComprehendMedicalTestFunction,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "ComprehendMedicalBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy under parallel-suite load. Budget ~150s of
21// readiness polling.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under
35// parallel load (cold re-init, IAM propagation, throttling surfaced as a
36// defect). Retry 5xx only; a genuine 4xx/assertion failure surfaces
37// immediately.
38const send = (request: HttpClientRequest.HttpClientRequest) =>
39 HttpClient.execute(request).pipe(
40 Effect.flatMap((response) =>
41 response.status >= 500
42 ? response.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(
45 new TransientUpstream({ status: response.status, body }),
46 ),
47 ),
48 )
49 : Effect.succeed(response),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "TransientUpstream",
53 schedule: Schedule.max([
54 Schedule.exponential("1 second"),
55 Schedule.recurs(5),
56 ]),
57 }),
58 );
59
60describe(
61 "ComprehendMedical Bindings",
62 {
63 tags: [
64 "provider:aws",
65 "provider:aws:comprehendmedical",
66 "provider:aws:lambda",
67 "live",
68 ],
69 },
70 () => {
71 beforeAll(
72 Effect.gen(function* () {
73 yield* Effect.logInfo(
74 "ComprehendMedical test setup: destroying previous resources",
75 );
76 yield* sharedStack.destroy();
77
78 yield* Effect.logInfo(
79 "ComprehendMedical test setup: deploying fixture",
80 );
81 const { functionUrl } = yield* sharedStack.deploy(
82 Effect.gen(function* () {
83 return yield* ComprehendMedicalTestFunction;
84 }).pipe(Effect.provide(ComprehendMedicalTestFunctionLive)),
85 );
86
87 expect(functionUrl).toBeTruthy();
88 baseUrl = functionUrl!.replace(/\/+$/, "");
89 const readinessUrl = `${baseUrl}/ping`;
90
91 yield* Effect.logInfo(
92 `ComprehendMedical test setup: probing readiness at ${readinessUrl}`,
93 );
94 yield* HttpClient.get(readinessUrl).pipe(
95 Effect.flatMap((response) =>
96 response.status === 200
97 ? Effect.succeed(response)
98 : Effect.fail(
99 new Error(`Function not ready: ${response.status}`),
100 ),
101 ),
102 Effect.tapError((error) =>
103 Effect.logWarning(
104 `ComprehendMedical test setup: fixture not ready yet (${String(error)})`,
105 ),
106 ),
107 Effect.retry({ schedule: readinessPolicy }),
108 );
109 }),
110 { timeout: 240_000 },
111 );
112
113 afterAll(sharedStack.destroy(), { timeout: 120_000 });
114
115 describe("DetectEntitiesV2", () => {
116 test.provider(
117 "extracts medical entities from a clinical note",
118 (_stack) =>
119 Effect.gen(function* () {
120 const response = (yield* send(
121 HttpClientRequest.get(`${baseUrl}/entities`),
122 ).pipe(Effect.flatMap((r) => r.json))) as {
123 entities: Array<{ text: string; category: string; type: string }>;
124 modelVersion: string;
125 };
126
127 expect(response.entities.length).toBeGreaterThan(0);
128 // atenolol is a medication; hypertension is a medical condition.
129 const categories = response.entities.map((e) => e.category);
130 expect(categories).toContain("MEDICATION");
131 expect(categories).toContain("MEDICAL_CONDITION");
132 expect(response.modelVersion.length).toBeGreaterThan(0);
133 }),
134 { timeout: 120_000 },
135 );
136 });
137
138 describe("DetectPHI", () => {
139 test.provider(
140 "detects protected health information",
141 (_stack) =>
142 Effect.gen(function* () {
143 const response = (yield* send(
144 HttpClientRequest.get(`${baseUrl}/phi`),
145 ).pipe(Effect.flatMap((r) => r.json))) as {
146 entities: Array<{ text: string; type: string }>;
147 };
148
149 expect(response.entities.length).toBeGreaterThan(0);
150 const types = response.entities.map((e) => e.type);
151 // The note contains a name, an age, and a date.
152 expect(types).toContain("NAME");
153 }),
154 { timeout: 120_000 },
155 );
156 });
157
158 describe("InferICD10CM", () => {
159 test.provider(
160 "links conditions to ICD-10-CM codes",
161 (_stack) =>
162 Effect.gen(function* () {
163 const response = (yield* send(
164 HttpClientRequest.get(`${baseUrl}/icd10`),
165 ).pipe(Effect.flatMap((r) => r.json))) as {
166 entities: Array<{
167 text: string;
168 codes: Array<{ code: string; description: string }>;
169 }>;
170 };
171
172 expect(response.entities.length).toBeGreaterThan(0);
173 const withCodes = response.entities.filter(
174 (e) => e.codes.length > 0,
175 );
176 expect(withCodes.length).toBeGreaterThan(0);
177 // ICD-10-CM codes look like "E11.9", "I10", etc.
178 expect(withCodes[0].codes[0].code.length).toBeGreaterThan(0);
179 }),
180 { timeout: 120_000 },
181 );
182 });
183
184 describe("InferRxNorm", () => {
185 test.provider(
186 "links medications to RxNorm concepts",
187 (_stack) =>
188 Effect.gen(function* () {
189 const response = (yield* send(
190 HttpClientRequest.get(`${baseUrl}/rxnorm`),
191 ).pipe(Effect.flatMap((r) => r.json))) as {
192 entities: Array<{
193 text: string;
194 concepts: Array<{ code: string; description: string }>;
195 }>;
196 };
197
198 expect(response.entities.length).toBeGreaterThan(0);
199 const withConcepts = response.entities.filter(
200 (e) => e.concepts.length > 0,
201 );
202 expect(withConcepts.length).toBeGreaterThan(0);
203 expect(withConcepts[0].concepts[0].code.length).toBeGreaterThan(0);
204 }),
205 { timeout: 120_000 },
206 );
207 });
208
209 describe("InferSNOMEDCT", () => {
210 test.provider(
211 "links concepts to SNOMED CT codes",
212 (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* send(
215 HttpClientRequest.get(`${baseUrl}/snomed`),
216 ).pipe(Effect.flatMap((r) => r.json))) as {
217 entities: Array<{
218 text: string;
219 concepts: Array<{ code: string; description: string }>;
220 }>;
221 };
222
223 expect(response.entities.length).toBeGreaterThan(0);
224 const withConcepts = response.entities.filter(
225 (e) => e.concepts.length > 0,
226 );
227 expect(withConcepts.length).toBeGreaterThan(0);
228 expect(withConcepts[0].concepts[0].code.length).toBeGreaterThan(0);
229 }),
230 { timeout: 120_000 },
231 );
232 });
233
234 describe("List*Jobs", () => {
235 test.provider(
236 "lists batch jobs across all five job families",
237 (_stack) =>
238 Effect.gen(function* () {
239 const response = (yield* send(
240 HttpClientRequest.get(`${baseUrl}/jobs`),
241 ).pipe(Effect.flatMap((r) => r.json))) as {
242 entities: number;
243 icd10cm: number;
244 phi: number;
245 rxnorm: number;
246 snomedct: number;
247 };
248
249 // Every list call authorized and answered — counts are >= 0.
250 expect(response.entities).toBeGreaterThanOrEqual(0);
251 expect(response.icd10cm).toBeGreaterThanOrEqual(0);
252 expect(response.phi).toBeGreaterThanOrEqual(0);
253 expect(response.rxnorm).toBeGreaterThanOrEqual(0);
254 expect(response.snomedct).toBeGreaterThanOrEqual(0);
255 }),
256 { timeout: 120_000 },
257 );
258 });
259
260 describe("Describe*/Stop*Job", () => {
261 test.provider(
262 "answers with the typed ResourceNotFoundException for unknown jobs",
263 (_stack) =>
264 Effect.gen(function* () {
265 const response = (yield* send(
266 HttpClientRequest.get(`${baseUrl}/job-checks`),
267 ).pipe(Effect.flatMap((r) => r.json))) as {
268 describes: string[];
269 stops: string[];
270 };
271
272 // IAM authorizes before the job lookup, so the typed
273 // ResourceNotFoundException proves both wiring and permissions
274 // for every describe/stop binding.
275 expect(response.describes).toEqual(
276 Array(5).fill("ResourceNotFoundException"),
277 );
278 expect(response.stops).toEqual(
279 Array(5).fill("ResourceNotFoundException"),
280 );
281 }),
282 { timeout: 120_000 },
283 );
284 });
285
286 describe("StartPHIDetectionJob", () => {
287 test.provider(
288 "passes IAM authorization and fails typed on the bogus data-access role",
289 (_stack) =>
290 Effect.gen(function* () {
291 // A same-account-but-nonexistent role is rejected AFTER the
292 // comprehendmedical:StartPHIDetectionJob authorization with the
293 // typed InvalidRequestException (DATA_ACCESS_ROLE_ARN_INVALID); a
294 // caller missing the Start permission gets AccessDeniedException
295 // instead — so this proves the binding granted the action.
296 const { Account } = yield* sts.getCallerIdentity({});
297 const roleArn = `arn:aws:iam::${Account}:role/alchemy-nonexistent-comprehendmedical-role`;
298
299 const response = (yield* send(
300 HttpClientRequest.get(
301 `${baseUrl}/job-start?roleArn=${encodeURIComponent(roleArn)}`,
302 ),
303 ).pipe(Effect.flatMap((r) => r.json))) as { tag: string };
304
305 expect(response.tag).toBe("InvalidRequestException");
306 }),
307 { timeout: 120_000 },
308 );
309 });
310 },
311);