1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as serverless from "@distilled.cloud/aws/redshift-serverless";
5import { describe, expect } from "alchemy-test";
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 SnapshotFunctionLive, {
11 SnapshotFunction,
12} from "./fixtures/snapshot-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "RedshiftSnapshotBindings");
17
18// Ungated typed-error probes: prove the distilled error union carries the
19// not-found tag the snapshot bindings can surface. Runs in every CI pass at
20// near-zero cost.
21test.provider(
22 "getSnapshot on a nonexistent snapshot fails with ResourceNotFoundException",
23 () =>
24 Effect.gen(function* () {
25 const error = yield* Effect.flip(
26 serverless.getSnapshot({
27 snapshotName: "alchemy-nonexistent-rssnap-probe",
28 }),
29 );
30 expect(error._tag).toBe("ResourceNotFoundException");
31 }),
32 { tags: ["provider:aws", "provider:aws:redshiftserverless", "live"] },
33);
34
35test.provider(
36 "deleteSnapshot on a nonexistent snapshot fails with ResourceNotFoundException",
37 () =>
38 Effect.gen(function* () {
39 const error = yield* Effect.flip(
40 serverless.deleteSnapshot({
41 snapshotName: "alchemy-nonexistent-rssnap-probe",
42 }),
43 );
44 expect(error._tag).toBe("ResourceNotFoundException");
45 }),
46 { tags: ["provider:aws", "provider:aws:redshiftserverless", "live"] },
47);
48
49const SNAPSHOT_NAME = "alchemy-test-rssnap-snapshot";
50
51let baseUrl: string;
52
53// The full lifecycle deploys a Redshift Serverless namespace (no workgroup,
54// so no RPU billing floor — but namespace + snapshot provisioning still takes
55// minutes), so it is gated behind AWS_TEST_REDSHIFT=1 and destroys everything
56// in afterAll.
57describe.skipIf(!process.env.AWS_TEST_REDSHIFT)(
58 "RedshiftServerless Snapshot Bindings",
59 {
60 tags: [
61 "provider:aws",
62 "provider:aws:lambda",
63 "provider:aws:redshiftserverless",
64 "live",
65 ],
66 },
67 () => {
68 beforeAll(
69 Effect.gen(function* () {
70 yield* Effect.logInfo(
71 "Snapshot bindings setup: destroying previous run",
72 );
73 yield* sharedStack.destroy();
74
75 yield* Effect.logInfo("Snapshot bindings setup: deploying fixture");
76 const { functionUrl } = yield* sharedStack.deploy(
77 Effect.gen(function* () {
78 return yield* SnapshotFunction;
79 }).pipe(Effect.provide(SnapshotFunctionLive)),
80 );
81
82 expect(functionUrl).toBeTruthy();
83 baseUrl = functionUrl!.replace(/\/+$/, "");
84 yield* Effect.logInfo(
85 `Snapshot bindings setup: function URL ready (${functionUrl})`,
86 );
87 }),
88 // namespace (~1 min) + Lambda deploy.
89 { timeout: 600_000 },
90 );
91
92 afterAll(sharedStack.destroy(), { timeout: 600_000 });
93
94 const json = (
95 request: HttpClientRequest.HttpClientRequest,
96 ): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
97 HttpClient.execute(request).pipe(
98 Effect.flatMap((res) =>
99 res.status === 200
100 ? res.json
101 : Effect.flatMap(res.text, (body) =>
102 Effect.fail(
103 new Error(`${request.url} -> ${res.status}: ${body}`),
104 ),
105 ),
106 ),
107 );
108
109 test.provider(
110 "create, poll, list, update, and delete a snapshot through the deployed bindings",
111 (_stack) =>
112 Effect.gen(function* () {
113 // Create — retried while the fresh function URL warms up.
114 const created = (yield* json(
115 HttpClientRequest.post(`${baseUrl}/snapshot?name=${SNAPSHOT_NAME}`),
116 ).pipe(
117 Effect.retry({
118 schedule: Schedule.max([
119 Schedule.exponential("1 second"),
120 Schedule.recurs(8),
121 ]),
122 }),
123 )) as { snapshotName?: string; status?: string };
124 expect(created.snapshotName).toBe(SNAPSHOT_NAME);
125
126 // Poll via GetSnapshot until the snapshot settles to AVAILABLE.
127 const settled = (yield* json(
128 HttpClientRequest.get(`${baseUrl}/snapshot?name=${SNAPSHOT_NAME}`),
129 ).pipe(
130 Effect.repeat({
131 schedule: Schedule.spaced("5 seconds"),
132 until: (body): boolean =>
133 (body as { status?: string }).status?.toUpperCase() ===
134 "AVAILABLE",
135 times: 36,
136 }),
137 )) as {
138 snapshotName?: string;
139 namespaceName?: string;
140 retentionPeriod?: number;
141 status?: string;
142 };
143 expect(settled.status?.toUpperCase()).toBe("AVAILABLE");
144 expect(settled.namespaceName).toBe("alchemy-test-rssnap-ns");
145 expect(settled.retentionPeriod).toBe(1);
146
147 // ListSnapshots filtered by the bound namespace sees it.
148 const listed = (yield* json(
149 HttpClientRequest.get(`${baseUrl}/snapshots`),
150 )) as { names: (string | undefined)[] };
151 expect(listed.names).toContain(SNAPSHOT_NAME);
152
153 // UpdateSnapshot extends the retention period.
154 const updated = (yield* json(
155 HttpClientRequest.post(
156 `${baseUrl}/snapshot/retention?name=${SNAPSHOT_NAME}`,
157 ),
158 )) as { retentionPeriod?: number };
159 expect(updated.retentionPeriod).toBe(2);
160
161 // Recovery-point + table-restore listings respond (may be empty
162 // for a fresh, never-computed namespace).
163 const recovery = (yield* json(
164 HttpClientRequest.get(`${baseUrl}/recovery-points`),
165 )) as { count: number };
166 expect(recovery.count).toBeGreaterThanOrEqual(0);
167 const restores = (yield* json(
168 HttpClientRequest.get(`${baseUrl}/table-restores`),
169 )) as { count: number };
170 expect(restores.count).toBeGreaterThanOrEqual(0);
171
172 // DeleteSnapshot removes it; out-of-band read confirms it is gone.
173 yield* json(
174 HttpClientRequest.delete(
175 `${baseUrl}/snapshot?name=${SNAPSHOT_NAME}`,
176 ),
177 );
178 const gone = yield* serverless
179 .getSnapshot({ snapshotName: SNAPSHOT_NAME })
180 .pipe(
181 Effect.map((r) => r.snapshot?.status ?? "PRESENT"),
182 Effect.catchTag("ResourceNotFoundException", () =>
183 Effect.succeed("GONE" as const),
184 ),
185 Effect.repeat({
186 schedule: Schedule.spaced("5 seconds"),
187 until: (status): boolean =>
188 status === "GONE" || status === "DELETING",
189 times: 12,
190 }),
191 );
192 expect(["GONE", "DELETING"]).toContain(gone);
193 }),
194 { timeout: 420_000 },
195 );
196 },
197);