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 { HELLO_PNG_TEXT } from "./constants.ts";
11import TextractTestFunctionLive, { TextractTestFunction } from "./handler.ts";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "TextractBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy under parallel-suite load.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25
26class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
27 readonly status: number;
28 readonly body: string;
29}> {}
30
31// The shared Lambda fixture occasionally answers a transient 5xx under load
32// (cold re-init, IAM propagation, upstream throttling surfaced as a defect).
33// Retry 5xx only; a genuine 4xx/assertion failure surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("1 second"),
51 Schedule.recurs(5),
52 ]),
53 }),
54 );
55
56const getJson = <T>(path: string) =>
57 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
58 Effect.flatMap((r) => r.json),
59 Effect.map((body) => body as T),
60 );
61
62const postJson = <T>(path: string) =>
63 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 Effect.map((body) => body as T),
66 );
67
68/** Poll an async job's get route until it leaves IN_PROGRESS (bounded). */
69const pollJob = <T extends { jobStatus?: string }>(
70 path: string,
71 jobId: string,
72) =>
73 getJson<T>(`${path}?jobId=${jobId}`).pipe(
74 Effect.repeat({
75 schedule: Schedule.spaced("3 seconds"),
76 until: (r): boolean =>
77 r.jobStatus !== undefined && r.jobStatus !== "IN_PROGRESS",
78 times: 40,
79 }),
80 );
81
82describe(
83 "Textract Bindings",
84 {
85 tags: [
86 "provider:aws",
87 "provider:aws:lambda",
88 "provider:aws:s3",
89 "provider:aws:textract",
90 "live",
91 ],
92 },
93 () => {
94 beforeAll(
95 Effect.gen(function* () {
96 yield* Effect.logInfo(
97 "Textract test setup: destroying previous resources",
98 );
99 yield* sharedStack.destroy();
100
101 yield* Effect.logInfo("Textract test setup: deploying fixture");
102 const { functionUrl } = yield* sharedStack.deploy(
103 Effect.gen(function* () {
104 return yield* TextractTestFunction;
105 }).pipe(Effect.provide(TextractTestFunctionLive)),
106 );
107
108 expect(functionUrl).toBeTruthy();
109 baseUrl = functionUrl!.replace(/\/+$/, "");
110
111 yield* Effect.logInfo("Textract test setup: probing readiness");
112 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
113 Effect.flatMap((response) =>
114 response.status === 200
115 ? Effect.succeed(response)
116 : Effect.fail(
117 new Error(`Function not ready: ${response.status}`),
118 ),
119 ),
120 Effect.retry({ schedule: readinessPolicy }),
121 );
122
123 // Seed the S3 input object the async Start* routes analyze.
124 const seeded = yield* postJson<{ seeded: boolean }>("/seed");
125 expect(seeded.seeded).toBe(true);
126 }),
127 { timeout: 240_000 },
128 );
129
130 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
131 timeout: 180_000,
132 });
133
134 describe("synchronous analysis", () => {
135 test.provider(
136 "AnalyzeDocument detects the rendered text",
137 (_stack) =>
138 Effect.gen(function* () {
139 const response = yield* getJson<{
140 pages?: number;
141 lines: string[];
142 }>("/analyze-document");
143 expect(response.pages).toBe(1);
144 expect(response.lines).toContain(HELLO_PNG_TEXT);
145 }),
146 { timeout: 120_000 },
147 );
148
149 test.provider(
150 "AnalyzeExpense analyzes the document as a receipt",
151 (_stack) =>
152 Effect.gen(function* () {
153 const response = yield* getJson<{
154 pages?: number;
155 expenseDocuments: number;
156 }>("/analyze-expense");
157 expect(response.pages).toBe(1);
158 expect(response.expenseDocuments).toBeGreaterThanOrEqual(0);
159 }),
160 { timeout: 120_000 },
161 );
162
163 test.provider(
164 "AnalyzeID analyzes the document as an identity document",
165 (_stack) =>
166 Effect.gen(function* () {
167 const response = yield* getJson<{
168 identityDocuments: number;
169 }>("/analyze-id");
170 expect(response.identityDocuments).toBeGreaterThanOrEqual(1);
171 }),
172 { timeout: 120_000 },
173 );
174 });
175
176 describe("asynchronous jobs", () => {
177 test.provider(
178 "StartDocumentTextDetection + GetDocumentTextDetection run to completion",
179 (_stack) =>
180 Effect.gen(function* () {
181 const { jobId } = yield* postJson<{ jobId: string }>(
182 "/start-text-detection",
183 );
184 expect(jobId).toBeTruthy();
185
186 const result = yield* pollJob<{
187 jobStatus?: string;
188 lines: string[];
189 }>("/get-text-detection", jobId);
190 expect(result.jobStatus).toBe("SUCCEEDED");
191 expect(result.lines).toContain(HELLO_PNG_TEXT);
192 }),
193 { timeout: 180_000 },
194 );
195
196 test.provider(
197 "analysis, expense, and lending jobs run to completion",
198 (_stack) =>
199 Effect.gen(function* () {
200 const [analysis, expense, lending] = yield* Effect.all(
201 [
202 postJson<{ jobId: string }>("/start-analysis"),
203 postJson<{ jobId: string }>("/start-expense"),
204 postJson<{ jobId: string }>("/start-lending"),
205 ],
206 { concurrency: 3 },
207 );
208 expect(analysis.jobId).toBeTruthy();
209 expect(expense.jobId).toBeTruthy();
210 expect(lending.jobId).toBeTruthy();
211
212 const [analysisResult, expenseResult, lendingResult] =
213 yield* Effect.all(
214 [
215 pollJob<{ jobStatus?: string; blocks: number }>(
216 "/get-analysis",
217 analysis.jobId,
218 ),
219 pollJob<{ jobStatus?: string; expenseDocuments: number }>(
220 "/get-expense",
221 expense.jobId,
222 ),
223 pollJob<{ jobStatus?: string; results: number }>(
224 "/get-lending",
225 lending.jobId,
226 ),
227 ],
228 { concurrency: 3 },
229 );
230 expect(analysisResult.jobStatus).toBe("SUCCEEDED");
231 expect(analysisResult.blocks).toBeGreaterThan(0);
232 expect(expenseResult.jobStatus).toBe("SUCCEEDED");
233 expect(lendingResult.jobStatus).toBe("SUCCEEDED");
234
235 // Lending summary is available once the job completed.
236 const summary = yield* getJson<{
237 jobStatus?: string;
238 documentGroups: number;
239 }>(`/get-lending-summary?jobId=${lending.jobId}`);
240 expect(summary.jobStatus).toBe("SUCCEEDED");
241 expect(summary.documentGroups).toBeGreaterThanOrEqual(0);
242 }),
243 { timeout: 240_000 },
244 );
245 });
246
247 describe("adapter management", () => {
248 test.provider(
249 "GetAdapter, ListAdapters, and ListAdapterVersions read the deployed adapter",
250 (_stack) =>
251 Effect.gen(function* () {
252 const response = yield* getJson<{
253 adapterName?: string;
254 featureTypes?: string[];
255 listed: (string | undefined)[];
256 versionsCount: number;
257 }>("/adapters");
258 expect(response.adapterName).toBeTruthy();
259 expect(response.featureTypes).toEqual(["QUERIES"]);
260 expect(response.listed).toContain(response.adapterName);
261 expect(response.versionsCount).toBe(0);
262 }),
263 { timeout: 120_000 },
264 );
265
266 test.provider(
267 "adapter-version bindings surface typed errors (IAM grants proven)",
268 (_stack) =>
269 Effect.gen(function* () {
270 const probes = yield* getJson<{
271 getVersionProbe: string;
272 deleteVersionProbe: string;
273 createVersionProbe: string;
274 }>("/adapter-version-probes");
275 expect([
276 "ResourceNotFoundException",
277 "ValidationException",
278 ]).toContain(probes.getVersionProbe);
279 // DeleteAdapterVersion is idempotent — deleting a nonexistent
280 // version succeeds (verified live), which proves the grant.
281 expect([
282 "success",
283 "ResourceNotFoundException",
284 "ValidationException",
285 "ConflictException",
286 ]).toContain(probes.deleteVersionProbe);
287 expect([
288 "InvalidS3ObjectException",
289 "InvalidParameterException",
290 "ValidationException",
291 "LimitExceededException",
292 ]).toContain(probes.createVersionProbe);
293 }),
294 { timeout: 120_000 },
295 );
296 });
297 },
298);