| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AWSEnvironment } from "@/AWS/Environment"; |
| 3 | import * as Core from "@/Test/Core"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as medicalimaging from "@distilled.cloud/aws/medical-imaging"; |
| 6 | import * as s3 from "@distilled.cloud/aws/s3"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import MedicalImagingTestFunctionLive, { |
| 12 | IMPORT_PREFIX, |
| 13 | MedicalImagingTestFunction, |
| 14 | } from "./handler"; |
| 15 | |
| 16 | const testOptions = { providers: AWS.providers() }; |
| 17 | const { test } = Test.make(testOptions); |
| 18 | |
| 19 | // Well-formed-but-nonexistent ids (32 hex chars) the ungated probes are |
| 20 | // driven against. |
| 21 | const NONEXISTENT_DATASTORE = "0123456789abcdef0123456789abcdef"; |
| 22 | const NONEXISTENT_IMAGE_SET = "0123456789abcdef0123456789abcdef"; |
| 23 | const 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. |
| 39 | const expectNotFoundOrDenied = (error: { _tag: string }) => |
| 40 | expect(["ResourceNotFoundException", "AccessDeniedException"]).toContain( |
| 41 | error._tag, |
| 42 | ); |
| 43 | |
| 44 | describe( |
| 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 | |
| 224 | const sharedStack = Core.scratchStack(testOptions, "MedicalImagingBindings"); |
| 225 | |
| 226 | test.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 | ); |