1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as medicalimaging from "@distilled.cloud/aws/medical-imaging";
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 MedicalImagingTestFunctionLive, {
12 IMPORT_PREFIX,
13 MedicalImagingTestFunction,
14} from "./handler";
15
16const testOptions = { providers: AWS.providers() };
17const { test } = Test.make(testOptions);
18
19// Well-formed-but-nonexistent ids (32 hex chars) the ungated probes are
20// driven against.
21const NONEXISTENT_DATASTORE = "0123456789abcdef0123456789abcdef";
22const NONEXISTENT_IMAGE_SET = "0123456789abcdef0123456789abcdef";
23const NONEXISTENT_JOB = "0123456789abcdef0123456789abcdef";
24
25// ---------------------------------------------------------------------------
26// Ungated typed-error probes: every data-plane operation the eleven bindings
27// wrap is exercised directly through distilled against a nonexistent data
28// store, and must answer with its typed tag. Import-job routes consistently
29// answer ResourceNotFoundException; image-set routes flip-flop between
30// AccessDeniedException and ResourceNotFoundException (observed answering
31// each within hours of the other in mid-2026), so those probes accept
32// either typed tag. These prove the distilled error unions (and request
33// serialization) at near-zero cost on every CI pass, while the full runtime
34// fixture below is gated behind the multi-minute data store provisioning.
35// ---------------------------------------------------------------------------
36
37// AWS answers image-set routes on a nonexistent datastore with either tag
38// depending on which backend handles the request; both are typed.
39const expectNotFoundOrDenied = (error: { _tag: string }) =>
40 expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain(
41 error._tag,
42 );
43
44describe(
45 "MedicalImaging data-plane operations (typed-error probes)",
46 { tags: ["provider:aws", "provider:aws:medicalimaging", "live"] },
47 () => {
48 test.provider(
49 "getImageSet on a nonexistent datastore fails with a typed not-found/denied tag",
50 () =>
51 Effect.gen(function* () {
52 const error = yield* Effect.flip(
53 medicalimaging.getImageSet({
54 datastoreId: NONEXISTENT_DATASTORE,
55 imageSetId: NONEXISTENT_IMAGE_SET,
56 }),
57 );
58 expectNotFoundOrDenied(error);
59 }),
60 );
61
62 test.provider(
63 "getImageSetMetadata on a nonexistent datastore fails with a typed not-found/denied tag",
64 () =>
65 Effect.gen(function* () {
66 const error = yield* Effect.flip(
67 medicalimaging.getImageSetMetadata({
68 datastoreId: NONEXISTENT_DATASTORE,
69 imageSetId: NONEXISTENT_IMAGE_SET,
70 }),
71 );
72 expectNotFoundOrDenied(error);
73 }),
74 );
75
76 test.provider(
77 "getImageFrame on a nonexistent datastore fails with a typed not-found/denied tag",
78 () =>
79 Effect.gen(function* () {
80 const error = yield* Effect.flip(
81 medicalimaging.getImageFrame({
82 datastoreId: NONEXISTENT_DATASTORE,
83 imageSetId: NONEXISTENT_IMAGE_SET,
84 imageFrameInformation: { imageFrameId: NONEXISTENT_IMAGE_SET },
85 }),
86 );
87 expectNotFoundOrDenied(error);
88 }),
89 );
90
91 test.provider(
92 "searchImageSets on a nonexistent datastore fails with a typed not-found/denied tag",
93 () =>
94 Effect.gen(function* () {
95 const error = yield* Effect.flip(
96 medicalimaging.searchImageSets({
97 datastoreId: NONEXISTENT_DATASTORE,
98 }),
99 );
100 expectNotFoundOrDenied(error);
101 }),
102 );
103
104 test.provider(
105 "listImageSetVersions on a nonexistent datastore fails with a typed not-found/denied tag",
106 () =>
107 Effect.gen(function* () {
108 const error = yield* Effect.flip(
109 medicalimaging.listImageSetVersions({
110 datastoreId: NONEXISTENT_DATASTORE,
111 imageSetId: NONEXISTENT_IMAGE_SET,
112 }),
113 );
114 expectNotFoundOrDenied(error);
115 }),
116 );
117
118 test.provider(
119 "updateImageSetMetadata on a nonexistent datastore fails with a typed not-found/denied tag",
120 () =>
121 Effect.gen(function* () {
122 const error = yield* Effect.flip(
123 medicalimaging.updateImageSetMetadata({
124 datastoreId: NONEXISTENT_DATASTORE,
125 imageSetId: NONEXISTENT_IMAGE_SET,
126 latestVersionId: "1",
127 updateImageSetMetadataUpdates: { revertToVersionId: "1" },
128 }),
129 );
130 expectNotFoundOrDenied(error);
131 }),
132 );
133
134 test.provider(
135 "copyImageSet on a nonexistent datastore fails with a typed not-found/denied tag",
136 () =>
137 Effect.gen(function* () {
138 const error = yield* Effect.flip(
139 medicalimaging.copyImageSet({
140 datastoreId: NONEXISTENT_DATASTORE,
141 sourceImageSetId: NONEXISTENT_IMAGE_SET,
142 copyImageSetInformation: {
143 sourceImageSet: { latestVersionId: "1" },
144 },
145 }),
146 );
147 expectNotFoundOrDenied(error);
148 }),
149 );
150
151 test.provider(
152 "deleteImageSet on a nonexistent datastore fails with a typed not-found/denied tag",
153 () =>
154 Effect.gen(function* () {
155 const error = yield* Effect.flip(
156 medicalimaging.deleteImageSet({
157 datastoreId: NONEXISTENT_DATASTORE,
158 imageSetId: NONEXISTENT_IMAGE_SET,
159 }),
160 );
161 expectNotFoundOrDenied(error);
162 }),
163 );
164
165 test.provider(
166 "getDICOMImportJob on a nonexistent datastore fails with ResourceNotFoundException",
167 () =>
168 Effect.gen(function* () {
169 const error = yield* Effect.flip(
170 medicalimaging.getDICOMImportJob({
171 datastoreId: NONEXISTENT_DATASTORE,
172 jobId: NONEXISTENT_JOB,
173 }),
174 );
175 expect(error._tag).toBe("ResourceNotFoundException");
176 }),
177 );
178
179 test.provider(
180 "listDICOMImportJobs on a nonexistent datastore fails with ResourceNotFoundException",
181 () =>
182 Effect.gen(function* () {
183 const error = yield* Effect.flip(
184 medicalimaging.listDICOMImportJobs({
185 datastoreId: NONEXISTENT_DATASTORE,
186 }),
187 );
188 expect(error._tag).toBe("ResourceNotFoundException");
189 }),
190 );
191
192 test.provider(
193 "startDICOMImportJob on a nonexistent datastore fails with a typed tag",
194 () =>
195 Effect.gen(function* () {
196 const { accountId } = yield* AWSEnvironment.current;
197 const error = yield* Effect.flip(
198 medicalimaging.startDICOMImportJob({
199 datastoreId: NONEXISTENT_DATASTORE,
200 clientToken: "alchemy-medicalimaging-probe",
201 dataAccessRoleArn: `arn:aws:iam::${accountId}:role/alchemy-probe-nonexistent`,
202 inputS3Uri: "s3://alchemy-probe-nonexistent/in/",
203 outputS3Uri: "s3://alchemy-probe-nonexistent/out/",
204 }),
205 );
206 expect([
207 "ResourceNotFoundException",
208 "ValidationException",
209 "AccessDeniedException",
210 ]).toContain(error._tag);
211 }),
212 );
213 },
214);
215
216// ---------------------------------------------------------------------------
217// Full runtime fixture: a Lambda bound to all eleven data-plane bindings
218// against a live HealthImaging data store. A data store takes a few minutes
219// to provision (and must be empty to delete), so this is gated behind
220// AWS_TEST_MEDICAL_IMAGING=1 (same gate as the Datastore lifecycle test) and
221// always destroys what it created.
222// ---------------------------------------------------------------------------
223
224const sharedStack = Core.scratchStack(testOptions, "MedicalImagingBindings");
225
226test.provider.skipIf(!process.env.AWS_TEST_MEDICAL_IMAGING)(
227 "image set + import job bindings against a live data store",
228 () =>
229 Effect.gen(function* () {
230 yield* sharedStack.destroy();
231
232 yield* Effect.gen(function* () {
233 const { functionUrl } = yield* sharedStack.deploy(
234 Effect.gen(function* () {
235 return yield* MedicalImagingTestFunction;
236 }).pipe(Effect.provide(MedicalImagingTestFunctionLive)),
237 );
238 expect(functionUrl).toBeTruthy();
239 const baseUrl = functionUrl!.replace(/\/+$/, "");
240
241 const getJson = (path: string) =>
242 HttpClient.get(`${baseUrl}${path}`).pipe(
243 Effect.flatMap((response) =>
244 response.status >= 500
245 ? Effect.fail(
246 new Error(`transient upstream ${response.status}`),
247 )
248 : Effect.succeed(response),
249 ),
250 Effect.retry({
251 schedule: Schedule.max([
252 Schedule.exponential("500 millis"),
253 Schedule.recurs(10),
254 ]),
255 }),
256 Effect.flatMap((r) => r.json),
257 );
258
259 // All eleven capabilities initialized in the runtime.
260 const bindings = (yield* getJson("/bindings")) as { bound: string[] };
261 expect(bindings.bound).toHaveLength(11);
262
263 // Upload a (deliberately invalid) DICOM input out-of-band — the
264 // import job still submits and runs; per-object failures land in
265 // the output manifests.
266 const config = (yield* getJson("/config")) as { bucketName: string };
267 yield* s3.putObject({
268 Bucket: config.bucketName,
269 Key: `${IMPORT_PREFIX}not-a-dicom.dcm`,
270 Body: new TextEncoder().encode("alchemy medical-imaging fixture"),
271 ContentType: "application/dicom",
272 });
273
274 // StartDICOMImportJob — proves datastoreId + dataAccessRoleArn
275 // injection and the iam:PassRole grant.
276 const importJob = (yield* getJson("/import")) as {
277 jobId?: string;
278 status?: string;
279 errorTag?: string;
280 };
281 expect(importJob.errorTag).toBeUndefined();
282 expect(importJob.jobId).toBeTruthy();
283 expect(importJob.status).toBe("SUBMITTED");
284
285 // GetDICOMImportJob — the job is observable immediately.
286 const described = (yield* getJson(
287 `/describe-import?jobId=${importJob.jobId}`,
288 )) as { status?: string; errorTag?: string };
289 expect(described.errorTag).toBeUndefined();
290 expect(described.status).toBeTruthy();
291
292 // ListDICOMImportJobs sees it.
293 const imports = (yield* getJson("/list-imports")) as { count: number };
294 expect(imports.count).toBeGreaterThanOrEqual(1);
295
296 // SearchImageSets — the store has no image sets (the import input
297 // is invalid), so an empty result proves the grant + wiring.
298 const search = (yield* getJson("/search")) as {
299 count?: number;
300 errorTag?: string;
301 };
302 expect(search.errorTag).toBeUndefined();
303 expect(search.count).toBe(0);
304
305 // Image-set-scoped bindings against a nonexistent image set: the
306 // typed not-found tag (not AccessDeniedException) proves the IAM
307 // grant on `{datastoreArn}/imageset/*` and the id injection.
308 for (const route of [
309 "/get-image-set",
310 "/get-metadata",
311 "/get-frame",
312 "/versions",
313 "/update-metadata",
314 "/copy",
315 "/delete-image-set",
316 ]) {
317 const result = (yield* getJson(route)) as { errorTag?: string };
318 expect(result.errorTag).toBe("ResourceNotFoundException");
319 }
320
321 // Wait (bounded) for the import job to finish so the data store is
322 // quiescent (and guaranteed image-set-free) before destroy.
323 yield* getJson(`/describe-import?jobId=${importJob.jobId}`).pipe(
324 Effect.map((r) => (r as { status: string }).status),
325 Effect.repeat({
326 schedule: Schedule.spaced("10 seconds"),
327 until: (status): boolean =>
328 status !== "SUBMITTED" && status !== "IN_PROGRESS",
329 times: 30,
330 }),
331 );
332 }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie)));
333 }),
334 // data store create (a few minutes) + import job + async delete, one test.
335 {
336 tags: [
337 "provider:aws",
338 "provider:aws:iam",
339 "provider:aws:lambda",
340 "provider:aws:medicalimaging",
341 "provider:aws:s3",
342 "live",
343 ],
344 timeout: 1_800_000,
345 },
346);