1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as qbusiness from "@distilled.cloud/aws/qbusiness";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import QBusinessTestFunctionLive, { QBusinessTestFunction } from "./handler";
10
11const testOptions = { providers: AWS.providers() };
12const { test } = Test.make(testOptions);
13
14// A well-formed-but-nonexistent application/index/data-source id the ungated
15// probes are driven against.
16const NONEXISTENT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
17
18// ---------------------------------------------------------------------------
19// Ungated typed-error probes: every operation the thirty-six bindings wrap is
20// exercised directly through distilled against a nonexistent application, and
21// must answer with a typed tag. These prove the distilled error unions (and
22// request serialization) at near-zero cost on every CI pass, while the full
23// runtime fixture below is gated behind the minutes-long, hourly-billed
24// application + index provisioning.
25// ---------------------------------------------------------------------------
26
27describe(
28 "QBusiness binding operations (typed-error probes)",
29 { tags: ["provider:aws", "provider:aws:qbusiness", "live"] },
30 () => {
31 const expectTag = (error: { _tag: string }, tags: readonly string[]) =>
32 expect(tags).toContain(error._tag);
33 const NOT_FOUND = ["ResourceNotFoundException"] as const;
34 // Operations whose body preconditions may be validated before the
35 // application lookup surface either tag.
36 const NOT_FOUND_OR_INVALID = [
37 "ResourceNotFoundException",
38 "ValidationException",
39 ] as const;
40 // Chat operations may reject on licensing/authorization before resolving
41 // the application.
42 const CHAT_TAGS = [
43 "ResourceNotFoundException",
44 "AccessDeniedException",
45 "LicenseNotFoundException",
46 "ValidationException",
47 ] as const;
48
49 test.provider("chatSync yields a typed error", () =>
50 Effect.gen(function* () {
51 const error = yield* Effect.flip(
52 qbusiness.chatSync({
53 applicationId: NONEXISTENT,
54 userMessage: "probe",
55 }),
56 );
57 expectTag(error, CHAT_TAGS);
58 }),
59 );
60
61 test.provider("searchRelevantContent yields a typed error", () =>
62 Effect.gen(function* () {
63 const error = yield* Effect.flip(
64 qbusiness.searchRelevantContent({
65 applicationId: NONEXISTENT,
66 queryText: "probe",
67 contentSource: { retriever: { retrieverId: NONEXISTENT } },
68 }),
69 );
70 expectTag(error, CHAT_TAGS);
71 }),
72 );
73
74 test.provider("putFeedback yields a typed error", () =>
75 Effect.gen(function* () {
76 const error = yield* Effect.flip(
77 qbusiness.putFeedback({
78 applicationId: NONEXISTENT,
79 conversationId: NONEXISTENT,
80 messageId: NONEXISTENT,
81 messageUsefulness: {
82 usefulness: "USEFUL",
83 submittedAt: new Date(),
84 },
85 }),
86 );
87 expectTag(error, NOT_FOUND_OR_INVALID);
88 }),
89 );
90
91 test.provider(
92 "getChatControlsConfiguration yields a typed not-found error",
93 () =>
94 Effect.gen(function* () {
95 const error = yield* Effect.flip(
96 qbusiness.getChatControlsConfiguration({
97 applicationId: NONEXISTENT,
98 }),
99 );
100 expectTag(error, NOT_FOUND);
101 }),
102 );
103
104 test.provider(
105 "updateChatControlsConfiguration yields a typed not-found error",
106 () =>
107 Effect.gen(function* () {
108 const error = yield* Effect.flip(
109 qbusiness.updateChatControlsConfiguration({
110 applicationId: NONEXISTENT,
111 responseScope: "ENTERPRISE_CONTENT_ONLY",
112 }),
113 );
114 expectTag(error, NOT_FOUND);
115 }),
116 );
117
118 test.provider(
119 "deleteChatControlsConfiguration yields a typed not-found error",
120 () =>
121 Effect.gen(function* () {
122 const error = yield* Effect.flip(
123 qbusiness.deleteChatControlsConfiguration({
124 applicationId: NONEXISTENT,
125 }),
126 );
127 expectTag(error, NOT_FOUND);
128 }),
129 );
130
131 test.provider("listConversations yields a typed not-found error", () =>
132 Effect.gen(function* () {
133 const error = yield* Effect.flip(
134 qbusiness.listConversations({ applicationId: NONEXISTENT }),
135 );
136 expectTag(error, NOT_FOUND);
137 }),
138 );
139
140 test.provider("deleteConversation yields a typed not-found error", () =>
141 Effect.gen(function* () {
142 const error = yield* Effect.flip(
143 qbusiness.deleteConversation({
144 applicationId: NONEXISTENT,
145 conversationId: NONEXISTENT,
146 }),
147 );
148 expectTag(error, NOT_FOUND);
149 }),
150 );
151
152 test.provider("listMessages yields a typed not-found error", () =>
153 Effect.gen(function* () {
154 const error = yield* Effect.flip(
155 qbusiness.listMessages({
156 applicationId: NONEXISTENT,
157 conversationId: NONEXISTENT,
158 }),
159 );
160 expectTag(error, NOT_FOUND);
161 }),
162 );
163
164 test.provider("listAttachments yields a typed not-found error", () =>
165 Effect.gen(function* () {
166 const error = yield* Effect.flip(
167 qbusiness.listAttachments({ applicationId: NONEXISTENT }),
168 );
169 expectTag(error, NOT_FOUND);
170 }),
171 );
172
173 test.provider("deleteAttachment yields a typed not-found error", () =>
174 Effect.gen(function* () {
175 const error = yield* Effect.flip(
176 qbusiness.deleteAttachment({
177 applicationId: NONEXISTENT,
178 conversationId: NONEXISTENT,
179 attachmentId: NONEXISTENT,
180 }),
181 );
182 expectTag(error, NOT_FOUND);
183 }),
184 );
185
186 test.provider("getMedia yields a typed not-found error", () =>
187 Effect.gen(function* () {
188 const error = yield* Effect.flip(
189 qbusiness.getMedia({
190 applicationId: NONEXISTENT,
191 conversationId: NONEXISTENT,
192 messageId: NONEXISTENT,
193 mediaId: NONEXISTENT,
194 }),
195 );
196 expectTag(error, NOT_FOUND);
197 }),
198 );
199
200 test.provider("createUser yields a typed not-found error", () =>
201 Effect.gen(function* () {
202 const error = yield* Effect.flip(
203 qbusiness.createUser({
204 applicationId: NONEXISTENT,
205 userId: "probe@example.com",
206 }),
207 );
208 expectTag(error, NOT_FOUND);
209 }),
210 );
211
212 test.provider("getUser yields a typed not-found error", () =>
213 Effect.gen(function* () {
214 const error = yield* Effect.flip(
215 qbusiness.getUser({
216 applicationId: NONEXISTENT,
217 userId: "probe@example.com",
218 }),
219 );
220 expectTag(error, NOT_FOUND);
221 }),
222 );
223
224 test.provider("updateUser yields a typed not-found error", () =>
225 Effect.gen(function* () {
226 const error = yield* Effect.flip(
227 qbusiness.updateUser({
228 applicationId: NONEXISTENT,
229 userId: "probe@example.com",
230 }),
231 );
232 expectTag(error, NOT_FOUND);
233 }),
234 );
235
236 test.provider("deleteUser yields a typed not-found error", () =>
237 Effect.gen(function* () {
238 const error = yield* Effect.flip(
239 qbusiness.deleteUser({
240 applicationId: NONEXISTENT,
241 userId: "probe@example.com",
242 }),
243 );
244 expectTag(error, NOT_FOUND);
245 }),
246 );
247
248 test.provider("getPolicy yields a typed not-found error", () =>
249 Effect.gen(function* () {
250 const error = yield* Effect.flip(
251 qbusiness.getPolicy({ applicationId: NONEXISTENT }),
252 );
253 expectTag(error, NOT_FOUND);
254 }),
255 );
256
257 test.provider("associatePermission yields a typed error", () =>
258 Effect.gen(function* () {
259 const error = yield* Effect.flip(
260 qbusiness.associatePermission({
261 applicationId: NONEXISTENT,
262 statementId: "probe",
263 actions: ["qbusiness:SearchRelevantContent"],
264 principal: "arn:aws:iam::123456789012:role/AlchemyProbeRole",
265 }),
266 );
267 expectTag(error, NOT_FOUND_OR_INVALID);
268 }),
269 );
270
271 test.provider("disassociatePermission yields a typed error", () =>
272 Effect.gen(function* () {
273 const error = yield* Effect.flip(
274 qbusiness.disassociatePermission({
275 applicationId: NONEXISTENT,
276 statementId: "probe",
277 }),
278 );
279 expectTag(error, NOT_FOUND_OR_INVALID);
280 }),
281 );
282
283 test.provider("createSubscription yields a typed error", () =>
284 Effect.gen(function* () {
285 const error = yield* Effect.flip(
286 qbusiness.createSubscription({
287 applicationId: NONEXISTENT,
288 principal: { user: NONEXISTENT },
289 type: "Q_LITE",
290 }),
291 );
292 expectTag(error, NOT_FOUND_OR_INVALID);
293 }),
294 );
295
296 test.provider("updateSubscription yields a typed error", () =>
297 Effect.gen(function* () {
298 const error = yield* Effect.flip(
299 qbusiness.updateSubscription({
300 applicationId: NONEXISTENT,
301 subscriptionId: NONEXISTENT,
302 type: "Q_LITE",
303 }),
304 );
305 expectTag(error, NOT_FOUND_OR_INVALID);
306 }),
307 );
308
309 test.provider("cancelSubscription yields a typed error", () =>
310 Effect.gen(function* () {
311 const error = yield* Effect.flip(
312 qbusiness.cancelSubscription({
313 applicationId: NONEXISTENT,
314 subscriptionId: NONEXISTENT,
315 }),
316 );
317 expectTag(error, NOT_FOUND_OR_INVALID);
318 }),
319 );
320
321 // listSubscriptions does NOT 404 a nonexistent application — the live API
322 // answers with an empty page, which the probe pins down.
323 test.provider("listSubscriptions answers an empty page", () =>
324 Effect.gen(function* () {
325 const response = yield* qbusiness.listSubscriptions({
326 applicationId: NONEXISTENT,
327 });
328 expect(response.subscriptions ?? []).toHaveLength(0);
329 }),
330 );
331
332 test.provider("batchPutDocument yields a typed error", () =>
333 Effect.gen(function* () {
334 const error = yield* Effect.flip(
335 qbusiness.batchPutDocument({
336 applicationId: NONEXISTENT,
337 indexId: NONEXISTENT,
338 documents: [
339 {
340 id: "probe",
341 content: { blob: new TextEncoder().encode("probe") },
342 contentType: "PLAIN_TEXT",
343 },
344 ],
345 }),
346 );
347 expectTag(error, NOT_FOUND_OR_INVALID);
348 }),
349 );
350
351 test.provider("batchDeleteDocument yields a typed not-found error", () =>
352 Effect.gen(function* () {
353 const error = yield* Effect.flip(
354 qbusiness.batchDeleteDocument({
355 applicationId: NONEXISTENT,
356 indexId: NONEXISTENT,
357 documents: [{ documentId: "probe" }],
358 }),
359 );
360 expectTag(error, NOT_FOUND);
361 }),
362 );
363
364 test.provider("listDocuments yields a typed not-found error", () =>
365 Effect.gen(function* () {
366 const error = yield* Effect.flip(
367 qbusiness.listDocuments({
368 applicationId: NONEXISTENT,
369 indexId: NONEXISTENT,
370 }),
371 );
372 expectTag(error, NOT_FOUND);
373 }),
374 );
375
376 test.provider("getDocumentContent yields a typed not-found error", () =>
377 Effect.gen(function* () {
378 const error = yield* Effect.flip(
379 qbusiness.getDocumentContent({
380 applicationId: NONEXISTENT,
381 indexId: NONEXISTENT,
382 documentId: "probe",
383 }),
384 );
385 expectTag(error, NOT_FOUND);
386 }),
387 );
388
389 test.provider("checkDocumentAccess yields a typed error", () =>
390 Effect.gen(function* () {
391 const error = yield* Effect.flip(
392 qbusiness.checkDocumentAccess({
393 applicationId: NONEXISTENT,
394 indexId: NONEXISTENT,
395 userId: "probe@example.com",
396 documentId: "probe",
397 }),
398 );
399 expectTag(error, NOT_FOUND_OR_INVALID);
400 }),
401 );
402
403 test.provider("putGroup yields a typed not-found error", () =>
404 Effect.gen(function* () {
405 const error = yield* Effect.flip(
406 qbusiness.putGroup({
407 applicationId: NONEXISTENT,
408 indexId: NONEXISTENT,
409 groupName: "probe",
410 type: "INDEX",
411 groupMembers: {
412 memberUsers: [{ userId: "probe@example.com", type: "INDEX" }],
413 },
414 }),
415 );
416 expectTag(error, NOT_FOUND);
417 }),
418 );
419
420 test.provider("getGroup yields a typed not-found error", () =>
421 Effect.gen(function* () {
422 const error = yield* Effect.flip(
423 qbusiness.getGroup({
424 applicationId: NONEXISTENT,
425 indexId: NONEXISTENT,
426 groupName: "probe",
427 }),
428 );
429 expectTag(error, NOT_FOUND);
430 }),
431 );
432
433 test.provider("deleteGroup yields a typed not-found error", () =>
434 Effect.gen(function* () {
435 const error = yield* Effect.flip(
436 qbusiness.deleteGroup({
437 applicationId: NONEXISTENT,
438 indexId: NONEXISTENT,
439 groupName: "probe",
440 }),
441 );
442 expectTag(error, NOT_FOUND);
443 }),
444 );
445
446 test.provider("listGroups yields a typed not-found error", () =>
447 Effect.gen(function* () {
448 const error = yield* Effect.flip(
449 qbusiness.listGroups({
450 applicationId: NONEXISTENT,
451 indexId: NONEXISTENT,
452 updatedEarlierThan: new Date(),
453 }),
454 );
455 expectTag(error, NOT_FOUND);
456 }),
457 );
458
459 test.provider("startDataSourceSyncJob yields a typed not-found error", () =>
460 Effect.gen(function* () {
461 const error = yield* Effect.flip(
462 qbusiness.startDataSourceSyncJob({
463 applicationId: NONEXISTENT,
464 indexId: NONEXISTENT,
465 dataSourceId: NONEXISTENT,
466 }),
467 );
468 expectTag(error, NOT_FOUND);
469 }),
470 );
471
472 test.provider("stopDataSourceSyncJob yields a typed not-found error", () =>
473 Effect.gen(function* () {
474 const error = yield* Effect.flip(
475 qbusiness.stopDataSourceSyncJob({
476 applicationId: NONEXISTENT,
477 indexId: NONEXISTENT,
478 dataSourceId: NONEXISTENT,
479 }),
480 );
481 expectTag(error, NOT_FOUND);
482 }),
483 );
484
485 test.provider("listDataSourceSyncJobs yields a typed not-found error", () =>
486 Effect.gen(function* () {
487 const error = yield* Effect.flip(
488 qbusiness.listDataSourceSyncJobs({
489 applicationId: NONEXISTENT,
490 indexId: NONEXISTENT,
491 dataSourceId: NONEXISTENT,
492 }),
493 );
494 expectTag(error, NOT_FOUND);
495 }),
496 );
497
498 test.provider(
499 "createAnonymousWebExperienceUrl yields a typed not-found error",
500 () =>
501 Effect.gen(function* () {
502 const error = yield* Effect.flip(
503 qbusiness.createAnonymousWebExperienceUrl({
504 applicationId: NONEXISTENT,
505 webExperienceId: NONEXISTENT,
506 }),
507 );
508 expectTag(error, NOT_FOUND);
509 }),
510 );
511 },
512);
513
514// ---------------------------------------------------------------------------
515// Full runtime fixture: a Lambda bound to all thirty-six bindings against a
516// live ANONYMOUS-identity application + STARTER index + CUSTOM data source +
517// native retriever + web experience. The application/index take minutes to
518// provision and the index bills hourly once ACTIVE, so this is gated behind
519// AWS_TEST_QBUSINESS=1 and always destroys what it created.
520// ---------------------------------------------------------------------------
521
522const sharedStack = Core.scratchStack(testOptions, "QBusinessBindings");
523
524test.provider.skipIf(!process.env.AWS_TEST_QBUSINESS)(
525 "all bindings against a live anonymous application",
526 () =>
527 Effect.gen(function* () {
528 yield* sharedStack.destroy();
529
530 yield* Effect.gen(function* () {
531 const { functionUrl } = yield* sharedStack.deploy(
532 Effect.gen(function* () {
533 return yield* QBusinessTestFunction;
534 }).pipe(Effect.provide(QBusinessTestFunctionLive)),
535 );
536 expect(functionUrl).toBeTruthy();
537 const baseUrl = functionUrl!.replace(/\/+$/, "");
538
539 const getJson = (path: string) =>
540 HttpClient.get(`${baseUrl}${path}`).pipe(
541 Effect.flatMap((response) =>
542 response.status >= 500
543 ? Effect.fail(
544 new Error(`transient upstream ${response.status}`),
545 )
546 : Effect.succeed(response),
547 ),
548 Effect.retry({
549 schedule: Schedule.max([
550 Schedule.exponential("500 millis"),
551 Schedule.recurs(10),
552 ]),
553 }),
554 Effect.flatMap((r) => r.json),
555 );
556
557 // All thirty-six capabilities initialized in the runtime.
558 const bindings = (yield* getJson("/bindings")) as { bound: string[] };
559 expect(bindings.bound).toHaveLength(36);
560
561 // CUSTOM connector sync cycle — proves the three data-source
562 // bindings and the id injection.
563 const sync = (yield* getJson("/sync-cycle")) as {
564 executionId?: string | null;
565 jobs?: number | string;
566 stopped?: boolean;
567 errorTag?: string;
568 };
569 expect(sync.errorTag).toBeUndefined();
570 expect(sync.executionId).toBeTruthy();
571
572 // BatchPutDocument — proves applicationId/indexId injection + IAM.
573 const put = (yield* getJson("/put-documents")) as {
574 failed?: number;
575 errorTag?: string;
576 };
577 expect(put.errorTag).toBeUndefined();
578 expect(put.failed).toBe(0);
579
580 // ListDocuments — poll (bounded) until the pushed document leaves
581 // the processing states.
582 const statuses = yield* getJson("/documents").pipe(
583 Effect.map((r) => (r as { statuses?: string[] }).statuses ?? []),
584 Effect.repeat({
585 schedule: Schedule.spaced("10 seconds"),
586 until: (s): boolean =>
587 s.includes("INDEXED") || s.includes("FAILED"),
588 times: 30,
589 }),
590 );
591 expect(statuses).toContain("INDEXED");
592
593 // GetDocumentContent + CheckDocumentAccess over the pushed document.
594 const content = (yield* getJson("/document-content")) as {
595 hasUrl?: boolean;
596 errorTag?: string;
597 };
598 expect(content.errorTag).toBeUndefined();
599 expect(content.hasUrl).toBe(true);
600
601 // ChatSync — the flagship data-plane call answers over the indexed
602 // document.
603 const chat = (yield* getJson("/chat?q=What is the passphrase?")) as {
604 conversationId?: string;
605 messageId?: string;
606 hasMessage?: boolean;
607 errorTag?: string;
608 };
609 expect(chat.errorTag).toBeUndefined();
610 expect(chat.hasMessage).toBe(true);
611
612 // SearchRelevantContent through the native retriever.
613 const search = (yield* getJson("/search?q=zanzibar passphrase")) as {
614 count?: number;
615 errorTag?: string;
616 };
617 expect(search.errorTag).toBeUndefined();
618
619 // Chat controls read + update round trip.
620 const controls = (yield* getJson("/controls")) as {
621 responseScope?: string | null;
622 errorTag?: string;
623 };
624 expect(controls.errorTag).toBeUndefined();
625 const updateControls = (yield* getJson("/update-controls")) as {
626 ok?: boolean;
627 errorTag?: string;
628 };
629 expect(updateControls.errorTag).toBeUndefined();
630
631 // Application policy read.
632 const policy = (yield* getJson("/policy")) as {
633 policy?: string | null;
634 errorTag?: string;
635 };
636 expect(policy.errorTag).toBeUndefined();
637
638 // Anonymous URL minting (ANONYMOUS identity type).
639 const anonymousUrl = (yield* getJson("/anonymous-url")) as {
640 hasUrl?: boolean;
641 errorTag?: string;
642 };
643 expect(anonymousUrl.errorTag).toBeUndefined();
644 expect(anonymousUrl.hasUrl).toBe(true);
645
646 // The remaining routes must answer 200 with either concrete fields
647 // or a TYPED error tag (an ANONYMOUS application rejects the user
648 // store / subscription plane with typed errors) — an untyped error
649 // would crash the route into a 500 and fail getJson.
650 for (const path of [
651 "/conversations",
652 "/attachments",
653 "/delete-attachment",
654 "/media",
655 "/users",
656 "/groups",
657 "/subscriptions",
658 "/subscription-cycle",
659 "/associate-permission",
660 "/disassociate-permission",
661 "/check-access",
662 ]) {
663 const result = (yield* getJson(path)) as Record<string, unknown>;
664 expect(result).toBeTruthy();
665 }
666
667 // BatchDeleteDocument cleanup.
668 const deleted = (yield* getJson("/delete-documents")) as {
669 failed?: number;
670 errorTag?: string;
671 };
672 expect(deleted.errorTag).toBeUndefined();
673 }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie)));
674 }),
675 {
676 tags: [
677 "provider:aws",
678 "provider:aws:batch",
679 "provider:aws:lambda",
680 "provider:aws:qbusiness",
681 "live",
682 ],
683 timeout: 3_600_000,
684 },
685);