1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import * as Translate from "@/AWS/Translate";
5import * as Cause from "effect/Cause";
6import * as Context from "effect/Context";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import * as Schedule from "effect/Schedule";
12import { HttpServerRequest } from "effect/http/HttpServerRequest";
13import * as HttpServerResponse from "effect/http/HttpServerResponse";
14
15// Deterministic fixture names (bucket names are account-global; this suite
16// owns them in the testing account).
17export const FIXTURE_BUCKET_NAME = "alchemy-test-translate-bindings";
18export const TERMINOLOGY_NAME = "alchemy-translate-bindings-glossary";
19
20// Checked-in CSV terminology — never generated at test time. The first row
21// is the language-code header; each subsequent row is a term pair.
22export const TERMINOLOGY_CSV = ["en,es", "Alchemy,Alquimia"].join("\n");
23
24// Well-formed-but-nonexistent 32-hex-digit job id used to drive the typed
25// ResourceNotFoundException paths. An IAM gap would surface
26// AccessDeniedException (reported as `error` by the handler), so a typed
27// not-found tag proves the grant end-to-end.
28const BOGUS_JOB_ID = "00000000000000000000000000000000";
29
30export class TranslateTestFunction extends Lambda.Function<Lambda.Function>()(
31 "TranslateTestFunction",
32) {}
33
34/**
35 * Shared infrastructure for the Translate bindings fixture: the batch-job
36 * S3 bucket, the data-access role Translate assumes for batch jobs, and the
37 * custom terminology the routes reference by name.
38 */
39export class TranslateFixtures extends Context.Service<
40 TranslateFixtures,
41 {
42 bucket: S3.Bucket;
43 dataAccessRole: IAM.Role;
44 terminology: Translate.Terminology;
45 }
46>()("TranslateFixtures") {}
47
48export const TranslateFixturesLive = Layer.effect(
49 TranslateFixtures,
50 Effect.gen(function* () {
51 const bucket = yield* S3.Bucket("TranslateJobBucket", {
52 bucketName: FIXTURE_BUCKET_NAME,
53 forceDestroy: true,
54 });
55 // The role Amazon Translate assumes to read input documents and write
56 // batch translation results.
57 const dataAccessRole = yield* IAM.Role("TranslateDataAccessRole", {
58 assumeRolePolicyDocument: {
59 Version: "2012-10-17",
60 Statement: [
61 {
62 Effect: "Allow",
63 Principal: { Service: "translate.amazonaws.com" },
64 Action: ["sts:AssumeRole"],
65 },
66 ],
67 },
68 inlinePolicies: {
69 s3Access: {
70 Version: "2012-10-17",
71 Statement: [
72 {
73 Effect: "Allow",
74 Action: ["s3:GetObject", "s3:ListBucket", "s3:PutObject"],
75 Resource: [
76 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}`,
77 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}/*`,
78 ],
79 },
80 ],
81 },
82 },
83 });
84 const terminology = yield* Translate.Terminology("BindingsGlossary", {
85 terminologyName: TERMINOLOGY_NAME,
86 file: TERMINOLOGY_CSV,
87 format: "CSV",
88 });
89 return { bucket, dataAccessRole, terminology };
90 }),
91);
92
93export default TranslateTestFunction.make(
94 {
95 main: import.meta.url,
96 functionUrl: true,
97 // The job lifecycle route retries StartTextTranslationJob through
98 // fresh-role IAM propagation (bounded ~40s).
99 timeout: Duration.seconds(120),
100 },
101 Effect.gen(function* () {
102 const { bucket, dataAccessRole } = yield* TranslateFixtures;
103
104 // --- bindings under test ---
105 const translateText = yield* Translate.TranslateText();
106 const translateDocument = yield* Translate.TranslateDocument();
107 const listLanguages = yield* Translate.ListLanguages();
108 const getTerminology = yield* Translate.GetTerminology();
109 const listTerminologies = yield* Translate.ListTerminologies();
110 const getParallelData = yield* Translate.GetParallelData();
111 const listParallelData = yield* Translate.ListParallelData();
112 const startTextTranslationJob =
113 yield* Translate.StartTextTranslationJob(dataAccessRole);
114 const describeTextTranslationJob =
115 yield* Translate.DescribeTextTranslationJob();
116 const stopTextTranslationJob = yield* Translate.StopTextTranslationJob();
117 const listTextTranslationJobs = yield* Translate.ListTextTranslationJobs();
118 const putObject = yield* S3.PutObject(bucket);
119
120 return {
121 fetch: Effect.gen(function* () {
122 const request = yield* HttpServerRequest;
123 const url = new URL(request.originalUrl);
124 const pathname = url.pathname;
125
126 // Cheap readiness route — no Translate call.
127 if (request.method === "GET" && pathname === "/ping") {
128 return yield* HttpServerResponse.json({ ok: true });
129 }
130
131 // TranslateText — plain and with the fixture terminology applied.
132 if (request.method === "GET" && pathname === "/translate") {
133 const basic = yield* translateText({
134 Text: "Hello, world!",
135 SourceLanguageCode: "en",
136 TargetLanguageCode: "es",
137 });
138 // NOTE: with "Alchemy" as the sentence-initial subject the model
139 // treats it as a brand name and declines to substitute the term;
140 // mid-sentence it applies reliably (verified with the raw API).
141 const withTerminology = yield* translateText({
142 Text: "I love Alchemy so much.",
143 SourceLanguageCode: "en",
144 TargetLanguageCode: "es",
145 TerminologyNames: [TERMINOLOGY_NAME],
146 });
147 return yield* HttpServerResponse.json({
148 basic: basic.TranslatedText,
149 withTerminology: withTerminology.TranslatedText,
150 appliedTerminologies: (
151 withTerminology.AppliedTerminologies ?? []
152 ).map((t) => t.Name),
153 });
154 }
155
156 // TranslateDocument — plain-text document, decode the Redacted blob.
157 if (request.method === "GET" && pathname === "/document") {
158 const result = yield* translateDocument({
159 Document: {
160 Content: new TextEncoder().encode("Good morning, friend."),
161 ContentType: "text/plain",
162 },
163 SourceLanguageCode: "en",
164 TargetLanguageCode: "es",
165 });
166 const content = result.TranslatedDocument.Content;
167 const bytes = Redacted.isRedacted(content)
168 ? Redacted.value(content)
169 : content;
170 return yield* HttpServerResponse.json({
171 translated: new TextDecoder().decode(bytes),
172 sourceLanguageCode: result.SourceLanguageCode,
173 targetLanguageCode: result.TargetLanguageCode,
174 });
175 }
176
177 if (request.method === "GET" && pathname === "/languages") {
178 const result = yield* listLanguages({ MaxResults: 500 });
179 return yield* HttpServerResponse.json({
180 count: (result.Languages ?? []).length,
181 codes: (result.Languages ?? []).map((l) => l.LanguageCode),
182 });
183 }
184
185 // GetTerminology + ListTerminologies against the fixture glossary.
186 if (request.method === "GET" && pathname === "/terminologies") {
187 const got = yield* getTerminology({ Name: TERMINOLOGY_NAME });
188 const listed = yield* listTerminologies({ MaxResults: 100 });
189 return yield* HttpServerResponse.json({
190 name: got.TerminologyProperties?.Name ?? null,
191 termCount: got.TerminologyProperties?.TermCount ?? null,
192 sourceLanguageCode:
193 got.TerminologyProperties?.SourceLanguageCode ?? null,
194 downloadLocation:
195 got.TerminologyDataLocation?.RepositoryType ?? null,
196 listedNames: (listed.TerminologyPropertiesList ?? []).flatMap(
197 (t) => (t.Name ? [t.Name] : []),
198 ),
199 });
200 }
201
202 // ListParallelData + GetParallelData's typed not-found path.
203 if (request.method === "GET" && pathname === "/parallel-data") {
204 const listed = yield* listParallelData({ MaxResults: 100 });
205 const missingTag = yield* getParallelData({
206 Name: "alchemy-nonexistent-parallel-data",
207 }).pipe(
208 Effect.map(() => "Found"),
209 Effect.catchTag("ResourceNotFoundException", (e) =>
210 Effect.succeed(e._tag),
211 ),
212 );
213 return yield* HttpServerResponse.json({
214 listedCount: (listed.ParallelDataPropertiesList ?? []).length,
215 missingTag,
216 });
217 }
218
219 // ListTextTranslationJobs + the Describe/Stop typed not-found paths.
220 if (request.method === "GET" && pathname === "/jobs") {
221 const listed = yield* listTextTranslationJobs({ MaxResults: 5 });
222 const describeTag = yield* describeTextTranslationJob({
223 JobId: BOGUS_JOB_ID,
224 }).pipe(
225 Effect.map(() => "Found"),
226 Effect.catchTag("ResourceNotFoundException", (e) =>
227 Effect.succeed(e._tag),
228 ),
229 );
230 const stopTag = yield* stopTextTranslationJob({
231 JobId: BOGUS_JOB_ID,
232 }).pipe(
233 Effect.map(() => "Stopped"),
234 Effect.catchTag("ResourceNotFoundException", (e) =>
235 Effect.succeed(e._tag),
236 ),
237 );
238 return yield* HttpServerResponse.json({
239 listedCount: (listed.TextTranslationJobPropertiesList ?? []).length,
240 describeTag,
241 stopTag,
242 });
243 }
244
245 // Full real lifecycle for a batch translation job: seed the input
246 // object, start the job, describe it, then stop it (the job never
247 // needs to complete — that takes tens of minutes).
248 if (request.method === "POST" && pathname === "/job-lifecycle") {
249 yield* putObject({
250 Key: "translate-input/greeting.txt",
251 Body: "Hello, world!\nAlchemy deploys infrastructure.\n",
252 ContentType: "text/plain",
253 });
254 // A freshly created data-access role can take a few seconds to
255 // become assumable by Translate — surfaced as
256 // InvalidParameterValueException/InvalidRequestException.
257 // Bounded retry (8 × 5s).
258 const started = yield* startTextTranslationJob({
259 JobName: "alchemy-translate-bindings-lifecycle",
260 InputDataConfig: {
261 S3Uri: `s3://${FIXTURE_BUCKET_NAME}/translate-input/`,
262 ContentType: "text/plain",
263 },
264 OutputDataConfig: {
265 S3Uri: `s3://${FIXTURE_BUCKET_NAME}/translate-output/`,
266 },
267 SourceLanguageCode: "en",
268 TargetLanguageCodes: ["es"],
269 }).pipe(
270 Effect.retry({
271 while: (e): boolean =>
272 e._tag === "InvalidParameterValueException" ||
273 e._tag === "InvalidRequestException",
274 schedule: Schedule.spaced("5 seconds"),
275 times: 8,
276 }),
277 );
278 const jobId = started.JobId ?? "";
279 const described = yield* describeTextTranslationJob({
280 JobId: jobId,
281 });
282 const stopped = yield* stopTextTranslationJob({ JobId: jobId });
283 return yield* HttpServerResponse.json({
284 jobId,
285 startStatus: started.JobStatus ?? null,
286 describedStatus:
287 described.TextTranslationJobProperties?.JobStatus ?? null,
288 stopStatus: stopped.JobStatus ?? null,
289 });
290 }
291
292 return yield* HttpServerResponse.json(
293 { error: "Not found", method: request.method, pathname },
294 { status: 404 },
295 );
296 }).pipe(
297 // Surface every failure (typed error or defect) to the test as JSON
298 // instead of an opaque 500 — the test asserts `error` is absent.
299 Effect.catchCause((cause) =>
300 HttpServerResponse.json({ error: Cause.pretty(cause) }),
301 ),
302 ),
303 };
304 }).pipe(
305 Effect.provide(
306 Layer.mergeAll(
307 Translate.TranslateTextHttp,
308 Translate.TranslateDocumentHttp,
309 Translate.ListLanguagesHttp,
310 Translate.GetTerminologyHttp,
311 Translate.ListTerminologiesHttp,
312 Translate.GetParallelDataHttp,
313 Translate.ListParallelDataHttp,
314 Translate.StartTextTranslationJobHttp,
315 Translate.DescribeTextTranslationJobHttp,
316 Translate.StopTextTranslationJobHttp,
317 Translate.ListTextTranslationJobsHttp,
318 S3.PutObjectHttp,
319 TranslateFixturesLive,
320 ),
321 ),
322 ),
323);