1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import { Credentials } from "@distilled.cloud/gcp/Credentials";
4import * as translate from "@distilled.cloud/gcp/translate_v3";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import { location, logLevel, currentParent } from "./common.ts";
12
13const { test } = Test.make({ providers: GCP.providers() });
14
15const GLOSSARY_ID = "alctrglos1";
16
17const waitUntilGone = (name: string) =>
18 translate.getProjectsLocationsGlossariesGlossaryEntries({ name }).pipe(
19 Effect.as("found" as const),
20 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
21 Effect.repeat({
22 schedule: Schedule.spaced("1 second"),
23 until: (status) => status === "gone",
24 times: 10,
25 }),
26 );
27
28const waitUntilGlossaryGone = (name: string) =>
29 translate.getProjectsLocationsGlossaries({ name }).pipe(
30 Effect.as("found" as const),
31 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
32 Effect.repeat({
33 schedule: Schedule.spaced("1 second"),
34 until: (status) => status === "gone",
35 times: 10,
36 }),
37 );
38
39const uploadObject = (bucketName: string, object: string, body: string) =>
40 Effect.gen(function* () {
41 const credentials = yield* Credentials;
42 const creds = yield* credentials;
43 const client = yield* HttpClient.HttpClient;
44 const bytes = yield* Effect.sync(() => new TextEncoder().encode(body));
45 const url =
46 `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o` +
47 `?uploadType=media&name=${encodeURIComponent(object)}`;
48 const response = yield* client.execute(
49 HttpClientRequest.post(url).pipe(
50 HttpClientRequest.setHeader(
51 "Authorization",
52 `Bearer ${Redacted.value(creds.accessToken)}`,
53 ),
54 HttpClientRequest.bodyUint8Array(bytes, "text/tab-separated-values"),
55 ),
56 );
57 if (response.status < 200 || response.status >= 300) {
58 const text = yield* response.text.pipe(
59 Effect.catch(() => Effect.succeed("")),
60 );
61 return yield* Effect.fail(
62 new Error(`object upload failed: ${response.status} ${text}`),
63 );
64 }
65 });
66
67const glossaryNameFromOperation = (operation: translate.Operation) => {
68 const response = operation.response ?? {};
69 const name = response.name;
70 return typeof name === "string" && name.length > 0 ? name : undefined;
71};
72
73const createGlossary = (glossaryId: string, inputUri?: string) =>
74 Effect.gen(function* () {
75 const parent = yield* currentParent;
76 const name = `${parent}/glossaries/${glossaryId}`;
77 const existing = yield* translate
78 .getProjectsLocationsGlossaries({ name })
79 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
80 if (existing !== undefined) return existing;
81 const operation = yield* translate
82 .createProjectsLocationsGlossaries({
83 parent,
84 body: {
85 name,
86 languagePair: {
87 sourceLanguageCode: "en",
88 targetLanguageCode: "es",
89 },
90 ...(inputUri ? { inputConfig: { gcsSource: { inputUri } } } : {}),
91 },
92 })
93 .pipe(
94 Effect.catchTag("Conflict", () =>
95 Effect.succeed({ name, done: true } as translate.Operation),
96 ),
97 );
98 const done = yield* GCP.Translate.waitForOperation(operation);
99 const fromOperation = glossaryNameFromOperation(done);
100 if (fromOperation !== undefined) return fromOperation;
101 return yield* translate.getProjectsLocationsGlossaries({ name });
102 });
103
104const deleteGlossary = (name: string) =>
105 translate.deleteProjectsLocationsGlossaries({ name }).pipe(
106 Effect.flatMap((operation) =>
107 GCP.Translate.waitForOperation(operation, { notFoundOk: true }),
108 ),
109 Effect.catchTag("NotFound", () => Effect.void),
110 );
111
112test.provider(
113 "getProjectsLocationsGlossariesGlossaryEntries on a missing entry fails with a typed tag",
114 (stack) =>
115 Effect.gen(function* () {
116 const parent = yield* currentParent;
117 yield* stack.destroy();
118
119 const error = yield* Effect.flip(
120 translate.getProjectsLocationsGlossariesGlossaryEntries({
121 name: `${parent}/glossaries/alchemy-missing/glossaryEntries/123`,
122 }),
123 );
124 expect(error._tag).toEqual("NotFound");
125
126 yield* stack.destroy();
127 }).pipe(logLevel),
128 { tags: ["provider:gcp", "provider:gcp:translate", "live"], timeout: 90_000 },
129);
130
131test.provider(
132 "create, update, and delete a glossary entry",
133 (stack) =>
134 Effect.gen(function* () {
135 const parent = yield* currentParent;
136 yield* stack.destroy();
137
138 const bucket = yield* stack.deploy(
139 Effect.gen(function* () {
140 return yield* GCP.Storage.Bucket("GlossarySrc", {
141 location: "US-CENTRAL1",
142 forceDestroy: true,
143 });
144 }),
145 );
146 yield* uploadObject(bucket.bucketName, "glossary.tsv", "hello\thola\n");
147
148 const glossary = yield* createGlossary(
149 GLOSSARY_ID,
150 `gs://${bucket.bucketName}/glossary.tsv`,
151 );
152 const glossaryName =
153 typeof glossary === "string"
154 ? glossary
155 : (glossary.name ?? `${parent}/glossaries/${GLOSSARY_ID}`);
156
157 const created = yield* stack.deploy(
158 Effect.gen(function* () {
159 const nextBucket = yield* GCP.Storage.Bucket("GlossarySrc", {
160 bucketName: bucket.bucketName,
161 location: "US-CENTRAL1",
162 forceDestroy: true,
163 });
164 const entry = yield* GCP.Translate.GlossariesGlossaryEntry("Hello", {
165 parent: glossaryName,
166 location,
167 description: "greeting",
168 termsPair: {
169 sourceTerm: { languageCode: "en", text: "hello" },
170 targetTerm: { languageCode: "es", text: "hola" },
171 },
172 });
173 return { bucket: nextBucket, entry };
174 }),
175 );
176
177 expect(created.entry.name).toContain("/glossaryEntries/");
178 expect(created.entry.parent).toEqual(glossaryName);
179 expect(created.entry.description).toEqual("greeting");
180 expect(created.entry.termsPair?.sourceTerm?.text).toEqual("hello");
181 expect(created.entry.termsPair?.targetTerm?.text).toEqual("hola");
182
183 const fetched =
184 yield* translate.getProjectsLocationsGlossariesGlossaryEntries({
185 name: created.entry.name,
186 });
187 expect(fetched.name).toEqual(created.entry.name);
188 expect(fetched.description).toEqual("greeting");
189 expect(fetched.termsPair?.targetTerm?.text).toEqual("hola");
190
191 const updated = yield* stack.deploy(
192 Effect.gen(function* () {
193 const nextBucket = yield* GCP.Storage.Bucket("GlossarySrc", {
194 bucketName: bucket.bucketName,
195 location: "US-CENTRAL1",
196 forceDestroy: true,
197 });
198 const entry = yield* GCP.Translate.GlossariesGlossaryEntry("Hello", {
199 parent: glossaryName,
200 location,
201 glossaryEntryId: created.entry.glossaryEntryId,
202 description: "greeting",
203 termsPair: {
204 sourceTerm: { languageCode: "en", text: "hello" },
205 targetTerm: { languageCode: "es", text: "buenas" },
206 },
207 });
208 return { bucket: nextBucket, entry };
209 }),
210 );
211
212 expect(updated.entry.name).toEqual(created.entry.name);
213 expect(updated.entry.termsPair?.targetTerm?.text).toEqual("buenas");
214
215 const patched =
216 yield* translate.getProjectsLocationsGlossariesGlossaryEntries({
217 name: created.entry.name,
218 });
219 expect(patched.termsPair?.targetTerm?.text).toEqual("buenas");
220
221 yield* stack.destroy();
222 const gone = yield* waitUntilGone(created.entry.name);
223 expect(gone).toEqual("gone");
224
225 yield* deleteGlossary(glossaryName);
226 const glossaryGone = yield* waitUntilGlossaryGone(glossaryName);
227 expect(glossaryGone).toEqual("gone");
228 }).pipe(logLevel),
229 {
230 tags: ["provider:gcp", "provider:gcp:translate", "live"],
231 timeout: 120_000,
232 },
233);