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 ComprehendTestFunctionLive, { ComprehendTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "ComprehendBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy under parallel-suite load.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// Freshly attached IAM policies propagate eventually; an early Comprehend
31// call can surface AccessDenied as a 500 through the handler's orDie. Retry
32// 5xx only; a genuine 4xx/assertion failure surfaces immediately.
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e): boolean => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("1 second"),
50 Schedule.recurs(6),
51 ]),
52 }),
53 );
54
55const getJson = (path: string) =>
56 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
57 Effect.flatMap((r) => r.json),
58 );
59
60const postJson = (path: string) =>
61 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
62 Effect.flatMap((r) => r.json),
63 );
64
65describe(
66 "Comprehend Bindings",
67 {
68 tags: [
69 "provider:aws",
70 "provider:aws:batch",
71 "provider:aws:comprehend",
72 "provider:aws:iam",
73 "provider:aws:lambda",
74 "provider:aws:s3",
75 "live",
76 ],
77 },
78 () => {
79 beforeAll(
80 Effect.gen(function* () {
81 yield* Effect.logInfo(
82 "Comprehend test setup: destroying previous resources",
83 );
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("Comprehend test setup: deploying fixture");
87 const { functionUrl } = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* ComprehendTestFunction;
90 }).pipe(Effect.provide(ComprehendTestFunctionLive)),
91 );
92
93 expect(functionUrl).toBeTruthy();
94 baseUrl = functionUrl!.replace(/\/+$/, "");
95 const readinessUrl = `${baseUrl}/ping`;
96
97 yield* Effect.logInfo(
98 `Comprehend test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `Comprehend test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 180_000 });
120
121 describe("real-time analysis (DetectDominantLanguage, DetectEntities, DetectKeyPhrases, DetectPiiEntities, DetectSentiment, DetectSyntax, DetectTargetedSentiment, DetectToxicContent, ContainsPiiEntities)", () => {
122 test.provider(
123 "every single-document detect binding returns a real inference",
124 (_stack) =>
125 Effect.gen(function* () {
126 const result = (yield* getJson("/detect-all")) as {
127 languageCode: string;
128 entityTypes: string[];
129 keyPhraseCount: number;
130 piiTypes: string[];
131 sentiment: string;
132 syntaxTags: string[];
133 targetedEntityCount: number;
134 toxicity: number;
135 piiLabels: string[];
136 };
137
138 // DetectDominantLanguage
139 expect(result.languageCode).toBe("en");
140 // DetectEntities — "Bob" is a PERSON, "Seattle" a LOCATION.
141 expect(result.entityTypes).toContain("PERSON");
142 expect(result.entityTypes).toContain("LOCATION");
143 // DetectKeyPhrases
144 expect(result.keyPhraseCount).toBeGreaterThan(0);
145 // DetectPiiEntities — the text contains a name and an email.
146 expect(result.piiTypes).toContain("NAME");
147 expect(result.piiTypes).toContain("EMAIL");
148 // DetectSentiment
149 expect(result.sentiment).toBe("POSITIVE");
150 // DetectSyntax — "cat" and "mat" are nouns.
151 expect(result.syntaxTags).toContain("NOUN");
152 // DetectTargetedSentiment — screen + battery entities.
153 expect(result.targetedEntityCount).toBeGreaterThan(0);
154 // DetectToxicContent — a compliment scores near zero.
155 expect(result.toxicity).toBeLessThan(0.5);
156 // ContainsPiiEntities
157 expect(result.piiLabels).toContain("NAME");
158 }),
159 { timeout: 120_000 },
160 );
161 });
162
163 describe("batch real-time analysis (BatchDetectDominantLanguage, BatchDetectEntities, BatchDetectKeyPhrases, BatchDetectSentiment, BatchDetectSyntax, BatchDetectTargetedSentiment)", () => {
164 test.provider(
165 "every batch detect binding returns index-aligned results",
166 (_stack) =>
167 Effect.gen(function* () {
168 const result = (yield* getJson("/batch-all")) as {
169 languageResults: number;
170 firstLanguage: string;
171 entityResults: number;
172 keyPhraseResults: number;
173 sentimentResults: number;
174 firstSentiment: string;
175 syntaxResults: number;
176 targetedResults: number;
177 };
178
179 expect(result.languageResults).toBe(2);
180 expect(result.firstLanguage).toBe("en");
181 expect(result.entityResults).toBe(1);
182 expect(result.keyPhraseResults).toBe(2);
183 expect(result.sentimentResults).toBe(2);
184 expect(result.firstSentiment).toBe("POSITIVE");
185 expect(result.syntaxResults).toBe(1);
186 expect(result.targetedResults).toBe(1);
187 }),
188 { timeout: 120_000 },
189 );
190 });
191
192 describe("ClassifyDocument", () => {
193 test.provider(
194 "drives the typed ResourceUnavailableException path for a missing endpoint",
195 (_stack) =>
196 Effect.gen(function* () {
197 const result = (yield* getJson("/classify-document")) as {
198 tag: string;
199 };
200 expect(result.tag).toBe("ResourceUnavailableException");
201 }),
202 { timeout: 120_000 },
203 );
204 });
205
206 describe("List*Jobs (all nine families)", () => {
207 test.provider(
208 "every list binding returns a page",
209 (_stack) =>
210 Effect.gen(function* () {
211 const counts = (yield* getJson("/jobs/list-all")) as Record<
212 string,
213 number
214 >;
215 for (const family of [
216 "documentClassification",
217 "dominantLanguage",
218 "entities",
219 "events",
220 "keyPhrases",
221 "piiEntities",
222 "sentiment",
223 "targetedSentiment",
224 "topics",
225 ]) {
226 expect(counts[family]).toBeGreaterThanOrEqual(0);
227 }
228 }),
229 { timeout: 120_000 },
230 );
231 });
232
233 describe("Describe*Job (all nine families)", () => {
234 test.provider(
235 "every describe binding surfaces the typed JobNotFoundException",
236 (_stack) =>
237 Effect.gen(function* () {
238 const tags = (yield* getJson(
239 "/jobs/describe-not-found-all",
240 )) as Record<string, string>;
241 expect(tags).toEqual({
242 documentClassification: "JobNotFoundException",
243 dominantLanguage: "JobNotFoundException",
244 entities: "JobNotFoundException",
245 // Events detection is closed to new customers; entitled accounts
246 // return JobNotFoundException instead.
247 events: expect.stringMatching(
248 /^(JobNotFoundException|NotAuthorizedException)$/,
249 ),
250 keyPhrases: "JobNotFoundException",
251 piiEntities: "JobNotFoundException",
252 sentiment: "JobNotFoundException",
253 targetedSentiment: "JobNotFoundException",
254 topics: "JobNotFoundException",
255 });
256 }),
257 { timeout: 120_000 },
258 );
259 });
260
261 describe("Stop*Job (all seven stoppable families)", () => {
262 test.provider(
263 "every stop binding surfaces the typed JobNotFoundException",
264 (_stack) =>
265 Effect.gen(function* () {
266 const tags = (yield* postJson(
267 "/jobs/stop-not-found-all",
268 )) as Record<string, string>;
269 expect(tags).toEqual({
270 dominantLanguage: "JobNotFoundException",
271 entities: "JobNotFoundException",
272 // Events detection is closed to new customers — the stop path
273 // hits the entitlement gate before the job lookup, surfacing the
274 // (patched) typed NotAuthorizedException.
275 events: expect.stringMatching(
276 /^(JobNotFoundException|NotAuthorizedException)$/,
277 ),
278 keyPhrases: "JobNotFoundException",
279 piiEntities: "JobNotFoundException",
280 sentiment: "JobNotFoundException",
281 targetedSentiment: "JobNotFoundException",
282 });
283 }),
284 { timeout: 120_000 },
285 );
286 });
287
288 describe("Start*Job (eight non-sentiment families)", () => {
289 test.provider(
290 "every start binding reaches Comprehend and surfaces the typed validation error",
291 (_stack) =>
292 Effect.gen(function* () {
293 const tags = (yield* postJson("/jobs/start-invalid-all")) as Record<
294 string,
295 string
296 >;
297 // A missing IAM/PassRole grant would surface AccessDenied as a 500
298 // (untyped, through orDie) — a typed server-side validation tag
299 // proves role injection + the grant end-to-end.
300 for (const [family, tag] of Object.entries(tags)) {
301 expect(
302 [
303 "ValidationException",
304 "InvalidRequestException",
305 "ResourceNotFoundException",
306 "NotAuthorizedException",
307 ],
308 `family ${family} returned ${tag}`,
309 ).toContain(tag);
310 }
311 expect(Object.keys(tags).sort()).toEqual([
312 "documentClassification",
313 "dominantLanguage",
314 "entities",
315 "events",
316 "keyPhrases",
317 "piiEntities",
318 "targetedSentiment",
319 "topics",
320 ]);
321 }),
322 { timeout: 120_000 },
323 );
324 });
325
326 describe("StartSentimentDetectionJob + DescribeSentimentDetectionJob + StopSentimentDetectionJob", () => {
327 test.provider(
328 "runs a real async job lifecycle: start, describe, stop",
329 (_stack) =>
330 Effect.gen(function* () {
331 const result = (yield* postJson("/jobs/sentiment/lifecycle")) as {
332 jobId: string;
333 startStatus: string;
334 describedStatus: string;
335 stopStatus: string;
336 };
337 expect(result.jobId.length).toBeGreaterThan(0);
338 expect(["SUBMITTED", "IN_PROGRESS"]).toContain(result.startStatus);
339 expect(["SUBMITTED", "IN_PROGRESS"]).toContain(
340 result.describedStatus,
341 );
342 expect(["STOP_REQUESTED", "STOPPED"]).toContain(result.stopStatus);
343 }),
344 { timeout: 120_000 },
345 );
346 });
347 },
348);