1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as healthlake from "@distilled.cloud/aws/healthlake";
6import * as s3 from "@distilled.cloud/aws/s3";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import HealthLakeTestFunctionLive, {
12 HealthLakeTestFunction,
13 IMPORT_PREFIX,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test } = Test.make(testOptions);
18
19// A well-formed-but-nonexistent data store id (32 hex chars) the ungated
20// probes are driven against.
21const NONEXISTENT_DATASTORE = "0123456789abcdef0123456789abcdef";
22const NONEXISTENT_JOB = "0123456789abcdef0123456789abcdef";
23
24// ---------------------------------------------------------------------------
25// Ungated typed-error probes: every job operation the six bindings wrap is
26// exercised directly through distilled against a nonexistent data store, and
27// must answer with the typed not-found tag the bindings' consumers handle.
28// These prove the distilled error unions (and request serialization) at
29// near-zero cost on every CI pass, while the full runtime fixture below is
30// gated behind the 15-30 minute data store provisioning.
31// ---------------------------------------------------------------------------
32
33describe(
34 "HealthLake job operations (typed-error probes)",
35 { tags: ["provider:aws", "provider:aws:healthlake", "live"] },
36 () => {
37 test.provider(
38 "describeFHIRImportJob on a nonexistent datastore fails with ResourceNotFoundException",
39 () =>
40 Effect.gen(function* () {
41 const error = yield* Effect.flip(
42 healthlake.describeFHIRImportJob({
43 DatastoreId: NONEXISTENT_DATASTORE,
44 JobId: NONEXISTENT_JOB,
45 }),
46 );
47 expect(error._tag).toBe("ResourceNotFoundException");
48 }),
49 );
50
51 test.provider(
52 "describeFHIRExportJob on a nonexistent datastore fails with ResourceNotFoundException",
53 () =>
54 Effect.gen(function* () {
55 const error = yield* Effect.flip(
56 healthlake.describeFHIRExportJob({
57 DatastoreId: NONEXISTENT_DATASTORE,
58 JobId: NONEXISTENT_JOB,
59 }),
60 );
61 expect(error._tag).toBe("ResourceNotFoundException");
62 }),
63 );
64
65 test.provider(
66 "listFHIRImportJobs on a nonexistent datastore fails with ResourceNotFoundException",
67 () =>
68 Effect.gen(function* () {
69 const error = yield* Effect.flip(
70 healthlake.listFHIRImportJobs({
71 DatastoreId: NONEXISTENT_DATASTORE,
72 }),
73 );
74 expect(error._tag).toBe("ResourceNotFoundException");
75 }),
76 );
77
78 test.provider(
79 "listFHIRExportJobs on a nonexistent datastore fails with ResourceNotFoundException",
80 () =>
81 Effect.gen(function* () {
82 const error = yield* Effect.flip(
83 healthlake.listFHIRExportJobs({
84 DatastoreId: NONEXISTENT_DATASTORE,
85 }),
86 );
87 expect(error._tag).toBe("ResourceNotFoundException");
88 }),
89 );
90
91 test.provider(
92 "startFHIRExportJob on a nonexistent datastore fails with a typed tag",
93 () =>
94 Effect.gen(function* () {
95 const { accountId } = yield* AWSEnvironment.current;
96 const error = yield* Effect.flip(
97 healthlake.startFHIRExportJob({
98 DatastoreId: NONEXISTENT_DATASTORE,
99 DataAccessRoleArn: `arn:aws:iam::${accountId}:role/alchemy-probe-nonexistent`,
100 OutputDataConfig: {
101 S3Configuration: {
102 S3Uri: "s3://alchemy-probe-nonexistent/export/",
103 KmsKeyId: `arn:aws:kms:us-east-1:${accountId}:key/00000000-0000-0000-0000-000000000000`,
104 },
105 },
106 }),
107 );
108 expect([
109 "ResourceNotFoundException",
110 "ValidationException",
111 "AccessDeniedException",
112 ]).toContain(error._tag);
113 }),
114 );
115
116 test.provider(
117 "startFHIRImportJob on a nonexistent datastore fails with a typed tag",
118 () =>
119 Effect.gen(function* () {
120 const { accountId } = yield* AWSEnvironment.current;
121 const error = yield* Effect.flip(
122 healthlake.startFHIRImportJob({
123 DatastoreId: NONEXISTENT_DATASTORE,
124 DataAccessRoleArn: `arn:aws:iam::${accountId}:role/alchemy-probe-nonexistent`,
125 InputDataConfig: { S3Uri: "s3://alchemy-probe-nonexistent/in/" },
126 JobOutputDataConfig: {
127 S3Configuration: {
128 S3Uri: "s3://alchemy-probe-nonexistent/out/",
129 KmsKeyId: `arn:aws:kms:us-east-1:${accountId}:key/00000000-0000-0000-0000-000000000000`,
130 },
131 },
132 }),
133 );
134 expect([
135 "ResourceNotFoundException",
136 "ValidationException",
137 "AccessDeniedException",
138 ]).toContain(error._tag);
139 }),
140 );
141 },
142);
143
144// ---------------------------------------------------------------------------
145// Full runtime fixture: a Lambda bound to all six job bindings against a live
146// FHIR data store. A data store takes ~15-30 minutes to provision and bills
147// while it exists, so this is gated behind AWS_TEST_HEALTHLAKE=1 (same gate
148// as the FHIRDatastore lifecycle test) and always destroys what it created.
149// ---------------------------------------------------------------------------
150
151const sharedStack = Core.scratchStack(testOptions, "HealthLakeBindings");
152
153// A single synthetic FHIR R4 Patient in NDJSON form — the smallest valid
154// import payload.
155const PATIENT_NDJSON = JSON.stringify({
156 resourceType: "Patient",
157 id: "alchemy-bindings-patient",
158 name: [{ family: "Alchemy", given: ["Bindings"] }],
159 gender: "unknown",
160});
161
162test.provider.skipIf(!process.env.AWS_TEST_HEALTHLAKE)(
163 "import + export job bindings against a live FHIR data store",
164 () =>
165 Effect.gen(function* () {
166 yield* sharedStack.destroy();
167
168 yield* Effect.gen(function* () {
169 const { functionUrl } = yield* sharedStack.deploy(
170 Effect.gen(function* () {
171 return yield* HealthLakeTestFunction;
172 }).pipe(Effect.provide(HealthLakeTestFunctionLive)),
173 );
174 expect(functionUrl).toBeTruthy();
175 const baseUrl = functionUrl!.replace(/\/+$/, "");
176
177 const getJson = (path: string) =>
178 HttpClient.get(`${baseUrl}${path}`).pipe(
179 Effect.flatMap((response) =>
180 response.status >= 500
181 ? Effect.fail(
182 new Error(`transient upstream ${response.status}`),
183 )
184 : Effect.succeed(response),
185 ),
186 Effect.retry({
187 schedule: Schedule.max([
188 Schedule.exponential("500 millis"),
189 Schedule.recurs(10),
190 ]),
191 }),
192 Effect.flatMap((r) => r.json),
193 );
194
195 // All six capabilities initialized in the runtime.
196 const bindings = (yield* getJson("/bindings")) as { bound: string[] };
197 expect(bindings.bound).toHaveLength(6);
198
199 // Upload the smallest valid import payload out-of-band.
200 const config = (yield* getJson("/config")) as { bucketName: string };
201 yield* s3.putObject({
202 Bucket: config.bucketName,
203 Key: `${IMPORT_PREFIX}patient.ndjson`,
204 Body: new TextEncoder().encode(PATIENT_NDJSON),
205 ContentType: "application/x-ndjson",
206 });
207
208 // StartFHIRImportJob — proves DatastoreId + DataAccessRoleArn
209 // injection and the iam:PassRole grant.
210 const importJob = (yield* getJson("/import")) as {
211 jobId?: string;
212 status?: string;
213 errorTag?: string;
214 };
215 expect(importJob.errorTag).toBeUndefined();
216 expect(importJob.jobId).toBeTruthy();
217 expect(importJob.status).toBe("SUBMITTED");
218
219 // DescribeFHIRImportJob — the job is observable immediately.
220 const describedImport = (yield* getJson(
221 `/describe-import?jobId=${importJob.jobId}`,
222 )) as { status?: string; errorTag?: string };
223 expect(describedImport.errorTag).toBeUndefined();
224 expect(describedImport.status).toBeTruthy();
225
226 // ListFHIRImportJobs sees it.
227 const imports = (yield* getJson("/list-imports")) as { count: number };
228 expect(imports.count).toBeGreaterThanOrEqual(1);
229
230 // Wait (bounded) for the import to leave the submitted/in-progress
231 // states so the concurrent export below is not rejected.
232 yield* getJson(`/describe-import?jobId=${importJob.jobId}`).pipe(
233 Effect.map((r) => (r as { status: string }).status),
234 Effect.repeat({
235 schedule: Schedule.spaced("15 seconds"),
236 until: (status): boolean =>
237 status !== "SUBMITTED" &&
238 status !== "QUEUED" &&
239 status !== "IN_PROGRESS",
240 times: 60,
241 }),
242 );
243
244 // StartFHIRExportJob + DescribeFHIRExportJob + ListFHIRExportJobs.
245 const exportJob = (yield* getJson("/export")) as {
246 jobId?: string;
247 status?: string;
248 errorTag?: string;
249 };
250 expect(exportJob.errorTag).toBeUndefined();
251 expect(exportJob.jobId).toBeTruthy();
252 expect(exportJob.status).toBe("SUBMITTED");
253
254 const describedExport = (yield* getJson(
255 `/describe-export?jobId=${exportJob.jobId}`,
256 )) as { status?: string; errorTag?: string };
257 expect(describedExport.errorTag).toBeUndefined();
258 expect(describedExport.status).toBeTruthy();
259
260 const exports = (yield* getJson("/list-exports")) as { count: number };
261 expect(exports.count).toBeGreaterThanOrEqual(1);
262
263 // Wait (bounded) for the export to finish so the bucket/data store
264 // are quiescent before destroy.
265 yield* getJson(`/describe-export?jobId=${exportJob.jobId}`).pipe(
266 Effect.map((r) => (r as { status: string }).status),
267 Effect.repeat({
268 schedule: Schedule.spaced("15 seconds"),
269 until: (status): boolean =>
270 status !== "SUBMITTED" &&
271 status !== "QUEUED" &&
272 status !== "IN_PROGRESS",
273 times: 60,
274 }),
275 );
276 }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie)));
277 }),
278 // data store create (~15-30 min) + import + export + delete wait, one test.
279 {
280 tags: [
281 "provider:aws",
282 "provider:aws:healthlake",
283 "provider:aws:iam",
284 "provider:aws:kms",
285 "provider:aws:lambda",
286 "provider:aws:s3",
287 "live",
288 ],
289 timeout: 5_400_000,
290 },
291);