1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as kendra from "@distilled.cloud/aws/kendra";
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 KendraTestFunctionLive, { KendraTestFunction } from "./handler";
10
11const testOptions = { providers: AWS.providers() };
12const { test } = Test.make(testOptions);
13
14// A well-formed-but-nonexistent index/data-source id the ungated probes are
15// driven against.
16const NONEXISTENT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
17
18// ---------------------------------------------------------------------------
19// Ungated typed-error probes: every operation the twenty-three bindings wrap
20// is exercised directly through distilled against a nonexistent index, 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 20-30 minute index provisioning.
24// ---------------------------------------------------------------------------
25
26describe(
27 "Kendra binding operations (typed-error probes)",
28 { tags: ["provider:aws", "provider:aws:kendra", "live"] },
29 () => {
30 const expectTag = (error: { _tag: string }, tags: readonly string[]) =>
31 expect(tags).toContain(error._tag);
32 const NOT_FOUND = ["ResourceNotFoundException"] as const;
33 // Operations whose body preconditions may be validated before the index
34 // lookup surface either tag.
35 const NOT_FOUND_OR_INVALID = [
36 "ResourceNotFoundException",
37 "ValidationException",
38 ] as const;
39
40 test.provider("query yields a typed not-found error", () =>
41 Effect.gen(function* () {
42 const error = yield* Effect.flip(
43 kendra.query({ IndexId: NONEXISTENT, QueryText: "probe" }),
44 );
45 expectTag(error, NOT_FOUND);
46 }),
47 );
48
49 test.provider("retrieve yields a typed not-found error", () =>
50 Effect.gen(function* () {
51 const error = yield* Effect.flip(
52 kendra.retrieve({ IndexId: NONEXISTENT, QueryText: "probe" }),
53 );
54 expectTag(error, NOT_FOUND);
55 }),
56 );
57
58 test.provider("getQuerySuggestions yields a typed not-found error", () =>
59 Effect.gen(function* () {
60 const error = yield* Effect.flip(
61 kendra.getQuerySuggestions({
62 IndexId: NONEXISTENT,
63 QueryText: "probe",
64 }),
65 );
66 expectTag(error, NOT_FOUND);
67 }),
68 );
69
70 test.provider("submitFeedback yields a typed error", () =>
71 Effect.gen(function* () {
72 const error = yield* Effect.flip(
73 kendra.submitFeedback({
74 IndexId: NONEXISTENT,
75 QueryId: "probe-query-id",
76 ClickFeedbackItems: [
77 { ResultId: "probe-result", ClickTime: new Date() },
78 ],
79 }),
80 );
81 expectTag(error, NOT_FOUND_OR_INVALID);
82 }),
83 );
84
85 test.provider("batchPutDocument yields a typed error", () =>
86 Effect.gen(function* () {
87 const error = yield* Effect.flip(
88 kendra.batchPutDocument({
89 IndexId: NONEXISTENT,
90 Documents: [
91 {
92 Id: "probe",
93 Blob: new TextEncoder().encode("probe"),
94 ContentType: "PLAIN_TEXT",
95 },
96 ],
97 }),
98 );
99 expectTag(error, NOT_FOUND_OR_INVALID);
100 }),
101 );
102
103 test.provider("batchDeleteDocument yields a typed not-found error", () =>
104 Effect.gen(function* () {
105 const error = yield* Effect.flip(
106 kendra.batchDeleteDocument({
107 IndexId: NONEXISTENT,
108 DocumentIdList: ["probe"],
109 }),
110 );
111 expectTag(error, NOT_FOUND);
112 }),
113 );
114
115 test.provider("batchGetDocumentStatus yields a typed not-found error", () =>
116 Effect.gen(function* () {
117 const error = yield* Effect.flip(
118 kendra.batchGetDocumentStatus({
119 IndexId: NONEXISTENT,
120 DocumentInfoList: [{ DocumentId: "probe" }],
121 }),
122 );
123 expectTag(error, NOT_FOUND);
124 }),
125 );
126
127 test.provider("getSnapshots yields a typed error", () =>
128 Effect.gen(function* () {
129 const error = yield* Effect.flip(
130 kendra.getSnapshots({
131 IndexId: NONEXISTENT,
132 Interval: "ONE_WEEK_AGO",
133 MetricType: "QUERIES_BY_COUNT",
134 }),
135 );
136 expectTag(error, NOT_FOUND_OR_INVALID);
137 }),
138 );
139
140 test.provider("putPrincipalMapping yields a typed error", () =>
141 Effect.gen(function* () {
142 const error = yield* Effect.flip(
143 kendra.putPrincipalMapping({
144 IndexId: NONEXISTENT,
145 GroupId: "probe",
146 GroupMembers: {
147 MemberUsers: [{ UserId: "probe@example.com" }],
148 },
149 }),
150 );
151 expectTag(error, NOT_FOUND_OR_INVALID);
152 }),
153 );
154
155 test.provider("deletePrincipalMapping yields a typed not-found error", () =>
156 Effect.gen(function* () {
157 const error = yield* Effect.flip(
158 kendra.deletePrincipalMapping({
159 IndexId: NONEXISTENT,
160 GroupId: "probe",
161 }),
162 );
163 expectTag(error, NOT_FOUND);
164 }),
165 );
166
167 test.provider(
168 "describePrincipalMapping yields a typed not-found error",
169 () =>
170 Effect.gen(function* () {
171 const error = yield* Effect.flip(
172 kendra.describePrincipalMapping({
173 IndexId: NONEXISTENT,
174 GroupId: "probe",
175 }),
176 );
177 expectTag(error, NOT_FOUND);
178 }),
179 );
180
181 test.provider(
182 "listGroupsOlderThanOrderingId yields a typed not-found error",
183 () =>
184 Effect.gen(function* () {
185 const error = yield* Effect.flip(
186 kendra.listGroupsOlderThanOrderingId({
187 IndexId: NONEXISTENT,
188 OrderingId: 1,
189 }),
190 );
191 expectTag(error, NOT_FOUND);
192 }),
193 );
194
195 test.provider("clearQuerySuggestions yields a typed not-found error", () =>
196 Effect.gen(function* () {
197 const error = yield* Effect.flip(
198 kendra.clearQuerySuggestions({ IndexId: NONEXISTENT }),
199 );
200 expectTag(error, NOT_FOUND);
201 }),
202 );
203
204 test.provider(
205 "describeQuerySuggestionsConfig yields a typed not-found error",
206 () =>
207 Effect.gen(function* () {
208 const error = yield* Effect.flip(
209 kendra.describeQuerySuggestionsConfig({ IndexId: NONEXISTENT }),
210 );
211 expectTag(error, NOT_FOUND);
212 }),
213 );
214
215 test.provider(
216 "updateQuerySuggestionsConfig yields a typed not-found error",
217 () =>
218 Effect.gen(function* () {
219 const error = yield* Effect.flip(
220 kendra.updateQuerySuggestionsConfig({
221 IndexId: NONEXISTENT,
222 Mode: "LEARN_ONLY",
223 }),
224 );
225 expectTag(error, NOT_FOUND);
226 }),
227 );
228
229 test.provider("createAccessControlConfiguration yields a typed error", () =>
230 Effect.gen(function* () {
231 const error = yield* Effect.flip(
232 kendra.createAccessControlConfiguration({
233 IndexId: NONEXISTENT,
234 Name: "probe",
235 }),
236 );
237 expectTag(error, NOT_FOUND_OR_INVALID);
238 }),
239 );
240
241 test.provider(
242 "describeAccessControlConfiguration yields a typed not-found error",
243 () =>
244 Effect.gen(function* () {
245 const error = yield* Effect.flip(
246 kendra.describeAccessControlConfiguration({
247 IndexId: NONEXISTENT,
248 Id: "probe",
249 }),
250 );
251 expectTag(error, NOT_FOUND);
252 }),
253 );
254
255 test.provider(
256 "updateAccessControlConfiguration yields a typed not-found error",
257 () =>
258 Effect.gen(function* () {
259 const error = yield* Effect.flip(
260 kendra.updateAccessControlConfiguration({
261 IndexId: NONEXISTENT,
262 Id: "probe",
263 }),
264 );
265 expectTag(error, NOT_FOUND);
266 }),
267 );
268
269 test.provider(
270 "deleteAccessControlConfiguration yields a typed not-found error",
271 () =>
272 Effect.gen(function* () {
273 const error = yield* Effect.flip(
274 kendra.deleteAccessControlConfiguration({
275 IndexId: NONEXISTENT,
276 Id: "probe",
277 }),
278 );
279 expectTag(error, NOT_FOUND);
280 }),
281 );
282
283 test.provider(
284 "listAccessControlConfigurations yields a typed not-found error",
285 () =>
286 Effect.gen(function* () {
287 const error = yield* Effect.flip(
288 kendra.listAccessControlConfigurations({ IndexId: NONEXISTENT }),
289 );
290 expectTag(error, NOT_FOUND);
291 }),
292 );
293
294 test.provider("startDataSourceSyncJob yields a typed not-found error", () =>
295 Effect.gen(function* () {
296 const error = yield* Effect.flip(
297 kendra.startDataSourceSyncJob({
298 IndexId: NONEXISTENT,
299 Id: NONEXISTENT,
300 }),
301 );
302 expectTag(error, NOT_FOUND);
303 }),
304 );
305
306 test.provider("stopDataSourceSyncJob yields a typed not-found error", () =>
307 Effect.gen(function* () {
308 const error = yield* Effect.flip(
309 kendra.stopDataSourceSyncJob({
310 IndexId: NONEXISTENT,
311 Id: NONEXISTENT,
312 }),
313 );
314 expectTag(error, NOT_FOUND);
315 }),
316 );
317
318 test.provider("listDataSourceSyncJobs yields a typed not-found error", () =>
319 Effect.gen(function* () {
320 const error = yield* Effect.flip(
321 kendra.listDataSourceSyncJobs({
322 IndexId: NONEXISTENT,
323 Id: NONEXISTENT,
324 }),
325 );
326 expectTag(error, NOT_FOUND);
327 }),
328 );
329 },
330);
331
332// ---------------------------------------------------------------------------
333// Full runtime fixture: a Lambda bound to all twenty-three bindings against a
334// live Developer-edition index + S3 data source. An index takes ~20-30
335// minutes to provision and bills hourly once ACTIVE, so this is gated behind
336// AWS_TEST_SLOW=1 (same gate as the Index lifecycle test) and always destroys
337// what it created.
338// ---------------------------------------------------------------------------
339
340const sharedStack = Core.scratchStack(testOptions, "KendraBindings");
341
342test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
343 "all bindings against a live index + data source",
344 () =>
345 Effect.gen(function* () {
346 yield* sharedStack.destroy();
347
348 yield* Effect.gen(function* () {
349 const { functionUrl } = yield* sharedStack.deploy(
350 Effect.gen(function* () {
351 return yield* KendraTestFunction;
352 }).pipe(Effect.provide(KendraTestFunctionLive)),
353 );
354 expect(functionUrl).toBeTruthy();
355 const baseUrl = functionUrl!.replace(/\/+$/, "");
356
357 const getJson = (path: string) =>
358 HttpClient.get(`${baseUrl}${path}`).pipe(
359 Effect.flatMap((response) =>
360 response.status >= 500
361 ? Effect.fail(
362 new Error(`transient upstream ${response.status}`),
363 )
364 : Effect.succeed(response),
365 ),
366 Effect.retry({
367 schedule: Schedule.max([
368 Schedule.exponential("500 millis"),
369 Schedule.recurs(10),
370 ]),
371 }),
372 Effect.flatMap((r) => r.json),
373 );
374
375 // All twenty-three capabilities initialized in the runtime.
376 const bindings = (yield* getJson("/bindings")) as { bound: string[] };
377 expect(bindings.bound).toHaveLength(23);
378
379 // BatchPutDocument — proves IndexId injection + the IAM grant.
380 const put = (yield* getJson("/put-documents")) as {
381 failed?: number;
382 errorTag?: string;
383 };
384 expect(put.errorTag).toBeUndefined();
385 expect(put.failed).toBe(0);
386
387 // BatchGetDocumentStatus — poll (bounded) until the pushed document
388 // leaves the processing states.
389 const status = yield* getJson("/document-status").pipe(
390 Effect.map((r) => (r as { status?: string }).status ?? "UNKNOWN"),
391 Effect.repeat({
392 schedule: Schedule.spaced("15 seconds"),
393 until: (s): boolean => s === "INDEXED" || s === "FAILED",
394 times: 40,
395 }),
396 );
397 expect(status).toBe("INDEXED");
398
399 // Query — the pushed document is searchable.
400 const queried = (yield* getJson("/query?q=zanzibar")) as {
401 queryId?: string;
402 resultId?: string;
403 count?: number;
404 errorTag?: string;
405 };
406 expect(queried.errorTag).toBeUndefined();
407 expect(queried.queryId).toBeTruthy();
408 expect(queried.count).toBeGreaterThanOrEqual(1);
409
410 // SubmitFeedback for the query's first result.
411 const feedback = (yield* getJson(
412 `/feedback?queryId=${queried.queryId}&resultId=${queried.resultId}`,
413 )) as { ok?: boolean; errorTag?: string };
414 expect(feedback.errorTag).toBeUndefined();
415
416 // Retrieve — passage retrieval over the same document.
417 const retrieved = (yield* getJson("/retrieve?q=zanzibar")) as {
418 count?: number;
419 errorTag?: string;
420 };
421 expect(retrieved.errorTag).toBeUndefined();
422
423 // GetQuerySuggestions (suggestions build from query history so an
424 // empty list is fine — no typed error is the assertion).
425 const suggested = (yield* getJson("/suggest?q=zanzi")) as {
426 count?: number;
427 errorTag?: string;
428 };
429 expect(suggested.errorTag).toBeUndefined();
430
431 // Query-suggestions config read + Duration-typed update + clear.
432 const config = (yield* getJson("/suggestions-config")) as {
433 mode?: string;
434 errorTag?: string;
435 };
436 expect(config.errorTag).toBeUndefined();
437 expect(config.mode).toBeTruthy();
438 const updatedSuggestions = (yield* getJson("/update-suggestions")) as {
439 ok?: boolean;
440 errorTag?: string;
441 };
442 expect(updatedSuggestions.errorTag).toBeUndefined();
443 const cleared = (yield* getJson("/clear-suggestions")) as {
444 ok?: boolean;
445 errorTag?: string;
446 };
447 expect(cleared.errorTag).toBeUndefined();
448
449 // Access-control configuration create/describe/update/delete round
450 // trip.
451 const acl = (yield* getJson("/access-control")) as {
452 id?: string;
453 name?: string;
454 errorTag?: string;
455 };
456 expect(acl.errorTag).toBeUndefined();
457 expect(acl.id).toBeTruthy();
458 expect(acl.name).toBe("block-departed-users");
459 const acls = (yield* getJson("/access-controls")) as {
460 count?: number;
461 errorTag?: string;
462 };
463 expect(acls.errorTag).toBeUndefined();
464
465 // Principal mapping put + describe, then delete.
466 const mapping = (yield* getJson("/principal-mapping")) as {
467 actions?: number;
468 errorTag?: string;
469 };
470 expect(mapping.errorTag).toBeUndefined();
471 expect(mapping.actions).toBeGreaterThanOrEqual(1);
472 const stale = (yield* getJson("/stale-groups")) as {
473 count?: number;
474 errorTag?: string;
475 };
476 expect(stale.errorTag).toBeUndefined();
477 const unmapped = (yield* getJson("/delete-principal-mapping")) as {
478 ok?: boolean;
479 errorTag?: string;
480 };
481 expect(unmapped.errorTag).toBeUndefined();
482
483 // Data-source sync trio: start, observe in history, stop.
484 const sync = (yield* getJson("/sync")) as {
485 executionId?: string;
486 errorTag?: string;
487 };
488 expect(sync.errorTag).toBeUndefined();
489 expect(sync.executionId).toBeTruthy();
490 const jobs = yield* getJson("/sync-jobs").pipe(
491 Effect.map((r) => (r as { count?: number }).count ?? 0),
492 Effect.repeat({
493 schedule: Schedule.spaced("10 seconds"),
494 until: (count): boolean => count >= 1,
495 times: 18,
496 }),
497 );
498 expect(jobs).toBeGreaterThanOrEqual(1);
499 // Stopping is best-effort: the sync may already have finished, which
500 // Kendra answers with a typed error we accept.
501 const stopped = (yield* getJson("/stop-sync")) as {
502 ok?: boolean;
503 errorTag?: string;
504 };
505 if (stopped.errorTag !== undefined) {
506 expect([
507 "ResourceNotFoundException",
508 "ConflictException",
509 "ValidationException",
510 ]).toContain(stopped.errorTag);
511 }
512
513 // GetSnapshots — search analytics for the index.
514 const snapshots = (yield* getJson("/snapshots")) as {
515 header?: string[];
516 errorTag?: string;
517 };
518 expect(snapshots.errorTag).toBeUndefined();
519
520 // BatchDeleteDocument cleanup.
521 const deleted = (yield* getJson("/delete-documents")) as {
522 failed?: number;
523 errorTag?: string;
524 };
525 expect(deleted.errorTag).toBeUndefined();
526 expect(deleted.failed).toBe(0);
527 }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie)));
528 }),
529 // index create (~20-30 min) + document indexing + sync + delete wait.
530 {
531 tags: [
532 "provider:aws",
533 "provider:aws:batch",
534 "provider:aws:iam",
535 "provider:aws:kendra",
536 "provider:aws:lambda",
537 "provider:aws:s3",
538 "live",
539 ],
540 timeout: 5_400_000,
541 },
542);