1import * as ElastiCache from "@/AWS/ElastiCache";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
11
12export class ElastiCacheBindingsTestFunction extends Lambda.Function<Lambda.Function>()(
13 "ElastiCacheBindingsTestFunction",
14) {}
15
16/**
17 * Account-level binding fixture: no serverless cache is ever created. List
18 * routes prove each grant and response decode against real (possibly empty)
19 * account data; probe routes drive name-addressed operations against
20 * nonexistent names (passed by the test as a query parameter) and must
21 * surface the service's typed not-found tag — an IAM gap would surface
22 * AccessDeniedException and fail the route with an opaque 500.
23 *
24 * The cache-scoped `CreateServerlessCacheSnapshot` binding needs a live
25 * cache and is exercised by the AWS_TEST_SLOW-gated lifecycle fixture
26 * instead.
27 */
28export default ElastiCacheBindingsTestFunction.make(
29 {
30 main,
31 functionUrl: true,
32 },
33 Effect.gen(function* () {
34 // Event source: subscribe the host to ElastiCache cache/snapshot
35 // lifecycle events. The deploy proves the EventBridge rule + invoke
36 // permission wiring.
37 yield* ElastiCache.consumeCacheEvents(
38 { kinds: ["cache-limit-approaching", "snapshot-creation-failed"] },
39 (events) =>
40 Stream.runForEach(events, (event) =>
41 Effect.log(
42 `elasticache event: ${event["detail-type"]} -> ${event.resources.join(", ")}`,
43 ),
44 ),
45 );
46
47 const describeCaches = yield* ElastiCache.DescribeServerlessCaches();
48 const describeSnapshots =
49 yield* ElastiCache.DescribeServerlessCacheSnapshots();
50 const deleteSnapshot = yield* ElastiCache.DeleteServerlessCacheSnapshot();
51 const copySnapshot = yield* ElastiCache.CopyServerlessCacheSnapshot();
52 const exportSnapshot = yield* ElastiCache.ExportServerlessCacheSnapshot();
53 const describeEvents = yield* ElastiCache.DescribeEvents();
54
55 const bound = {
56 describeCaches,
57 describeSnapshots,
58 deleteSnapshot,
59 copySnapshot,
60 exportSnapshot,
61 describeEvents,
62 };
63
64 return {
65 fetch: Effect.gen(function* () {
66 const request = yield* HttpServerRequest;
67 const url = new URL(request.originalUrl);
68 const pathname = url.pathname;
69 const name = url.searchParams.get("name") ?? "";
70
71 if (request.method === "GET" && pathname === "/bindings") {
72 return yield* HttpServerResponse.json({
73 bound: Object.keys(bound),
74 });
75 }
76
77 if (request.method === "GET" && pathname === "/caches") {
78 const result = yield* describeCaches();
79 return yield* HttpServerResponse.json({
80 count: (result.ServerlessCaches ?? []).length,
81 });
82 }
83
84 if (request.method === "GET" && pathname === "/snapshots") {
85 const result = yield* describeSnapshots();
86 return yield* HttpServerResponse.json({
87 count: (result.ServerlessCacheSnapshots ?? []).length,
88 });
89 }
90
91 if (request.method === "GET" && pathname === "/events") {
92 const result = yield* describeEvents({
93 SourceType: "serverless-cache",
94 });
95 return yield* HttpServerResponse.json({
96 count: (result.Events ?? []).length,
97 });
98 }
99
100 if (request.method === "GET" && pathname === "/cache-probe") {
101 const tag = yield* describeCaches({
102 ServerlessCacheName: name,
103 }).pipe(
104 Effect.map(() => "Found"),
105 Effect.catchTag("ServerlessCacheNotFoundFault", (e) =>
106 Effect.succeed(e._tag),
107 ),
108 );
109 return yield* HttpServerResponse.json({ tag });
110 }
111
112 if (request.method === "GET" && pathname === "/delete-probe") {
113 // ElastiCache validates the account's service-linked role before
114 // snapshot existence, so an account that never kept a cache
115 // surfaces ServiceLinkedRoleNotFoundFault instead of not-found.
116 const tag = yield* deleteSnapshot({
117 ServerlessCacheSnapshotName: name,
118 }).pipe(
119 Effect.map(() => "Deleted"),
120 Effect.catchTag(
121 [
122 "ServerlessCacheSnapshotNotFoundFault",
123 "ServiceLinkedRoleNotFoundFault",
124 ],
125 (e) => Effect.succeed(e._tag),
126 ),
127 );
128 return yield* HttpServerResponse.json({ tag });
129 }
130
131 if (request.method === "GET" && pathname === "/copy-probe") {
132 const tag = yield* copySnapshot({
133 SourceServerlessCacheSnapshotName: name,
134 TargetServerlessCacheSnapshotName: `${name}-copy`,
135 }).pipe(
136 Effect.map(() => "Copied"),
137 Effect.catch((e) => Effect.succeed(e._tag)),
138 );
139 return yield* HttpServerResponse.json({ tag });
140 }
141
142 if (request.method === "GET" && pathname === "/export-probe") {
143 const tag = yield* exportSnapshot({
144 ServerlessCacheSnapshotName: name,
145 S3BucketName: "alchemy-elasticache-export-probe",
146 }).pipe(
147 Effect.map(() => "Exported"),
148 Effect.catch((e) => Effect.succeed(e._tag)),
149 );
150 return yield* HttpServerResponse.json({ tag });
151 }
152
153 return yield* HttpServerResponse.json(
154 { error: "Not found", method: request.method, pathname },
155 { status: 404 },
156 );
157 }).pipe(Effect.orDie),
158 };
159 }).pipe(
160 Effect.provide(
161 Layer.mergeAll(
162 Lambda.EventSource,
163 ElastiCache.DescribeServerlessCachesHttp,
164 ElastiCache.DescribeServerlessCacheSnapshotsHttp,
165 ElastiCache.DeleteServerlessCacheSnapshotHttp,
166 ElastiCache.CopyServerlessCacheSnapshotHttp,
167 ElastiCache.ExportServerlessCacheSnapshotHttp,
168 ElastiCache.DescribeEventsHttp,
169 ),
170 ),
171 ),
172);