1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as crm from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import type * as translate from "@distilled.cloud/gcp/translate_v3";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { callProbe, dockerAvailable, expectProbe } from "../bindingHost.ts";
9import TranslateBindingsHost, {
10 Dataset,
11 EnEs,
12 glossaryName,
13 Hello,
14 modelDataset,
15} from "./fixtures/bindings-host.ts";
16
17const testOptions = { providers: GCP.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "TranslateBindings");
20
21let baseUrl: string;
22let hostAccount: string;
23let project: string;
24let datasetName: string;
25let modelName: string | undefined;
26let entryName: string | undefined;
27
28/**
29 * Translation resources have no per-resource IAM policy: getters grant
30 * `roles/cloudtranslate.viewer` and translate calls
31 * `roles/cloudtranslate.user`, both on the project.
32 */
33const expectProjectGrants = Effect.gen(function* () {
34 const policy = yield* crm.getIamPolicyProjects({
35 resource: `projects/${project}`,
36 body: { options: { requestedPolicyVersion: 3 } },
37 });
38 const roles = (policy.bindings ?? [])
39 .filter((binding) =>
40 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
41 )
42 .map((binding) => ({ role: binding.role, condition: binding.condition }))
43 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? ""));
44 expect(roles).toEqual([
45 { role: "roles/cloudtranslate.user", condition: undefined },
46 { role: "roles/cloudtranslate.viewer", condition: undefined },
47 ]);
48});
49
50describe.skipIf(!dockerAvailable)(
51 "Translate Bindings",
52 {
53 tags: [
54 "provider:gcp",
55 "provider:gcp:translate",
56 "provider:gcp:run",
57 "live",
58 ],
59 },
60 () => {
61 beforeAll(
62 Effect.gen(function* () {
63 yield* sharedStack.destroy();
64 const out = yield* sharedStack.deploy(
65 Effect.gen(function* () {
66 const host = yield* TranslateBindingsHost;
67 const dataset = yield* Dataset;
68 return {
69 uri: host.uri,
70 serviceAccount: host.serviceAccount,
71 dataset: dataset.name,
72 project: dataset.project,
73 model: modelDataset ? (yield* EnEs).name : undefined,
74 entry: glossaryName ? (yield* Hello).name : undefined,
75 };
76 }),
77 );
78 baseUrl = out.uri!;
79 hostAccount = out.serviceAccount!;
80 datasetName = out.dataset;
81 project = out.project;
82 modelName = out.model;
83 entryName = out.entry;
84 }),
85 { timeout: 900_000 },
86 );
87
88 afterAll(sharedStack.destroy(), { timeout: 600_000 });
89
90 describe("GetAdaptiveMtDataset", () => {
91 test.provider(
92 "reads the dataset as the host's service account",
93 (_stack) =>
94 Effect.gen(function* () {
95 const live = yield* expectProbe<translate.AdaptiveMtDataset>(
96 baseUrl,
97 "getAdaptiveMtDataset",
98 );
99 expect(live.name).toEqual(datasetName);
100 expect(live.sourceLanguageCode).toEqual("en");
101 expect(live.targetLanguageCode).toEqual("es");
102 yield* expectProjectGrants;
103 }),
104 {
105 tags: ["provider:gcp", "provider:gcp:translate", "live"],
106 timeout: 600_000,
107 },
108 );
109 });
110
111 describe("AdaptiveMtTranslate", () => {
112 test.provider(
113 "reaches the API as the host's service account (empty dataset is rejected as too small)",
114 (_stack) =>
115 Effect.gen(function* () {
116 // The dataset has no sentence pairs, so an authorized call gets
117 // the typed "fewer than 5 sentences" rejection.
118 const outcome = yield* callProbe(baseUrl, "adaptiveMtTranslate");
119 expect(outcome.ok ? "ok" : outcome.error._tag).toEqual(
120 "AdaptiveMtDatasetTooSmall",
121 );
122 yield* expectProjectGrants;
123 }),
124 {
125 tags: ["provider:gcp", "provider:gcp:translate", "live"],
126 timeout: 600_000,
127 },
128 );
129 });
130
131 describe.skipIf(!modelDataset)("GetModel", () => {
132 test.provider(
133 "reads the model as the host's service account",
134 (_stack) =>
135 Effect.gen(function* () {
136 const live = yield* expectProbe<translate.Model>(
137 baseUrl,
138 "getModel",
139 );
140 expect(live.name).toEqual(modelName);
141 yield* expectProjectGrants;
142 }),
143 {
144 tags: ["provider:gcp", "provider:gcp:translate", "live"],
145 timeout: 600_000,
146 },
147 );
148 });
149
150 describe.skipIf(!modelDataset)("TranslateText", () => {
151 test.provider(
152 "translates with the model as the host's service account",
153 (_stack) =>
154 Effect.gen(function* () {
155 const out = yield* expectProbe<translate.TranslateTextResponse>(
156 baseUrl,
157 "translateText",
158 );
159 expect(out.translations?.length).toEqual(1);
160 expect(out.translations?.[0]?.translatedText).toBeTruthy();
161 yield* expectProjectGrants;
162 }),
163 {
164 tags: ["provider:gcp", "provider:gcp:translate", "live"],
165 timeout: 600_000,
166 },
167 );
168 });
169
170 describe.skipIf(!glossaryName)("GetGlossariesGlossaryEntry", () => {
171 test.provider(
172 "reads the glossary entry as the host's service account",
173 (_stack) =>
174 Effect.gen(function* () {
175 const live = yield* expectProbe<translate.GlossaryEntry>(
176 baseUrl,
177 "getGlossaryEntry",
178 );
179 expect(live.name).toEqual(entryName);
180 expect(live.termsPair?.targetTerm?.text).toEqual("hola");
181 yield* expectProjectGrants;
182 }),
183 {
184 tags: ["provider:gcp", "provider:gcp:translate", "live"],
185 timeout: 600_000,
186 },
187 );
188 });
189 },
190);