1import * as AWS from "@/AWS";
2import { connectEnvPrefix } from "@/AWS/ElastiCache";
3import * as Test from "@/Test/Alchemy";
4import * as ElastiCache from "@distilled.cloud/aws/elasticache";
5import * as Lambda from "@distilled.cloud/aws/lambda";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import ElastiCacheTestFunctionLive, {
12 ElastiCacheTestFunction,
13 FixtureCache,
14} from "./handler.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// Ungated typed-error probe: proves the distilled error union carries the
19// not-found tag this provider's read/delete paths depend on.
20test.provider(
21 "describeServerlessCaches on a nonexistent cache fails with ServerlessCacheNotFoundFault",
22 () =>
23 Effect.gen(function* () {
24 const error = yield* Effect.flip(
25 ElastiCache.describeServerlessCaches({
26 ServerlessCacheName: "alchemy-nonexistent-cache-probe",
27 }),
28 );
29 expect(error._tag).toBe("ServerlessCacheNotFoundFault");
30 }),
31 { tags: ["provider:aws", "provider:aws:elasticache", "live"] },
32);
33
34const SNAPSHOT_NAME = "alchemy-elasticache-fixture-snap";
35
36// A snapshot still `creating` rejects deletion with
37// InvalidServerlessCacheSnapshotStateFault — retry (bounded) while it
38// settles. Already gone is success.
39const deleteSnapshot = (name: string) =>
40 ElastiCache.deleteServerlessCacheSnapshot({
41 ServerlessCacheSnapshotName: name,
42 }).pipe(
43 Effect.asVoid,
44 Effect.catchTag("ServerlessCacheSnapshotNotFoundFault", () => Effect.void),
45 Effect.retry({
46 while: (e): boolean =>
47 e._tag === "InvalidServerlessCacheSnapshotStateFault",
48 schedule: Schedule.max([
49 Schedule.fixed("10 seconds"),
50 Schedule.recurs(30),
51 ]),
52 }),
53 );
54
55// Serverless caches take ~1-3 minutes to provision and a similar time to
56// delete, and are metered (with a monthly floor) while they exist. The full
57// lifecycle is therefore gated behind AWS_TEST_SLOW=1 and runs as a single
58// sequential test that always destroys what it created.
59test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
60 "create serverless cache, bind Connect env, destroy, verify gone",
61 (stack) =>
62 Effect.gen(function* () {
63 // Clean slate in case a previous run died mid-flight (the snapshot is
64 // created out-of-band of the stack, so it needs its own cleanup).
65 yield* stack.destroy();
66 yield* deleteSnapshot(SNAPSHOT_NAME);
67
68 const { cache, fn } = yield* stack.deploy(
69 Effect.gen(function* () {
70 const { cache } = yield* FixtureCache;
71 const fn = yield* ElastiCacheTestFunction;
72 return { cache, fn };
73 }).pipe(Effect.provide(ElastiCacheTestFunctionLive)),
74 );
75
76 expect(cache.serverlessCacheName).toBeDefined();
77 expect(cache.serverlessCacheArn).toContain(":serverlesscache:");
78 expect(cache.engine).toBe("valkey");
79 expect(cache.endpointAddress).toContain("cache.amazonaws.com");
80 expect(cache.endpointPort).toBe(6379);
81
82 // Out-of-band verification via distilled: cache is live with the
83 // requested cost-control limits.
84 const described = yield* ElastiCache.describeServerlessCaches({
85 ServerlessCacheName: cache.serverlessCacheName,
86 });
87 const observed = described.ServerlessCaches?.[0];
88 expect(observed?.Status).toBe("available");
89 expect(observed?.CacheUsageLimits?.DataStorage?.Maximum).toBe(1);
90 expect(observed?.CacheUsageLimits?.ECPUPerSecond?.Maximum).toBe(1000);
91
92 // Env-only Connect binding: the deployed function's configuration
93 // must carry the endpoint env vars.
94 const config = yield* Lambda.getFunctionConfiguration({
95 FunctionName: fn.functionName,
96 });
97 const prefix = connectEnvPrefix("FixtureCache");
98 const env = config.Environment?.Variables ?? {};
99 // Distilled decodes Lambda env values as sensitive (Redacted) strings.
100 const envValue = (key: string) => {
101 const value = env[key];
102 return value === undefined || typeof value === "string"
103 ? value
104 : Redacted.value(value);
105 };
106 expect(envValue(`${prefix}_HOST`)).toBe(cache.endpointAddress);
107 expect(envValue(`${prefix}_PORT`)).toBe(String(cache.endpointPort));
108 expect(envValue(`${prefix}_TLS`)).toBe("true");
109
110 // Runtime shape: the binding resolves the same connection info inside
111 // the deployed function. The first request rides URL propagation AND
112 // the VPC-attached cold start (ENI provisioning), so budget a generous
113 // bounded retry (3s x 60 ≈ 180s).
114 const baseUrl = fn.functionUrl!.replace(/\/+$/, "");
115 const getJson = (path: string, times: number) =>
116 HttpClient.get(`${baseUrl}${path}`).pipe(
117 Effect.flatMap((res) =>
118 res.status === 200
119 ? res.json
120 : res.text.pipe(
121 Effect.flatMap((body) =>
122 Effect.fail(
123 new Error(`${path} returned ${res.status}: ${body}`),
124 ),
125 ),
126 ),
127 ),
128 Effect.retry({
129 schedule: Schedule.max([
130 Schedule.fixed("3 seconds"),
131 Schedule.recurs(times),
132 ]),
133 }),
134 );
135 const response = yield* getJson("/connection", 60);
136 expect((response as { host: string }).host).toBe(cache.endpointAddress);
137 expect((response as { port: number }).port).toBe(cache.endpointPort);
138 expect((response as { tls: boolean }).tls).toBe(true);
139
140 // Data-plane roundtrip: the VPC-attached function SETs then GETs a key
141 // through the cache over TLS with iovalkey and returns the read-back
142 // value.
143 const roundtrip = yield* getJson("/roundtrip?value=hello-valkey", 10);
144 expect((roundtrip as { value: string }).value).toBe("hello-valkey");
145
146 // Cache-scoped CreateServerlessCacheSnapshot binding: take an
147 // on-demand snapshot from inside the deployed function, verify it
148 // out-of-band, then delete it (snapshots bill per GB-month).
149 const snapshot = (yield* getJson(
150 `/snapshot?name=${SNAPSHOT_NAME}`,
151 10,
152 )) as { name: string; status: string };
153 expect(snapshot.name).toBe(SNAPSHOT_NAME);
154 expect(["creating", "available", "exists"]).toContain(snapshot.status);
155 const describedSnapshots =
156 yield* ElastiCache.describeServerlessCacheSnapshots({
157 ServerlessCacheSnapshotName: SNAPSHOT_NAME,
158 });
159 expect(
160 describedSnapshots.ServerlessCacheSnapshots?.[0]
161 ?.ServerlessCacheSnapshotName,
162 ).toBe(SNAPSHOT_NAME);
163 yield* deleteSnapshot(SNAPSHOT_NAME);
164
165 // Destroy immediately — serverless caches bill while they exist —
166 // and verify deletion out-of-band with a typed wait.
167 yield* stack.destroy();
168 yield* assertCacheDeleted(cache.serverlessCacheName);
169 }),
170 // create (~2 min) + lambda deploy (~1 min) + delete initiation, one test.
171 {
172 tags: [
173 "provider:aws",
174 "provider:aws:ec2",
175 "provider:aws:elasticache",
176 "provider:aws:lambda",
177 "live",
178 ],
179 timeout: 900_000,
180 },
181);
182
183// Deletion is verified as INITIATED (status `deleting`, which is
184// irreversible) or fully gone. Full disappearance takes another ~8 minutes
185// server-side; waiting for it would push the test into its timeout and —
186// with vitest's global `retry: 2` — trigger pointless create/destroy churn
187// of a billed resource.
188const assertCacheDeleted = (name: string) =>
189 Effect.gen(function* () {
190 const status = yield* ElastiCache.describeServerlessCaches({
191 ServerlessCacheName: name,
192 }).pipe(
193 Effect.map((r) => r.ServerlessCaches?.[0]?.Status ?? "gone"),
194 Effect.catchTag("ServerlessCacheNotFoundFault", () =>
195 Effect.succeed("gone" as const),
196 ),
197 );
198 if (status !== "gone" && status !== "deleting") {
199 return yield* Effect.fail(
200 new Error(
201 `Serverless cache '${name}' still exists (status: ${status})`,
202 ),
203 );
204 }
205 }).pipe(
206 Effect.retry({
207 schedule: Schedule.max([
208 Schedule.fixed("5 seconds"),
209 Schedule.recurs(24),
210 ]),
211 }),
212 );