1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import LexTestFunctionLive, { LexTestFunction } from "./handler.ts";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "LexV2Bindings");
15
16let baseUrl: string;
17
18class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
19 readonly status: number;
20 readonly body: string;
21}> {}
22
23/**
24 * POST /recognize with a bounded 5xx retry — the Lambda role's fresh
25 * `lex:RecognizeText` policy propagates eventually, surfacing as 502
26 * AccessDeniedException for the first seconds.
27 */
28const call = (request: HttpClientRequest.HttpClientRequest) =>
29 Effect.gen(function* () {
30 const response = yield* HttpClient.execute(request);
31 const body = yield* response.text;
32 if (response.status >= 500) {
33 return yield* Effect.fail(
34 new TransientUpstream({ status: response.status, body }),
35 );
36 }
37 if (response.status !== 200) {
38 // Terminal — fail fast with the typed tag in the message.
39 return yield* Effect.die(new Error(`status ${response.status}: ${body}`));
40 }
41 return JSON.parse(body) as Record<string, any>;
42 }).pipe(
43 Effect.retry({
44 while: (e) => e._tag === "TransientUpstream",
45 schedule: Schedule.max([
46 Schedule.exponential("1 second"),
47 Schedule.recurs(8),
48 ]),
49 }),
50 );
51
52const recognize = (text: string, sessionId: string) =>
53 call(
54 HttpClientRequest.post(`${baseUrl}/recognize`).pipe(
55 HttpClientRequest.bodyJsonUnsafe({ text, sessionId }),
56 ),
57 );
58
59describe(
60 "LexV2 Bindings",
61 {
62 tags: [
63 "provider:aws",
64 "provider:aws:iam",
65 "provider:aws:lambda",
66 "provider:aws:lexv2",
67 "live",
68 ],
69 },
70 () => {
71 beforeAll(
72 Effect.gen(function* () {
73 yield* Effect.logInfo("LexV2 e2e setup: destroying previous resources");
74 yield* sharedStack.destroy();
75
76 yield* Effect.logInfo(
77 "LexV2 e2e setup: deploying role -> bot -> locale -> intent -> version (build) -> alias -> Lambda",
78 );
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* LexTestFunction;
82 }).pipe(Effect.provide(LexTestFunctionLive)),
83 );
84 expect(functionUrl).toBeTruthy();
85 baseUrl = functionUrl!.replace(/\/+$/, "");
86
87 // Readiness probe — fresh function URLs take seconds to serve 200s.
88 yield* HttpClient.get(`${baseUrl}/health`).pipe(
89 Effect.flatMap((response) =>
90 response.status === 200
91 ? Effect.succeed(response)
92 : Effect.fail(
93 new Error(`Function not ready: ${response.status}`),
94 ),
95 ),
96 Effect.retry({
97 schedule: Schedule.max([
98 Schedule.fixed("2 seconds"),
99 Schedule.recurs(15),
100 ]),
101 }),
102 );
103 }),
104 // The bounded locale build can consume ~90s by itself. Lambda creation
105 // and IAM/function-URL propagation follow that build, so leave headroom
106 // for those bounded phases when the full AWS sweep saturates the account.
107 { timeout: 210_000 },
108 );
109 afterAll(sharedStack.destroy(), { timeout: 180_000 });
110
111 describe("RecognizeText", () => {
112 test.provider(
113 "recognizes a greeting utterance as the Greet intent",
114 () =>
115 Effect.gen(function* () {
116 const body = yield* recognize("hello", "alchemy-e2e-greet");
117 expect(body.intent).toBe("Greet");
118 }),
119 { timeout: 120_000 },
120 );
121
122 test.provider(
123 "routes an unmatched utterance to the fallback intent",
124 () =>
125 Effect.gen(function* () {
126 const body = yield* recognize(
127 "purple monkey dishwasher",
128 "alchemy-e2e-fallback",
129 );
130 expect(body.intent).toBe("FallbackIntent");
131 }),
132 { timeout: 120_000 },
133 );
134 });
135
136 describe("CodeHookEventSource", () => {
137 test.provider(
138 "fulfillment code hook closes the intent with the handler's message",
139 () =>
140 Effect.gen(function* () {
141 const body = yield* recognize("order a pizza", "alchemy-e2e-order");
142 expect(body.intent).toBe("OrderPizza");
143 expect(body.state).toBe("Fulfilled");
144 expect(body.messages).toContain(
145 "Order placed for alchemy-e2e-order!",
146 );
147 }),
148 { timeout: 120_000 },
149 );
150 });
151
152 describe("Sessions", () => {
153 test.provider(
154 "put, get, and delete a session",
155 () =>
156 Effect.gen(function* () {
157 const sessionId = "alchemy-e2e-session";
158
159 // PutSession seeds attributes.
160 const put = yield* call(
161 HttpClientRequest.post(`${baseUrl}/session`).pipe(
162 HttpClientRequest.bodyJsonUnsafe({
163 sessionId,
164 attributes: { favorite: "pepperoni" },
165 }),
166 ),
167 );
168 expect(put.sessionId).toBe(sessionId);
169
170 // GetSession reads them back.
171 const got = yield* call(
172 HttpClientRequest.get(
173 `${baseUrl}/session?sessionId=${sessionId}`,
174 ),
175 );
176 expect(got.attributes).toMatchObject({ favorite: "pepperoni" });
177
178 // DeleteSession ends the conversation ...
179 const deleted = yield* call(
180 HttpClientRequest.delete(
181 `${baseUrl}/session?sessionId=${sessionId}`,
182 ),
183 );
184 expect(deleted.sessionId).toBe(sessionId);
185
186 // ... so a fresh GetSession reports the typed not-found tag.
187 const response = yield* HttpClient.get(
188 `${baseUrl}/session?sessionId=${sessionId}`,
189 );
190 const body = (yield* response.json) as { error?: string };
191 expect(body.error).toBe("ResourceNotFoundException");
192 }),
193 { timeout: 120_000 },
194 );
195 });
196
197 describe("RecognizeUtterance", () => {
198 test.provider(
199 "recognizes a text utterance and returns response metadata",
200 () =>
201 Effect.gen(function* () {
202 const body = yield* call(
203 HttpClientRequest.post(`${baseUrl}/utterance`).pipe(
204 HttpClientRequest.bodyJsonUnsafe({
205 text: "hello",
206 sessionId: "alchemy-e2e-utterance",
207 }),
208 ),
209 );
210 expect(body.sessionId).toBe("alchemy-e2e-utterance");
211 expect(body.contentType).toContain("text/plain");
212 }),
213 { timeout: 120_000 },
214 );
215 });
216 },
217);