1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import * as Core from "@/Test/Core";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as personalize from "@distilled.cloud/aws/personalize";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import PersonalizeTestFunctionLive, {
12 PersonalizeTestFunction,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "PersonalizeBindings");
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;
27let resourceArns:
28 | {
29 group: string;
30 tracker: string;
31 datasets: string[];
32 schemas: string[];
33 }
34 | undefined;
35
36// Well-formed-but-nonexistent ARNs the probe routes are driven against —
37// training a solution/campaign takes ~an hour of paid compute, so the
38// campaign/solution routes only prove the IAM bind + typed error decode.
39const probeArns = Effect.gen(function* () {
40 const { accountId, region } = yield* AWSEnvironment.current;
41 return {
42 dataset: `arn:aws:personalize:${region}:${accountId}:dataset/alchemy-probe/INTERACTIONS`,
43 importJob: `arn:aws:personalize:${region}:${accountId}:dataset-import-job/alchemy-probe`,
44 datasetGroup: `arn:aws:personalize:${region}:${accountId}:dataset-group/alchemy-probe`,
45 solution: `arn:aws:personalize:${region}:${accountId}:solution/alchemy-probe`,
46 solutionVersion: `arn:aws:personalize:${region}:${accountId}:solution/alchemy-probe/0123456789abcdef`,
47 campaign: `arn:aws:personalize:${region}:${accountId}:campaign/alchemy-probe`,
48 batchJob: `arn:aws:personalize:${region}:${accountId}:batch-inference-job/alchemy-probe`,
49 };
50});
51
52// A nonexistent target answers ResourceNotFoundException; malformed-but-
53// plausible inputs can surface InvalidInputException. Both are typed tags
54// decoded inside the Lambda — an IAM gap or schema break would surface a
55// different tag (AccessDeniedException / UnknownAwsError) and fail.
56const EXPECTED_PROBE_TAGS = [
57 "ResourceNotFoundException",
58 "InvalidInputException",
59];
60
61const getJson = (path: string) =>
62 HttpClient.get(`${baseUrl}${path}`).pipe(
63 Effect.flatMap((response) =>
64 response.status >= 500
65 ? Effect.fail(new Error(`transient upstream ${response.status}`))
66 : Effect.succeed(response),
67 ),
68 Effect.retry({
69 schedule: Schedule.max([
70 Schedule.exponential("500 millis"),
71 Schedule.recurs(6),
72 ]),
73 }),
74 Effect.flatMap((r) => r.json),
75 );
76
77// IAM policy statements on a freshly-created role can take up to ~a minute to
78// propagate to the Personalize data plane, surfacing as a transient
79// AccessDeniedException tag. Retry (bounded) until a different tag appears —
80// a genuine IAM gap still fails the assertion with the AccessDeniedException
81// tag after the retries are exhausted.
82const routeTag = (path: string) =>
83 getJson(path).pipe(
84 Effect.map((response) => (response as { tag: string }).tag),
85 Effect.repeat({
86 schedule: Schedule.spaced("3 seconds"),
87 until: (tag: string): boolean => tag !== "AccessDeniedException",
88 times: 20,
89 }),
90 );
91
92const probeTag = (path: string, arn: string) =>
93 routeTag(`${path}?arn=${encodeURIComponent(arn)}`);
94
95// Create-probes that pass a data-access role: use a nonexistent role in OUR
96// account so the service validates (and 404s) the primary resource rather
97// than tripping cross-account PassRole checks.
98const probeTagWithRole = (path: string, arn: string) =>
99 Effect.gen(function* () {
100 const { accountId } = yield* AWSEnvironment.current;
101 const role = `arn:aws:iam::${accountId}:role/alchemy-nonexistent-probe-role`;
102 return yield* routeTag(
103 `${path}?arn=${encodeURIComponent(arn)}&role=${encodeURIComponent(role)}`,
104 );
105 });
106
107describe.sequential(
108 "Personalize Bindings",
109 {
110 tags: [
111 "provider:aws",
112 "provider:aws:lambda",
113 "provider:aws:personalize",
114 "live",
115 ],
116 },
117 () => {
118 beforeAll(
119 Effect.gen(function* () {
120 yield* Effect.logInfo(
121 "Personalize test setup: destroying previous resources",
122 );
123 yield* sharedStack.destroy();
124
125 yield* Effect.logInfo("Personalize test setup: deploying fixture");
126 const { functionUrl } = yield* sharedStack.deploy(
127 Effect.gen(function* () {
128 return yield* PersonalizeTestFunction;
129 }).pipe(Effect.provide(PersonalizeTestFunctionLive)),
130 );
131
132 // Resolve this generation's references before destroy removes its state.
133 const refOptions = {
134 stack: sharedStack.name,
135 stage: sharedStack.stage,
136 };
137 const group = yield* AWS.Personalize.DatasetGroup.ref(
138 "BindingsGroup",
139 refOptions,
140 );
141 const tracker = yield* AWS.Personalize.EventTracker.ref(
142 "Tracker",
143 refOptions,
144 );
145 const datasets = yield* Effect.forEach(
146 ["Interactions", "Items", "Users"],
147 (id) => AWS.Personalize.Dataset.ref(id, refOptions),
148 );
149 const schemas = yield* Effect.forEach(
150 ["InteractionsSchema", "ItemsSchema", "UsersSchema"],
151 (id) => AWS.Personalize.Schema.ref(id, refOptions),
152 );
153 resourceArns = yield* Effect.all({
154 group: Output.evaluate(group.datasetGroupArn, {}),
155 tracker: Output.evaluate(tracker.eventTrackerArn, {}),
156 datasets: Effect.forEach(datasets, (dataset) =>
157 Output.evaluate(dataset.datasetArn, {}),
158 ),
159 schemas: Effect.forEach(schemas, (schema) =>
160 Output.evaluate(schema.schemaArn, {}),
161 ),
162 }).pipe(Effect.provide(sharedStack.state));
163
164 expect(functionUrl).toBeTruthy();
165 baseUrl = functionUrl!.replace(/\/+$/, "");
166
167 const readinessUrl = `${baseUrl}/bindings`;
168 yield* HttpClient.get(readinessUrl).pipe(
169 Effect.flatMap((response) =>
170 response.status === 200
171 ? Effect.succeed(response)
172 : Effect.fail(
173 new Error(`Function not ready: ${response.status}`),
174 ),
175 ),
176 Effect.retry({ schedule: readinessPolicy }),
177 );
178 }),
179 { timeout: 480_000 },
180 );
181
182 // Verify only resources owned by this fixture generation. Old runs with the
183 // same stack-name prefix must not hold this run's cleanup open.
184 const assertPersonalizeResourcesGone = Core.withProviders(
185 Effect.gen(function* () {
186 if (!resourceArns) return;
187 yield* Effect.forEach(
188 [
189 personalize
190 .describeDatasetGroup({
191 datasetGroupArn: resourceArns.group,
192 })
193 .pipe(Effect.asVoid),
194 personalize
195 .describeEventTracker({
196 eventTrackerArn: resourceArns.tracker,
197 })
198 .pipe(Effect.asVoid),
199 ...resourceArns.datasets.map((datasetArn) =>
200 personalize.describeDataset({ datasetArn }).pipe(Effect.asVoid),
201 ),
202 ...resourceArns.schemas.map((schemaArn) =>
203 personalize.describeSchema({ schemaArn }).pipe(Effect.asVoid),
204 ),
205 ],
206 (describe) =>
207 describe.pipe(
208 Effect.as(false),
209 Effect.catchTag("ResourceNotFoundException", () =>
210 Effect.succeed(true),
211 ),
212 Effect.repeat({
213 schedule: Schedule.spaced("2 seconds"),
214 until: (gone) => gone,
215 times: 10,
216 }),
217 Effect.tap((gone) => Effect.sync(() => expect(gone).toBe(true))),
218 ),
219 { concurrency: 4 },
220 );
221 }),
222 testOptions,
223 sharedStack.name,
224 );
225
226 afterAll(
227 sharedStack
228 .destroy()
229 .pipe(Effect.andThen(assertPersonalizeResourcesGone)),
230 { timeout: 420_000 },
231 );
232
233 describe("binding registration", () => {
234 test.provider("all nineteen capabilities initialize in the runtime", () =>
235 Effect.gen(function* () {
236 const response = (yield* getJson("/bindings")) as { bound: string[] };
237 expect(response.bound).toHaveLength(19);
238 }),
239 );
240 });
241
242 describe("PutEvents", () => {
243 test.provider("records a real event through the event tracker", () =>
244 Effect.gen(function* () {
245 const tag = yield* routeTag("/put-events");
246 expect(tag).toBe("Recorded");
247 }),
248 );
249 });
250
251 describe("PutItems", () => {
252 test.provider("upserts a real item into the Items dataset", () =>
253 Effect.gen(function* () {
254 const tag = yield* routeTag("/put-items");
255 expect(tag).toBe("Recorded");
256 }),
257 );
258 });
259
260 describe("PutUsers", () => {
261 test.provider("upserts a real user into the Users dataset", () =>
262 Effect.gen(function* () {
263 const tag = yield* routeTag("/put-users");
264 expect(tag).toBe("Recorded");
265 }),
266 );
267 });
268
269 describe("PutActions", () => {
270 test.provider(
271 "surfaces a typed tag when the bound dataset is not an Actions dataset",
272 () =>
273 Effect.gen(function* () {
274 const tag = yield* routeTag("/put-actions-probe");
275 expect(EXPECTED_PROBE_TAGS).toContain(tag);
276 }),
277 );
278 });
279
280 describe("PutActionInteractions", () => {
281 test.provider(
282 "surfaces a typed tag without an Action interactions dataset",
283 () =>
284 Effect.gen(function* () {
285 const tag = yield* routeTag("/put-action-interactions-probe");
286 expect(EXPECTED_PROBE_TAGS).toContain(tag);
287 }),
288 );
289 });
290
291 describe("GetRecommendations", () => {
292 test.provider("surfaces a typed tag for a nonexistent campaign", () =>
293 Effect.gen(function* () {
294 const arns = yield* probeArns;
295 const tag = yield* probeTag("/recommendations-probe", arns.campaign);
296 expect(EXPECTED_PROBE_TAGS).toContain(tag);
297 }),
298 );
299 });
300
301 describe("GetPersonalizedRanking", () => {
302 test.provider("surfaces a typed tag for a nonexistent campaign", () =>
303 Effect.gen(function* () {
304 const arns = yield* probeArns;
305 const tag = yield* probeTag("/ranking-probe", arns.campaign);
306 expect(EXPECTED_PROBE_TAGS).toContain(tag);
307 }),
308 );
309 });
310
311 describe("GetActionRecommendations", () => {
312 test.provider("surfaces a typed tag for a nonexistent campaign", () =>
313 Effect.gen(function* () {
314 const arns = yield* probeArns;
315 const tag = yield* probeTag(
316 "/action-recommendations-probe",
317 arns.campaign,
318 );
319 expect(EXPECTED_PROBE_TAGS).toContain(tag);
320 }),
321 );
322 });
323
324 describe("CreateDatasetImportJob", () => {
325 test.provider("rejects a nonexistent dataset with a typed tag", () =>
326 Effect.gen(function* () {
327 const arns = yield* probeArns;
328 const tag = yield* probeTagWithRole(
329 "/import-create-probe",
330 arns.dataset,
331 );
332 expect(EXPECTED_PROBE_TAGS).toContain(tag);
333 }),
334 );
335 });
336
337 describe("DescribeDatasetImportJob", () => {
338 test.provider("surfaces a typed tag for a nonexistent import job", () =>
339 Effect.gen(function* () {
340 const arns = yield* probeArns;
341 const tag = yield* probeTag("/import-probe", arns.importJob);
342 expect(EXPECTED_PROBE_TAGS).toContain(tag);
343 }),
344 );
345 });
346
347 describe("CreateSolution", () => {
348 test.provider(
349 "rejects a nonexistent dataset group with a typed tag",
350 () =>
351 Effect.gen(function* () {
352 const arns = yield* probeArns;
353 const tag = yield* probeTag(
354 "/solution-create-probe",
355 arns.datasetGroup,
356 );
357 expect(EXPECTED_PROBE_TAGS).toContain(tag);
358 }),
359 );
360 });
361
362 describe("CreateSolutionVersion", () => {
363 test.provider("rejects a nonexistent solution with a typed tag", () =>
364 Effect.gen(function* () {
365 const arns = yield* probeArns;
366 const tag = yield* probeTag(
367 "/solution-version-create-probe",
368 arns.solution,
369 );
370 expect(EXPECTED_PROBE_TAGS).toContain(tag);
371 }),
372 );
373 });
374
375 describe("DescribeSolutionVersion", () => {
376 test.provider("surfaces a typed tag for a nonexistent version", () =>
377 Effect.gen(function* () {
378 const arns = yield* probeArns;
379 const tag = yield* probeTag(
380 "/solution-version-probe",
381 arns.solutionVersion,
382 );
383 expect(EXPECTED_PROBE_TAGS).toContain(tag);
384 }),
385 );
386 });
387
388 describe("GetSolutionMetrics", () => {
389 test.provider("surfaces a typed tag for a nonexistent version", () =>
390 Effect.gen(function* () {
391 const arns = yield* probeArns;
392 const tag = yield* probeTag(
393 "/solution-metrics-probe",
394 arns.solutionVersion,
395 );
396 expect(EXPECTED_PROBE_TAGS).toContain(tag);
397 }),
398 );
399 });
400
401 describe("CreateCampaign", () => {
402 test.provider(
403 "rejects a nonexistent solution version with a typed tag",
404 () =>
405 Effect.gen(function* () {
406 const arns = yield* probeArns;
407 const tag = yield* probeTag(
408 "/campaign-create-probe",
409 arns.solutionVersion,
410 );
411 expect(EXPECTED_PROBE_TAGS).toContain(tag);
412 }),
413 );
414 });
415
416 describe("UpdateCampaign", () => {
417 test.provider("surfaces a typed tag for a nonexistent campaign", () =>
418 Effect.gen(function* () {
419 const arns = yield* probeArns;
420 const tag = yield* probeTag("/campaign-update-probe", arns.campaign);
421 expect(EXPECTED_PROBE_TAGS).toContain(tag);
422 }),
423 );
424 });
425
426 describe("DescribeCampaign", () => {
427 test.provider("surfaces a typed tag for a nonexistent campaign", () =>
428 Effect.gen(function* () {
429 const arns = yield* probeArns;
430 const tag = yield* probeTag("/campaign-probe", arns.campaign);
431 expect(EXPECTED_PROBE_TAGS).toContain(tag);
432 }),
433 );
434 });
435
436 describe("CreateBatchInferenceJob", () => {
437 test.provider(
438 "rejects a nonexistent solution version with a typed tag",
439 () =>
440 Effect.gen(function* () {
441 const arns = yield* probeArns;
442 const tag = yield* probeTagWithRole(
443 "/batch-create-probe",
444 arns.solutionVersion,
445 );
446 expect(EXPECTED_PROBE_TAGS).toContain(tag);
447 }),
448 );
449 });
450
451 describe("DescribeBatchInferenceJob", () => {
452 test.provider("surfaces a typed tag for a nonexistent batch job", () =>
453 Effect.gen(function* () {
454 const arns = yield* probeArns;
455 const tag = yield* probeTag("/batch-probe", arns.batchJob);
456 expect(EXPECTED_PROBE_TAGS).toContain(tag);
457 }),
458 );
459 });
460 },
461);