1import * as Lambda from "@/AWS/Lambda";
2import * as QBusiness from "@/AWS/QBusiness";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class QBusinessTestFunction extends Lambda.Function<Lambda.Function>()(
13 "QBusinessTestFunction",
14) {}
15
16/**
17 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
18 * operation fails with a TYPED error — the test asserts on concrete fields
19 * (or a typed tag), which proves the binding wiring, the id injection, and
20 * the IAM grants. An untyped error crashes into a 500.
21 */
22const errorTagged = <A, E extends { _tag: string }, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | { errorTag: string }, never, R> =>
25 effect.pipe(
26 Effect.map((a): A | { errorTag: string } => a),
27 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
28 );
29
30/**
31 * Runtime fixture: an `ANONYMOUS`-identity Amazon Q Business application (no
32 * IAM Identity Center required) with a STARTER index, a CUSTOM data source,
33 * a native retriever, and a web experience, plus a Lambda bound to all
34 * thirty-six QBusiness bindings. Gated behind AWS_TEST_QBUSINESS=1 — the
35 * index bills hourly while it exists and the application takes minutes to
36 * provision.
37 */
38export default QBusinessTestFunction.make(
39 {
40 main,
41 functionUrl: true,
42 timeout: Duration.seconds(60),
43 },
44 Effect.gen(function* () {
45 const app = yield* QBusiness.Application("BindingsApp", {
46 identityType: "ANONYMOUS",
47 description: "alchemy QBusiness bindings fixture",
48 });
49
50 const index = yield* QBusiness.Index("BindingsIndex", {
51 applicationId: app.applicationId,
52 });
53
54 const retriever = yield* QBusiness.Retriever("BindingsRetriever", {
55 applicationId: app.applicationId,
56 type: "NATIVE_INDEX",
57 configuration: {
58 nativeIndexConfiguration: { indexId: index.indexId },
59 },
60 });
61
62 const source = yield* QBusiness.DataSource("BindingsSource", {
63 applicationId: app.applicationId,
64 indexId: index.indexId,
65 configuration: { type: "CUSTOM", version: "1.0.0" },
66 });
67
68 const web = yield* QBusiness.WebExperience("BindingsChat", {
69 applicationId: app.applicationId,
70 title: "Alchemy QBusiness Bindings",
71 });
72
73 // Application-scoped bindings.
74 const chat = yield* QBusiness.ChatSync(app);
75 const search = yield* QBusiness.SearchRelevantContent(app);
76 const feedback = yield* QBusiness.PutFeedback(app);
77 const getControls = yield* QBusiness.GetChatControlsConfiguration(app);
78 const updateControls =
79 yield* QBusiness.UpdateChatControlsConfiguration(app);
80 const deleteControls =
81 yield* QBusiness.DeleteChatControlsConfiguration(app);
82 const listConversations = yield* QBusiness.ListConversations(app);
83 const deleteConversation = yield* QBusiness.DeleteConversation(app);
84 const listMessages = yield* QBusiness.ListMessages(app);
85 const listAttachments = yield* QBusiness.ListAttachments(app);
86 const deleteAttachment = yield* QBusiness.DeleteAttachment(app);
87 const getMedia = yield* QBusiness.GetMedia(app);
88 const createUser = yield* QBusiness.CreateUser(app);
89 const getUser = yield* QBusiness.GetUser(app);
90 const updateUser = yield* QBusiness.UpdateUser(app);
91 const deleteUser = yield* QBusiness.DeleteUser(app);
92 const getPolicy = yield* QBusiness.GetPolicy(app);
93 const associatePermission = yield* QBusiness.AssociatePermission(app);
94 const disassociatePermission = yield* QBusiness.DisassociatePermission(app);
95 const createSubscription = yield* QBusiness.CreateSubscription(app);
96 const updateSubscription = yield* QBusiness.UpdateSubscription(app);
97 const cancelSubscription = yield* QBusiness.CancelSubscription(app);
98 const listSubscriptions = yield* QBusiness.ListSubscriptions(app);
99
100 // Index-scoped bindings.
101 const putDocuments = yield* QBusiness.BatchPutDocument(index);
102 const deleteDocuments = yield* QBusiness.BatchDeleteDocument(index);
103 const listDocuments = yield* QBusiness.ListDocuments(index);
104 const getDocumentContent = yield* QBusiness.GetDocumentContent(index);
105 const checkDocumentAccess = yield* QBusiness.CheckDocumentAccess(index);
106 const putGroup = yield* QBusiness.PutGroup(index);
107 const getGroup = yield* QBusiness.GetGroup(index);
108 const deleteGroup = yield* QBusiness.DeleteGroup(index);
109 const listGroups = yield* QBusiness.ListGroups(index);
110
111 // Data-source-scoped bindings.
112 const startSync = yield* QBusiness.StartDataSourceSyncJob(source);
113 const stopSync = yield* QBusiness.StopDataSourceSyncJob(source);
114 const listSyncJobs = yield* QBusiness.ListDataSourceSyncJobs(source);
115
116 // Web-experience-scoped binding.
117 const createAnonymousUrl =
118 yield* QBusiness.CreateAnonymousWebExperienceUrl(web);
119
120 // Needed by /search — resolved lazily at request time.
121 const retrieverId = yield* retriever.retrieverId;
122
123 const bound = {
124 chat,
125 search,
126 feedback,
127 getControls,
128 updateControls,
129 deleteControls,
130 listConversations,
131 deleteConversation,
132 listMessages,
133 listAttachments,
134 deleteAttachment,
135 getMedia,
136 createUser,
137 getUser,
138 updateUser,
139 deleteUser,
140 getPolicy,
141 associatePermission,
142 disassociatePermission,
143 createSubscription,
144 updateSubscription,
145 cancelSubscription,
146 listSubscriptions,
147 putDocuments,
148 deleteDocuments,
149 listDocuments,
150 getDocumentContent,
151 checkDocumentAccess,
152 putGroup,
153 getGroup,
154 deleteGroup,
155 listGroups,
156 startSync,
157 stopSync,
158 listSyncJobs,
159 createAnonymousUrl,
160 };
161
162 return {
163 fetch: Effect.gen(function* () {
164 const request = yield* HttpServerRequest;
165 const url = new URL(request.originalUrl);
166 const pathname = url.pathname;
167
168 if (pathname === "/bindings") {
169 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
170 }
171
172 // ------------------------------------------------- document plane
173 if (pathname === "/put-documents") {
174 const result = yield* errorTagged(
175 putDocuments({
176 documents: [
177 {
178 id: "welcome",
179 title: "Welcome to Alchemy",
180 content: {
181 blob: new TextEncoder().encode(
182 "Alchemy is an Infrastructure-as-Effects framework. " +
183 "The zanzibar passphrase is quicksilver.",
184 ),
185 },
186 contentType: "PLAIN_TEXT",
187 },
188 ],
189 }),
190 );
191 return yield* HttpServerResponse.json(
192 "errorTag" in result
193 ? result
194 : { failed: (result.failedDocuments ?? []).length },
195 );
196 }
197
198 if (pathname === "/documents") {
199 const result = yield* errorTagged(listDocuments());
200 return yield* HttpServerResponse.json(
201 "errorTag" in result
202 ? result
203 : {
204 count: (result.documentDetailList ?? []).length,
205 statuses: (result.documentDetailList ?? []).map(
206 (d) => d.status,
207 ),
208 },
209 );
210 }
211
212 if (pathname === "/document-content") {
213 const result = yield* errorTagged(
214 getDocumentContent({ documentId: "welcome" }),
215 );
216 return yield* HttpServerResponse.json(
217 "errorTag" in result
218 ? result
219 : { hasUrl: typeof result.presignedUrl === "string" },
220 );
221 }
222
223 if (pathname === "/check-access") {
224 const result = yield* errorTagged(
225 checkDocumentAccess({
226 userId: "anon@example.com",
227 documentId: "welcome",
228 }),
229 );
230 return yield* HttpServerResponse.json(
231 "errorTag" in result ? result : { hasAccess: result.hasAccess },
232 );
233 }
234
235 if (pathname === "/delete-documents") {
236 const result = yield* errorTagged(
237 deleteDocuments({ documents: [{ documentId: "welcome" }] }),
238 );
239 return yield* HttpServerResponse.json(
240 "errorTag" in result
241 ? result
242 : { failed: (result.failedDocuments ?? []).length },
243 );
244 }
245
246 // ----------------------------------------------------------- chat
247 if (pathname === "/chat") {
248 const q = url.searchParams.get("q") ?? "What is the passphrase?";
249 const result = yield* errorTagged(chat({ userMessage: q }));
250 return yield* HttpServerResponse.json(
251 "errorTag" in result
252 ? result
253 : {
254 conversationId: result.conversationId,
255 messageId: result.systemMessageId,
256 hasMessage: typeof result.systemMessage === "string",
257 },
258 );
259 }
260
261 if (pathname === "/search") {
262 const q = url.searchParams.get("q") ?? "zanzibar passphrase";
263 const result = yield* errorTagged(
264 search({
265 queryText: q,
266 contentSource: {
267 retriever: { retrieverId: yield* retrieverId },
268 },
269 }),
270 );
271 return yield* HttpServerResponse.json(
272 "errorTag" in result
273 ? result
274 : { count: (result.relevantContent ?? []).length },
275 );
276 }
277
278 if (pathname === "/feedback") {
279 const conversationId = url.searchParams.get("conversationId") ?? "";
280 const messageId = url.searchParams.get("messageId") ?? "";
281 const result = yield* errorTagged(
282 feedback({
283 conversationId,
284 messageId,
285 messageUsefulness: {
286 usefulness: "USEFUL",
287 submittedAt: new Date(),
288 },
289 }),
290 );
291 return yield* HttpServerResponse.json(
292 "errorTag" in result ? result : { ok: true },
293 );
294 }
295
296 // -------------------------------------------------- conversations
297 if (pathname === "/conversations") {
298 const result = yield* errorTagged(listConversations());
299 return yield* HttpServerResponse.json(
300 "errorTag" in result
301 ? result
302 : { count: (result.conversations ?? []).length },
303 );
304 }
305
306 if (pathname === "/delete-conversation") {
307 const conversationId = url.searchParams.get("conversationId") ?? "";
308 const result = yield* errorTagged(
309 deleteConversation({ conversationId }),
310 );
311 return yield* HttpServerResponse.json(
312 "errorTag" in result ? result : { ok: true },
313 );
314 }
315
316 if (pathname === "/messages") {
317 const conversationId = url.searchParams.get("conversationId") ?? "";
318 const result = yield* errorTagged(listMessages({ conversationId }));
319 return yield* HttpServerResponse.json(
320 "errorTag" in result
321 ? result
322 : { count: (result.messages ?? []).length },
323 );
324 }
325
326 if (pathname === "/attachments") {
327 const result = yield* errorTagged(listAttachments());
328 return yield* HttpServerResponse.json(
329 "errorTag" in result
330 ? result
331 : { count: (result.attachments ?? []).length },
332 );
333 }
334
335 if (pathname === "/delete-attachment") {
336 const result = yield* errorTagged(
337 deleteAttachment({
338 conversationId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
339 attachmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
340 }),
341 );
342 return yield* HttpServerResponse.json(
343 "errorTag" in result ? result : { ok: true },
344 );
345 }
346
347 if (pathname === "/media") {
348 const result = yield* errorTagged(
349 getMedia({
350 conversationId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
351 messageId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
352 mediaId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
353 }),
354 );
355 return yield* HttpServerResponse.json(
356 "errorTag" in result ? result : { ok: true },
357 );
358 }
359
360 // ------------------------------------------------- admin controls
361 if (pathname === "/controls") {
362 const result = yield* errorTagged(getControls());
363 return yield* HttpServerResponse.json(
364 "errorTag" in result
365 ? result
366 : { responseScope: result.responseScope ?? null },
367 );
368 }
369
370 if (pathname === "/update-controls") {
371 const result = yield* errorTagged(
372 updateControls({ responseScope: "ENTERPRISE_CONTENT_ONLY" }),
373 );
374 return yield* HttpServerResponse.json(
375 "errorTag" in result ? result : { ok: true },
376 );
377 }
378
379 if (pathname === "/delete-controls") {
380 const result = yield* errorTagged(deleteControls());
381 return yield* HttpServerResponse.json(
382 "errorTag" in result ? result : { ok: true },
383 );
384 }
385
386 // ---------------------------------------------------------- users
387 if (pathname === "/users") {
388 // create -> get -> update -> delete round trip through the four
389 // user-store bindings (an ANONYMOUS-identity application may
390 // reject the user store with a typed error — surfaced as tags).
391 const created = yield* errorTagged(
392 createUser({ userId: "user@example.com" }),
393 );
394 if ("errorTag" in created) {
395 return yield* HttpServerResponse.json(created);
396 }
397 const read = yield* errorTagged(
398 getUser({ userId: "user@example.com" }),
399 );
400 const updated = yield* errorTagged(
401 updateUser({
402 userId: "user@example.com",
403 userAliasesToUpdate: [{ userId: "corp-user" }],
404 }),
405 );
406 const deleted = yield* errorTagged(
407 deleteUser({ userId: "user@example.com" }),
408 );
409 return yield* HttpServerResponse.json({
410 aliases:
411 "errorTag" in read ? read : (read.userAliases ?? []).length,
412 updated: !("errorTag" in updated),
413 deleted: !("errorTag" in deleted),
414 });
415 }
416
417 // --------------------------------------------------------- groups
418 if (pathname === "/groups") {
419 const put = yield* errorTagged(
420 putGroup({
421 groupName: "engineering",
422 type: "INDEX",
423 groupMembers: {
424 memberUsers: [{ userId: "user@example.com", type: "INDEX" }],
425 },
426 }),
427 );
428 if ("errorTag" in put) {
429 return yield* HttpServerResponse.json(put);
430 }
431 const read = yield* errorTagged(
432 getGroup({ groupName: "engineering" }),
433 );
434 const listed = yield* errorTagged(
435 listGroups({ updatedEarlierThan: new Date() }),
436 );
437 const deleted = yield* errorTagged(
438 deleteGroup({ groupName: "engineering" }),
439 );
440 return yield* HttpServerResponse.json({
441 status:
442 "errorTag" in read
443 ? read.errorTag
444 : (read.status?.status ?? null),
445 listed: "errorTag" in listed ? listed.errorTag : true,
446 deleted: !("errorTag" in deleted),
447 });
448 }
449
450 // -------------------------------------------------------- policy
451 if (pathname === "/policy") {
452 const result = yield* errorTagged(getPolicy());
453 return yield* HttpServerResponse.json(
454 "errorTag" in result ? result : { policy: result.policy ?? null },
455 );
456 }
457
458 if (pathname === "/associate-permission") {
459 const result = yield* errorTagged(
460 associatePermission({
461 statementId: "alchemy-probe",
462 actions: ["qbusiness:SearchRelevantContent"],
463 principal: "arn:aws:iam::123456789012:role/AlchemyProbeRole",
464 }),
465 );
466 return yield* HttpServerResponse.json(
467 "errorTag" in result ? result : { ok: true },
468 );
469 }
470
471 if (pathname === "/disassociate-permission") {
472 const result = yield* errorTagged(
473 disassociatePermission({ statementId: "alchemy-probe" }),
474 );
475 return yield* HttpServerResponse.json(
476 "errorTag" in result ? result : { ok: true },
477 );
478 }
479
480 // -------------------------------------------------- subscriptions
481 if (pathname === "/subscriptions") {
482 const result = yield* errorTagged(listSubscriptions());
483 return yield* HttpServerResponse.json(
484 "errorTag" in result
485 ? result
486 : { count: (result.subscriptions ?? []).length },
487 );
488 }
489
490 if (pathname === "/subscription-cycle") {
491 // Expected to fail typed on an ANONYMOUS application (no Identity
492 // Center) — proves the three write bindings serialize correctly.
493 const created = yield* errorTagged(
494 createSubscription({
495 principal: { user: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" },
496 type: "Q_LITE",
497 }),
498 );
499 const updated = yield* errorTagged(
500 updateSubscription({
501 subscriptionId: "nonexistent",
502 type: "Q_BUSINESS",
503 }),
504 );
505 const cancelled = yield* errorTagged(
506 cancelSubscription({ subscriptionId: "nonexistent" }),
507 );
508 return yield* HttpServerResponse.json({
509 created: "errorTag" in created ? created.errorTag : "ok",
510 updated: "errorTag" in updated ? updated.errorTag : "ok",
511 cancelled: "errorTag" in cancelled ? cancelled.errorTag : "ok",
512 });
513 }
514
515 // ------------------------------------------------------ sync jobs
516 if (pathname === "/sync-cycle") {
517 // CUSTOM connector flow: open a sync scope, list, close it.
518 const started = yield* errorTagged(startSync());
519 if ("errorTag" in started) {
520 return yield* HttpServerResponse.json(started);
521 }
522 const listed = yield* errorTagged(listSyncJobs());
523 const stopped = yield* errorTagged(stopSync());
524 return yield* HttpServerResponse.json({
525 executionId: started.executionId ?? null,
526 jobs:
527 "errorTag" in listed
528 ? listed.errorTag
529 : (listed.history ?? []).length,
530 stopped: !("errorTag" in stopped),
531 });
532 }
533
534 // -------------------------------------------------- anonymous url
535 if (pathname === "/anonymous-url") {
536 const result = yield* errorTagged(
537 createAnonymousUrl({ sessionDuration: "15 minutes" }),
538 );
539 return yield* HttpServerResponse.json(
540 "errorTag" in result
541 ? result
542 : { hasUrl: typeof result.anonymousUrl === "string" },
543 );
544 }
545
546 return yield* HttpServerResponse.json(
547 { error: "Not found", method: request.method, pathname },
548 { status: 404 },
549 );
550 }).pipe(Effect.orDie),
551 };
552 }).pipe(
553 Effect.provide(
554 Layer.mergeAll(
555 QBusiness.ChatSyncHttp,
556 QBusiness.SearchRelevantContentHttp,
557 QBusiness.PutFeedbackHttp,
558 QBusiness.GetChatControlsConfigurationHttp,
559 QBusiness.UpdateChatControlsConfigurationHttp,
560 QBusiness.DeleteChatControlsConfigurationHttp,
561 QBusiness.ListConversationsHttp,
562 QBusiness.DeleteConversationHttp,
563 QBusiness.ListMessagesHttp,
564 QBusiness.ListAttachmentsHttp,
565 QBusiness.DeleteAttachmentHttp,
566 QBusiness.GetMediaHttp,
567 QBusiness.CreateUserHttp,
568 QBusiness.GetUserHttp,
569 QBusiness.UpdateUserHttp,
570 QBusiness.DeleteUserHttp,
571 QBusiness.GetPolicyHttp,
572 QBusiness.AssociatePermissionHttp,
573 QBusiness.DisassociatePermissionHttp,
574 QBusiness.CreateSubscriptionHttp,
575 QBusiness.UpdateSubscriptionHttp,
576 QBusiness.CancelSubscriptionHttp,
577 QBusiness.ListSubscriptionsHttp,
578 QBusiness.BatchPutDocumentHttp,
579 QBusiness.BatchDeleteDocumentHttp,
580 QBusiness.ListDocumentsHttp,
581 QBusiness.GetDocumentContentHttp,
582 QBusiness.CheckDocumentAccessHttp,
583 QBusiness.PutGroupHttp,
584 QBusiness.GetGroupHttp,
585 QBusiness.DeleteGroupHttp,
586 QBusiness.ListGroupsHttp,
587 QBusiness.StartDataSourceSyncJobHttp,
588 QBusiness.StopDataSourceSyncJobHttp,
589 QBusiness.ListDataSourceSyncJobsHttp,
590 QBusiness.CreateAnonymousWebExperienceUrlHttp,
591 ),
592 ),
593 ),
594);