1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import SimpleDBTestFunctionLive, { SimpleDBTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "SimpleDBBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy under parallel-suite load. Budget ~150s of
18// readiness polling so we don't fail the whole suite on a slow init.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25
26class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
27 readonly status: number;
28 readonly body: string;
29}> {}
30
31// The shared Lambda fixture occasionally answers a transient 5xx under
32// full-suite parallel load — a cold re-init or a throttled SimpleDB call the
33// handler's `Effect.orDie` surfaces as a 500. Retry 5xx a few times; genuine
34// 4xx/assertion failures are returned immediately.
35const send = (request: HttpClientRequest.HttpClientRequest) =>
36 HttpClient.execute(request).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e) =>
50 e._tag === "TransientUpstream" || e._tag === "HttpClientError",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58// Response bodies cross the HTTP wire, so they are opaque JSON to the test.
59// Widen to `unknown` so call sites narrow with a single explicit cast.
60const asUnknown = Effect.map((body: unknown) => body);
61
62const postJson = (path: string, body: unknown) =>
63 send(
64 HttpClientRequest.bodyJsonUnsafe(
65 HttpClientRequest.post(`${baseUrl}${path}`),
66 body,
67 ),
68 ).pipe(
69 Effect.flatMap((r) => r.json),
70 asUnknown,
71 );
72
73const deleteJson = (path: string, body: unknown) =>
74 send(
75 HttpClientRequest.bodyJsonUnsafe(
76 HttpClientRequest.delete(`${baseUrl}${path}`),
77 body,
78 ),
79 ).pipe(
80 Effect.flatMap((r) => r.json),
81 asUnknown,
82 );
83
84const getJson = (path: string) =>
85 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
86 Effect.flatMap((r) => r.json),
87 asUnknown,
88 );
89
90interface Attribute {
91 Name: string;
92 Value: string;
93}
94
95interface Item {
96 Name: string;
97 Attributes?: Attribute[];
98}
99
100describe(
101 "SimpleDB Bindings",
102 {
103 tags: [
104 "provider:aws",
105 "provider:aws:batch",
106 "provider:aws:lambda",
107 "provider:aws:simpledb",
108 "live",
109 ],
110 },
111 () => {
112 beforeAll(
113 Effect.gen(function* () {
114 yield* Effect.logInfo(
115 "SimpleDB test setup: destroying previous resources",
116 );
117 yield* sharedStack.destroy();
118
119 yield* Effect.logInfo("SimpleDB test setup: deploying fixture");
120 const { functionUrl } = yield* sharedStack.deploy(
121 Effect.gen(function* () {
122 return yield* SimpleDBTestFunction;
123 }).pipe(Effect.provide(SimpleDBTestFunctionLive)),
124 );
125
126 expect(functionUrl).toBeTruthy();
127 baseUrl = functionUrl!.replace(/\/+$/, "");
128 const readinessUrl = `${baseUrl}/metadata`;
129
130 yield* Effect.logInfo(
131 `SimpleDB test setup: probing readiness at ${readinessUrl}`,
132 );
133 yield* HttpClient.get(readinessUrl).pipe(
134 Effect.flatMap((response) =>
135 response.status === 200
136 ? Effect.succeed(response)
137 : Effect.fail(
138 new Error(`Function not ready: ${response.status}`),
139 ),
140 ),
141 Effect.tapError((error) =>
142 Effect.logWarning(
143 `SimpleDB test setup: fixture not ready yet (${String(error)})`,
144 ),
145 ),
146 Effect.retry({ schedule: readinessPolicy }),
147 );
148 }),
149 { timeout: 240_000 },
150 );
151
152 afterAll(sharedStack.destroy(), { timeout: 120_000 });
153
154 describe("PutAttributes", () => {
155 test.provider("puts attributes on an item", (_stack) =>
156 Effect.gen(function* () {
157 const response = yield* postJson("/put", {
158 item: "put-test#1",
159 attributes: [{ name: "color", value: "blue" }],
160 });
161 expect(response).toHaveProperty("success", true);
162 }),
163 );
164 });
165
166 describe("GetAttributes", () => {
167 test.provider("round-trips put then get", (_stack) =>
168 Effect.gen(function* () {
169 yield* postJson("/put", {
170 item: "get-test#1",
171 attributes: [
172 { name: "color", value: "green" },
173 { name: "size", value: "large" },
174 ],
175 });
176
177 const response = (yield* getJson(
178 `/get?item=${encodeURIComponent("get-test#1")}`,
179 )) as { attributes: Attribute[] };
180
181 const byName = Object.fromEntries(
182 response.attributes.map((a) => [a.Name, a.Value]),
183 );
184 expect(byName).toMatchObject({ color: "green", size: "large" });
185 }),
186 );
187
188 test.provider("returns no attributes for a missing item", (_stack) =>
189 Effect.gen(function* () {
190 const response = (yield* getJson(
191 `/get?item=${encodeURIComponent("missing-item#404")}`,
192 )) as { attributes: Attribute[] };
193 expect(response.attributes).toEqual([]);
194 }),
195 );
196 });
197
198 describe("Select", () => {
199 test.provider("selects items with a where clause", (_stack) =>
200 Effect.gen(function* () {
201 yield* postJson("/put", {
202 item: "select-test#1",
203 attributes: [{ name: "kind", value: "select-target" }],
204 });
205
206 const response = (yield* getJson(
207 `/select?where=${encodeURIComponent("kind = 'select-target'")}`,
208 )) as { items: Item[] };
209
210 expect(response.items.map((i) => i.Name)).toContain("select-test#1");
211 const item = response.items.find((i) => i.Name === "select-test#1")!;
212 expect(item.Attributes).toContainEqual({
213 Name: "kind",
214 Value: "select-target",
215 });
216 }),
217 );
218 });
219
220 describe("BatchPutAttributes", () => {
221 test.provider("puts attributes on multiple items at once", (_stack) =>
222 Effect.gen(function* () {
223 const response = yield* postJson("/batch-put", {
224 items: [
225 {
226 item: "batch-test#1",
227 attributes: [{ name: "batch", value: "one" }],
228 },
229 {
230 item: "batch-test#2",
231 attributes: [{ name: "batch", value: "two" }],
232 },
233 ],
234 });
235 expect(response).toHaveProperty("success", true);
236
237 const first = (yield* getJson(
238 `/get?item=${encodeURIComponent("batch-test#1")}`,
239 )) as { attributes: Attribute[] };
240 expect(first.attributes).toContainEqual({
241 Name: "batch",
242 Value: "one",
243 });
244 const second = (yield* getJson(
245 `/get?item=${encodeURIComponent("batch-test#2")}`,
246 )) as { attributes: Attribute[] };
247 expect(second.attributes).toContainEqual({
248 Name: "batch",
249 Value: "two",
250 });
251 }),
252 );
253 });
254
255 describe("DeleteAttributes", () => {
256 test.provider("deletes a whole item", (_stack) =>
257 Effect.gen(function* () {
258 yield* postJson("/put", {
259 item: "delete-test#1",
260 attributes: [{ name: "doomed", value: "yes" }],
261 });
262
263 const response = yield* deleteJson("/delete", {
264 item: "delete-test#1",
265 });
266 expect(response).toHaveProperty("success", true);
267
268 const after = (yield* getJson(
269 `/get?item=${encodeURIComponent("delete-test#1")}`,
270 )) as { attributes: Attribute[] };
271 expect(after.attributes).toEqual([]);
272 }),
273 );
274
275 test.provider("deletes a single attribute", (_stack) =>
276 Effect.gen(function* () {
277 yield* postJson("/put", {
278 item: "delete-test#2",
279 attributes: [
280 { name: "keep", value: "me" },
281 { name: "drop", value: "me" },
282 ],
283 });
284
285 yield* deleteJson("/delete", {
286 item: "delete-test#2",
287 attributes: ["drop"],
288 });
289
290 const after = (yield* getJson(
291 `/get?item=${encodeURIComponent("delete-test#2")}`,
292 )) as { attributes: Attribute[] };
293 expect(after.attributes).toContainEqual({
294 Name: "keep",
295 Value: "me",
296 });
297 expect(after.attributes.map((a) => a.Name)).not.toContain("drop");
298 }),
299 );
300 });
301
302 describe("BatchDeleteAttributes", () => {
303 test.provider("deletes multiple items at once", (_stack) =>
304 Effect.gen(function* () {
305 yield* postJson("/batch-put", {
306 items: [
307 {
308 item: "batch-delete#1",
309 attributes: [{ name: "x", value: "1" }],
310 },
311 {
312 item: "batch-delete#2",
313 attributes: [{ name: "x", value: "2" }],
314 },
315 ],
316 });
317
318 const response = yield* postJson("/batch-delete", {
319 items: ["batch-delete#1", "batch-delete#2"],
320 });
321 expect(response).toHaveProperty("success", true);
322
323 const first = (yield* getJson(
324 `/get?item=${encodeURIComponent("batch-delete#1")}`,
325 )) as { attributes: Attribute[] };
326 expect(first.attributes).toEqual([]);
327 const second = (yield* getJson(
328 `/get?item=${encodeURIComponent("batch-delete#2")}`,
329 )) as { attributes: Attribute[] };
330 expect(second.attributes).toEqual([]);
331 }),
332 );
333 });
334
335 describe("ListDomains", () => {
336 test.provider("lists the fixture's domain", (_stack) =>
337 Effect.gen(function* () {
338 const response = (yield* getJson("/domains")) as {
339 domainNames: string[];
340 };
341 expect(
342 response.domainNames.some((name) =>
343 name.includes("BindingsDomain"),
344 ),
345 ).toBe(true);
346 }),
347 );
348 });
349
350 describe("DomainMetadata", () => {
351 test.provider("returns domain metadata", (_stack) =>
352 Effect.gen(function* () {
353 const response = (yield* getJson("/metadata")) as {
354 itemCount?: number;
355 };
356 expect(typeof response.itemCount).toBe("number");
357 }),
358 );
359 });
360 },
361);