1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as lambda from "@distilled.cloud/aws/lambda";
5import * as s3 from "@distilled.cloud/aws/s3";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import EntityResolutionTestFunctionLive, {
13 EntityResolutionTestFunction,
14 FIXTURE_BUCKET_NAME,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "EntityResolutionBindings");
20
21// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
22// over 60s on a fresh deploy.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(75),
26]);
27
28let baseUrl: string;
29let functionArn: string;
30
31class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
32 readonly status: number;
33 readonly body: string;
34}> {}
35
36class FunctionStillExists extends Data.TaggedError("FunctionStillExists") {}
37class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
38
39// The shared Lambda fixture occasionally answers a transient 5xx under load
40// (cold re-init, IAM propagation on the freshly attached policy that the
41// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
42// 4xx/assertion failure surfaces immediately.
43const send = (request: HttpClientRequest.HttpClientRequest) =>
44 HttpClient.execute(request).pipe(
45 Effect.flatMap((response) =>
46 response.status >= 500
47 ? response.text.pipe(
48 Effect.flatMap((body) =>
49 Effect.fail(
50 new TransientUpstream({ status: response.status, body }),
51 ),
52 ),
53 )
54 : Effect.succeed(response),
55 ),
56 Effect.retry({
57 while: (e) => e._tag === "TransientUpstream",
58 schedule: Schedule.max([
59 Schedule.exponential("500 millis"),
60 Schedule.recurs(6),
61 ]),
62 }),
63 );
64
65const getJson = (path: string) =>
66 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
67 Effect.flatMap((r) => r.json),
68 );
69
70const postJson = (path: string) =>
71 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
72 Effect.flatMap((r) => r.json),
73 );
74
75describe.sequential(
76 "EntityResolution Bindings",
77 {
78 tags: [
79 "provider:aws",
80 "provider:aws:batch",
81 "provider:aws:entityresolution",
82 "provider:aws:glue",
83 "provider:aws:iam",
84 "provider:aws:lambda",
85 "provider:aws:s3",
86 "live",
87 ],
88 },
89 () => {
90 beforeAll(
91 Effect.gen(function* () {
92 yield* Effect.logInfo(
93 "EntityResolution bindings setup: destroying previous resources",
94 );
95 yield* sharedStack.destroy();
96
97 yield* Effect.logInfo(
98 "EntityResolution bindings setup: deploying fixture",
99 );
100 const attrs = yield* sharedStack.deploy(
101 Effect.gen(function* () {
102 return yield* EntityResolutionTestFunction;
103 }).pipe(Effect.provide(EntityResolutionTestFunctionLive)),
104 );
105
106 expect(attrs.functionUrl).toBeTruthy();
107 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
108 functionArn = attrs.functionArn;
109
110 const readinessUrl = `${baseUrl}/bindings`;
111 yield* Effect.logInfo(
112 `EntityResolution bindings setup: probing readiness at ${readinessUrl}`,
113 );
114 yield* HttpClient.get(readinessUrl).pipe(
115 Effect.flatMap((response) =>
116 response.status === 200
117 ? Effect.succeed(response)
118 : Effect.fail(
119 new Error(`Function not ready: ${response.status}`),
120 ),
121 ),
122 Effect.tapError((error) =>
123 Effect.logWarning(
124 `EntityResolution bindings setup: fixture not ready yet (${String(error)})`,
125 ),
126 ),
127 Effect.retry({ schedule: readinessPolicy }),
128 );
129 }),
130 { timeout: 240_000 },
131 );
132
133 afterAll.skipIf(!!process.env.NO_DESTROY)(
134 Effect.gen(function* () {
135 yield* sharedStack.destroy();
136 // Assert gone (skipped when beforeAll never got far enough to deploy):
137 // the fixture Lambda answers with the typed not-found tag and the
138 // fixed-name fixture bucket no longer heads. afterAll runs outside
139 // `test.provider`'s layer, so raw distilled calls need the provider
140 // layer (credentials, region) supplied explicitly.
141 if (functionArn) {
142 yield* Core.withProviders(
143 Effect.gen(function* () {
144 yield* lambda.getFunction({ FunctionName: functionArn }).pipe(
145 Effect.flatMap(() => Effect.fail(new FunctionStillExists())),
146 Effect.retry({
147 while: (error) => error._tag === "FunctionStillExists",
148 schedule: Schedule.exponential("500 millis"),
149 times: 8,
150 }),
151 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
152 );
153 yield* s3.headBucket({ Bucket: FIXTURE_BUCKET_NAME }).pipe(
154 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
155 Effect.retry({
156 while: (error) => error._tag === "BucketStillExists",
157 schedule: Schedule.exponential("500 millis"),
158 times: 8,
159 }),
160 Effect.catchTag("NotFound", () => Effect.void),
161 );
162 }),
163 testOptions,
164 sharedStack.name,
165 );
166 }
167 }),
168 { timeout: 240_000 },
169 );
170
171 describe("binding registration", () => {
172 test.provider(
173 "all nine capabilities initialize in the runtime",
174 (_stack) =>
175 Effect.gen(function* () {
176 const response = (yield* getJson("/bindings")) as {
177 bound: string[];
178 };
179 expect(response.bound).toHaveLength(9);
180 }),
181 );
182 });
183
184 describe("ListMatchingJobs / ListIdMappingJobs", () => {
185 test.provider(
186 "both workflow-scoped listings answer (fresh workflows have no jobs)",
187 (_stack) =>
188 Effect.gen(function* () {
189 const response = (yield* getJson("/jobs")) as {
190 matchingJobs: number;
191 idMappingJobs: number;
192 };
193 expect(response.matchingJobs).toBe(0);
194 expect(response.idMappingJobs).toBe(0);
195 }),
196 );
197 });
198
199 describe("GetMatchingJob / GetIdMappingJob", () => {
200 test.provider(
201 "a bogus job id surfaces the typed ResourceNotFoundException",
202 (_stack) =>
203 Effect.gen(function* () {
204 const response = (yield* getJson("/jobs/not-found")) as {
205 matching: string;
206 idMapping: string;
207 };
208 expect(response.matching).toBe("ResourceNotFoundException");
209 expect(response.idMapping).toBe("ResourceNotFoundException");
210 }),
211 );
212 });
213
214 describe("GetMatchId", () => {
215 test.provider(
216 "an unprocessed record answers with no match id",
217 (_stack) =>
218 Effect.gen(function* () {
219 const response = (yield* postJson("/match-id")) as {
220 result: string;
221 matchId: string | null;
222 matchRule: string | null;
223 message: string | null;
224 };
225 // Either an empty lookup succeeds or the service rejects real-time
226 // lookups on a never-run workflow with a typed tag — both prove the
227 // grant + typed plumbing (an IAM gap would be a 500).
228 expect(
229 ["ok", "ValidationException", "ResourceNotFoundException"],
230 response.message ?? undefined,
231 ).toContain(response.result);
232 expect(response.matchId).toBeNull();
233 }),
234 );
235 });
236
237 describe("GenerateMatchId", () => {
238 test.provider(
239 "real-time generation matches two records by email",
240 (_stack) =>
241 Effect.gen(function* () {
242 const response = (yield* postJson("/generate-match-id")) as {
243 result: string;
244 matchGroups?: number;
245 failedRecords?: number;
246 message?: string | null;
247 };
248 // Either the account supports real-time generation on this
249 // workflow (both records land in one match group) or the service
250 // rejects it with a typed tag — both prove the grant + plumbing
251 // (an IAM gap would be a 500).
252 expect(
253 ["ok", "ValidationException", "ResourceNotFoundException"],
254 response.message ?? undefined,
255 ).toContain(response.result);
256 if (response.result === "ok") {
257 expect(response.matchGroups).toBeGreaterThanOrEqual(1);
258 }
259 }),
260 );
261 });
262
263 describe("BatchDeleteUniqueId", () => {
264 test.provider(
265 "deleting unknown unique ids answers per-id results",
266 (_stack) =>
267 Effect.gen(function* () {
268 const response = (yield* postJson("/delete-unique-ids")) as {
269 result: string;
270 status?: string;
271 deleted?: number;
272 errors?: number;
273 message?: string | null;
274 };
275 expect(
276 ["ok", "ValidationException", "ResourceNotFoundException"],
277 response.message ?? undefined,
278 ).toContain(response.result);
279 }),
280 );
281 });
282
283 // The batch job runs take many minutes and cannot be cancelled — only
284 // exercised when explicitly opted in.
285 describe("StartMatchingJob / StartIdMappingJob (gated)", () => {
286 test.provider.skipIf(!process.env.AWS_TEST_ENTITYRESOLUTION_RUN)(
287 "starts a matching job through the binding",
288 (_stack) =>
289 Effect.gen(function* () {
290 const response = (yield* postJson("/start-matching-job")) as {
291 jobId: string;
292 status: string;
293 };
294 expect(response.jobId).toBeTruthy();
295 expect(["QUEUED", "RUNNING"]).toContain(response.status);
296 }),
297 { timeout: 120_000 },
298 );
299
300 test.provider.skipIf(!process.env.AWS_TEST_ENTITYRESOLUTION_RUN)(
301 "starts an id mapping job through the binding",
302 (_stack) =>
303 Effect.gen(function* () {
304 const response = (yield* postJson("/start-id-mapping-job")) as {
305 jobId: string;
306 status: string;
307 };
308 expect(response.jobId).toBeTruthy();
309 expect(["QUEUED", "RUNNING"]).toContain(response.status);
310 }),
311 { timeout: 120_000 },
312 );
313 });
314 },
315);