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 S3TablesBindingsFunctionLive, {
11 S3TablesBindingsFunction,
12} from "./bindings-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "S3TablesBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy under parallel-suite load.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58describe.sequential(
59 "S3Tables Bindings",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:lambda",
64 "provider:aws:s3tables",
65 "live",
66 ],
67 },
68 () => {
69 beforeAll(
70 Effect.gen(function* () {
71 yield* Effect.logInfo(
72 "S3Tables test setup: destroying previous resources",
73 );
74 yield* sharedStack.destroy();
75
76 yield* Effect.logInfo("S3Tables test setup: deploying fixture");
77 const { functionUrl } = yield* sharedStack.deploy(
78 Effect.gen(function* () {
79 return yield* S3TablesBindingsFunction;
80 }).pipe(Effect.provide(S3TablesBindingsFunctionLive)),
81 );
82
83 expect(functionUrl).toBeTruthy();
84 baseUrl = functionUrl!.replace(/\/+$/, "");
85
86 const readinessUrl = `${baseUrl}/bindings`;
87 yield* Effect.logInfo(
88 `S3Tables test setup: probing readiness at ${readinessUrl}`,
89 );
90 yield* HttpClient.get(readinessUrl).pipe(
91 Effect.flatMap((response) =>
92 response.status === 200
93 ? Effect.succeed(response)
94 : Effect.fail(
95 new Error(`Function not ready: ${response.status}`),
96 ),
97 ),
98 Effect.tapError((error) =>
99 Effect.logWarning(
100 `S3Tables test setup: fixture not ready yet (${String(error)})`,
101 ),
102 ),
103 Effect.retry({ schedule: readinessPolicy }),
104 );
105 }),
106 { timeout: 240_000 },
107 );
108
109 afterAll(sharedStack.destroy(), { timeout: 240_000 });
110
111 describe("binding registration", () => {
112 test.provider("all 6 capabilities initialize in the runtime", (_stack) =>
113 Effect.gen(function* () {
114 const response = yield* send(
115 HttpClientRequest.get(`${baseUrl}/bindings`),
116 ).pipe(Effect.flatMap((r) => r.json));
117 expect((response as any).bound).toHaveLength(6);
118 }),
119 );
120 });
121
122 describe("ListNamespaces", () => {
123 test.provider("lists the bucket's namespaces", (_stack) =>
124 Effect.gen(function* () {
125 const response = (yield* send(
126 HttpClientRequest.get(`${baseUrl}/namespaces`),
127 ).pipe(Effect.flatMap((r) => r.json))) as any;
128 expect(response.names).toContain("bindings");
129 }),
130 );
131 });
132
133 describe("ListTables", () => {
134 test.provider("lists the namespace's tables", (_stack) =>
135 Effect.gen(function* () {
136 const response = (yield* send(
137 HttpClientRequest.get(`${baseUrl}/tables`),
138 ).pipe(Effect.flatMap((r) => r.json))) as any;
139 expect(response.names).toContain("events");
140 }),
141 );
142 });
143
144 describe("GetTable", () => {
145 test.provider("reads the bound table's details", (_stack) =>
146 Effect.gen(function* () {
147 const response = (yield* send(
148 HttpClientRequest.get(`${baseUrl}/table`),
149 ).pipe(Effect.flatMap((r) => r.json))) as any;
150 expect(response.name).toBe("events");
151 expect(response.format).toBe("ICEBERG");
152 expect(response.versionToken).toBeTruthy();
153 expect(response.warehouseLocation).toMatch(/^s3:\/\//);
154 }),
155 );
156 });
157
158 describe("GetTableMetadataLocation + UpdateTableMetadataLocation", () => {
159 test.provider("round-trips the Iceberg commit protocol", (_stack) =>
160 Effect.gen(function* () {
161 const current = (yield* send(
162 HttpClientRequest.get(`${baseUrl}/metadata-location`),
163 ).pipe(Effect.flatMap((r) => r.json))) as any;
164 expect(current.versionToken).toBeTruthy();
165 expect(current.warehouseLocation).toMatch(/^s3:\/\//);
166
167 // The commit either succeeds (the service records the pointer) or is
168 // rejected with a typed 4xx tag because the fixture didn't write a
169 // real Iceberg metadata file first — both prove the IAM grant and
170 // identifier injection (a grant gap would be a 500 AccessDenied).
171 const commit = (yield* send(
172 HttpClientRequest.post(`${baseUrl}/metadata-location/commit`),
173 ).pipe(Effect.flatMap((r) => r.json))) as any;
174 if (commit.committed) {
175 expect(commit.versionTokenChanged).toBe(true);
176 } else {
177 expect(["BadRequestException", "ConflictException"]).toContain(
178 commit.errorTag,
179 );
180 }
181 }),
182 );
183 });
184
185 describe("GetTableMaintenanceJobStatus", () => {
186 test.provider("reads the table's maintenance job statuses", (_stack) =>
187 Effect.gen(function* () {
188 const response = (yield* send(
189 HttpClientRequest.get(`${baseUrl}/maintenance-jobs`),
190 ).pipe(Effect.flatMap((r) => r.json))) as any;
191 expect(response.tableArn).toContain("/table/");
192 expect(Array.isArray(response.jobs)).toBe(true);
193 }),
194 );
195 });
196 },
197);