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 Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import MemoryDBBindingsTestFunctionLive, {
9 MemoryDBBindingsTestFunction,
10} from "./bindings-handler.ts";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "MemoryDBBindings");
15
16const NONEXISTENT_NAME = "alchemy-memorydb-nonexistent-probe";
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27const getJson = (path: string) =>
28 HttpClient.get(`${baseUrl}${path}`).pipe(
29 Effect.flatMap((response) =>
30 response.status >= 500
31 ? Effect.fail(new Error(`transient upstream ${response.status}`))
32 : Effect.succeed(response),
33 ),
34 Effect.retry({
35 schedule: Schedule.max([
36 Schedule.exponential("500 millis"),
37 Schedule.recurs(6),
38 ]),
39 }),
40 Effect.flatMap((r) => r.json),
41 );
42
43describe.sequential(
44 "MemoryDB Bindings",
45 {
46 tags: [
47 "provider:aws",
48 "provider:aws:batch",
49 "provider:aws:lambda",
50 "provider:aws:memorydb",
51 "live",
52 ],
53 },
54 () => {
55 beforeAll(
56 Effect.gen(function* () {
57 yield* Effect.logInfo(
58 "MemoryDB bindings setup: destroying previous resources",
59 );
60 yield* sharedStack.destroy();
61
62 yield* Effect.logInfo("MemoryDB bindings setup: deploying fixture");
63 const attrs = yield* sharedStack.deploy(
64 Effect.gen(function* () {
65 return yield* MemoryDBBindingsTestFunction;
66 }).pipe(Effect.provide(MemoryDBBindingsTestFunctionLive)),
67 );
68
69 expect(attrs.functionUrl).toBeTruthy();
70 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
71
72 const readinessUrl = `${baseUrl}/bindings`;
73 yield* HttpClient.get(readinessUrl).pipe(
74 Effect.flatMap((response) =>
75 response.status === 200
76 ? Effect.succeed(response)
77 : Effect.fail(
78 new Error(`Function not ready: ${response.status}`),
79 ),
80 ),
81 Effect.retry({ schedule: readinessPolicy }),
82 );
83 }),
84 { timeout: 240_000 },
85 );
86
87 afterAll(sharedStack.destroy(), { timeout: 120_000 });
88
89 describe("binding registration", () => {
90 test.provider("all eight capabilities initialize in the runtime", () =>
91 Effect.gen(function* () {
92 const response = yield* getJson("/bindings");
93 expect((response as any).bound).toHaveLength(8);
94 }),
95 );
96 });
97
98 describe("DescribeClusters", () => {
99 test.provider("lists the account's clusters", () =>
100 Effect.gen(function* () {
101 const response = yield* getJson("/clusters");
102 expect((response as any).count).toBeGreaterThanOrEqual(0);
103 }),
104 );
105
106 test.provider("surfaces the typed not-found tag", () =>
107 Effect.gen(function* () {
108 const response = yield* getJson(
109 `/cluster-probe?name=${NONEXISTENT_NAME}`,
110 );
111 expect((response as any).tag).toBe("ClusterNotFoundFault");
112 }),
113 );
114 });
115
116 describe("DescribeSnapshots", () => {
117 test.provider("lists the account's snapshots", () =>
118 Effect.gen(function* () {
119 const response = yield* getJson("/snapshots");
120 expect((response as any).count).toBeGreaterThanOrEqual(0);
121 }),
122 );
123 });
124
125 describe("DescribeEvents", () => {
126 test.provider("lists recent cluster events", () =>
127 Effect.gen(function* () {
128 const response = yield* getJson("/events");
129 expect((response as any).count).toBeGreaterThanOrEqual(0);
130 }),
131 );
132 });
133
134 describe("DescribeServiceUpdates", () => {
135 test.provider("lists the account's service updates", () =>
136 Effect.gen(function* () {
137 const response = yield* getJson("/service-updates");
138 expect((response as any).count).toBeGreaterThanOrEqual(0);
139 }),
140 );
141 });
142
143 describe("DescribeEngineVersions", () => {
144 test.provider("lists supported valkey engine versions", () =>
145 Effect.gen(function* () {
146 const response = yield* getJson("/engine-versions");
147 expect((response as any).count).toBeGreaterThanOrEqual(1);
148 }),
149 );
150 });
151
152 describe("BatchUpdateCluster", () => {
153 test.provider("rejects a nonexistent cluster with a typed outcome", () =>
154 Effect.gen(function* () {
155 const response = yield* getJson(
156 `/batch-probe?name=${NONEXISTENT_NAME}`,
157 );
158 const { unprocessed, tag } = response as {
159 unprocessed: number;
160 tag: string;
161 };
162 // Either the cluster comes back unprocessed or the call is rejected
163 // with a typed tag — an IAM gap would have 500'd the route instead.
164 // Observed live: ServiceUpdateNotFoundFault ("Service Update ...
165 // not found").
166 expect(
167 unprocessed >= 1 ||
168 [
169 "ServiceUpdateNotFoundFault",
170 "InvalidParameterCombinationException",
171 "InvalidParameterValueException",
172 ].includes(tag),
173 ).toBe(true);
174 }),
175 );
176 });
177
178 describe("DeleteSnapshot", () => {
179 test.provider("surfaces the typed not-found tag", () =>
180 Effect.gen(function* () {
181 const response = yield* getJson(
182 `/delete-probe?name=${NONEXISTENT_NAME}`,
183 );
184 // ServiceLinkedRoleNotFoundFault: MemoryDB validates the SLR before
185 // snapshot existence in accounts that never kept a cluster.
186 expect([
187 "SnapshotNotFoundFault",
188 "ServiceLinkedRoleNotFoundFault",
189 ]).toContain((response as any).tag);
190 }),
191 );
192 });
193
194 describe("CopySnapshot", () => {
195 test.provider(
196 "rejects a nonexistent source snapshot with a typed tag",
197 () =>
198 Effect.gen(function* () {
199 const response = yield* getJson(
200 `/copy-probe?name=${NONEXISTENT_NAME}`,
201 );
202 expect([
203 "SnapshotNotFoundFault",
204 "InvalidParameterValueException",
205 "ServiceLinkedRoleNotFoundFault",
206 ]).toContain((response as any).tag);
207 }),
208 );
209 });
210 },
211);