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 TranslateTestFunctionLive, {
11 TERMINOLOGY_NAME,
12 TranslateTestFunction,
13} from "./handler.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "TranslateBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33// The shared Lambda fixture occasionally answers a transient 5xx under load
34// (cold re-init, IAM propagation). Retry only 5xx; a genuine 4xx/assertion
35// failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("2 seconds"),
53 Schedule.recurs(5),
54 ]),
55 }),
56 );
57
58describe.sequential(
59 "Translate Bindings",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:iam",
64 "provider:aws:lambda",
65 "provider:aws:s3",
66 "provider:aws:translate",
67 "live",
68 ],
69 },
70 () => {
71 beforeAll(
72 Effect.gen(function* () {
73 yield* Effect.logInfo(
74 "Translate test setup: destroying previous resources",
75 );
76 yield* sharedStack.destroy();
77
78 yield* Effect.logInfo("Translate test setup: deploying fixture");
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* TranslateTestFunction;
82 }).pipe(Effect.provide(TranslateTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87
88 const readinessUrl = `${baseUrl}/ping`;
89 yield* Effect.logInfo(
90 `Translate test setup: probing readiness at ${readinessUrl}`,
91 );
92 yield* HttpClient.get(readinessUrl).pipe(
93 Effect.flatMap((response) =>
94 response.status === 200
95 ? Effect.succeed(response)
96 : Effect.fail(
97 new Error(`Function not ready: ${response.status}`),
98 ),
99 ),
100 Effect.tapError((error) =>
101 Effect.logWarning(
102 `Translate test setup: fixture not ready yet (${String(error)})`,
103 ),
104 ),
105 Effect.retry({ schedule: readinessPolicy }),
106 );
107 }),
108 { timeout: 300_000 },
109 );
110
111 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
112 timeout: 180_000,
113 });
114
115 describe("TranslateText", () => {
116 test.provider(
117 "translates text and applies the fixture terminology",
118 (_stack) =>
119 Effect.gen(function* () {
120 // A freshly imported terminology can take a moment to propagate to
121 // TranslateText — poll the route (bounded) until the custom term
122 // lands in the translation.
123 const response = (yield* send(
124 HttpClientRequest.get(`${baseUrl}/translate`),
125 ).pipe(
126 Effect.flatMap((r) => r.json),
127 Effect.repeat({
128 schedule: Schedule.spaced("5 seconds"),
129 until: (r): boolean =>
130 (r as { withTerminology?: string }).withTerminology?.includes(
131 "Alquimia",
132 ) === true,
133 times: 8,
134 }),
135 )) as {
136 error?: string;
137 basic: string;
138 withTerminology: string;
139 appliedTerminologies: string[];
140 };
141
142 expect(response.error).toBeUndefined();
143 expect(response.basic.toLowerCase()).toContain("hola");
144 expect(response.withTerminology).toContain("Alquimia");
145 expect(response.appliedTerminologies).toContain(TERMINOLOGY_NAME);
146 }),
147 { timeout: 180_000 },
148 );
149 });
150
151 describe("TranslateDocument", () => {
152 test.provider(
153 "translates a plain-text document and decodes the redacted content",
154 (_stack) =>
155 Effect.gen(function* () {
156 const response = (yield* send(
157 HttpClientRequest.get(`${baseUrl}/document`),
158 ).pipe(Effect.flatMap((r) => r.json))) as {
159 error?: string;
160 translated: string;
161 sourceLanguageCode: string;
162 targetLanguageCode: string;
163 };
164
165 expect(response.error).toBeUndefined();
166 expect(response.translated.toLowerCase()).toContain("buenos");
167 expect(response.sourceLanguageCode).toBe("en");
168 expect(response.targetLanguageCode).toBe("es");
169 }),
170 { timeout: 120_000 },
171 );
172 });
173
174 describe("ListLanguages", () => {
175 test.provider(
176 "lists supported languages including Spanish",
177 (_stack) =>
178 Effect.gen(function* () {
179 const response = (yield* send(
180 HttpClientRequest.get(`${baseUrl}/languages`),
181 ).pipe(Effect.flatMap((r) => r.json))) as {
182 error?: string;
183 count: number;
184 codes: string[];
185 };
186
187 expect(response.error).toBeUndefined();
188 expect(response.count).toBeGreaterThan(10);
189 expect(response.codes).toContain("es");
190 }),
191 { timeout: 120_000 },
192 );
193 });
194
195 describe("GetTerminology + ListTerminologies", () => {
196 test.provider(
197 "reads the fixture glossary and lists it",
198 (_stack) =>
199 Effect.gen(function* () {
200 const response = (yield* send(
201 HttpClientRequest.get(`${baseUrl}/terminologies`),
202 ).pipe(Effect.flatMap((r) => r.json))) as {
203 error?: string;
204 name: string | null;
205 termCount: number | null;
206 sourceLanguageCode: string | null;
207 downloadLocation: string | null;
208 listedNames: string[];
209 };
210
211 expect(response.error).toBeUndefined();
212 expect(response.name).toBe(TERMINOLOGY_NAME);
213 expect(response.termCount).toBe(1);
214 expect(response.sourceLanguageCode).toBe("en");
215 expect(response.listedNames).toContain(TERMINOLOGY_NAME);
216 }),
217 { timeout: 120_000 },
218 );
219 });
220
221 describe("ListParallelData + GetParallelData", () => {
222 test.provider(
223 "lists parallel data and drives the typed not-found path",
224 (_stack) =>
225 Effect.gen(function* () {
226 const response = (yield* send(
227 HttpClientRequest.get(`${baseUrl}/parallel-data`),
228 ).pipe(Effect.flatMap((r) => r.json))) as {
229 error?: string;
230 listedCount: number;
231 missingTag: string;
232 };
233
234 expect(response.error).toBeUndefined();
235 expect(response.listedCount).toBeGreaterThanOrEqual(0);
236 expect(response.missingTag).toBe("ResourceNotFoundException");
237 }),
238 { timeout: 120_000 },
239 );
240 });
241
242 describe("ListTextTranslationJobs + Describe/Stop not-found", () => {
243 test.provider(
244 "lists jobs and drives the typed not-found paths",
245 (_stack) =>
246 Effect.gen(function* () {
247 const response = (yield* send(
248 HttpClientRequest.get(`${baseUrl}/jobs`),
249 ).pipe(Effect.flatMap((r) => r.json))) as {
250 error?: string;
251 listedCount: number;
252 describeTag: string;
253 stopTag: string;
254 };
255
256 expect(response.error).toBeUndefined();
257 expect(response.listedCount).toBeGreaterThanOrEqual(0);
258 expect(response.describeTag).toBe("ResourceNotFoundException");
259 expect(response.stopTag).toBe("ResourceNotFoundException");
260 }),
261 { timeout: 120_000 },
262 );
263 });
264
265 describe("StartTextTranslationJob + DescribeTextTranslationJob + StopTextTranslationJob", () => {
266 test.provider(
267 "starts a real batch job, describes it, and stops it",
268 (_stack) =>
269 Effect.gen(function* () {
270 const response = (yield* send(
271 HttpClientRequest.post(`${baseUrl}/job-lifecycle`),
272 ).pipe(Effect.flatMap((r) => r.json))) as {
273 error?: string;
274 jobId: string;
275 startStatus: string | null;
276 describedStatus: string | null;
277 stopStatus: string | null;
278 };
279
280 expect(response.error).toBeUndefined();
281 expect(response.jobId).toMatch(/^[0-9a-f]{32}$/);
282 expect(["SUBMITTED", "IN_PROGRESS"]).toContain(
283 response.startStatus,
284 );
285 expect(response.describedStatus).toBeTruthy();
286 expect([
287 "STOP_REQUESTED",
288 "STOPPED",
289 "COMPLETED",
290 "SUBMITTED",
291 "IN_PROGRESS",
292 ]).toContain(response.stopStatus);
293 }),
294 { timeout: 120_000 },
295 );
296 });
297 },
298);