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 OpenSearchDataPlaneFunctionLive, {
11 OpenSearchDataPlaneFunction,
12} from "./data-plane-handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "OpenSearchDataPlane");
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
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// Retry only transient 5xx (cold re-init, IAM propagation on the freshly
33// attached es:ESHttp* policy); a genuine 4xx surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("1 second"),
51 Schedule.recurs(8),
52 ]),
53 }),
54 );
55
56// The fixture provisions a real OpenSearch domain (~15-25 minutes, billed
57// per instance-hour) — gated behind AWS_TEST_SLOW=1 like the Domain
58// lifecycle test. The suite stays skip-clean without the flag.
59const describeGated = process.env.AWS_TEST_SLOW
60 ? describe.sequential
61 : describe.skip;
62
63describeGated("OpenSearch Data Plane", () => {
64 beforeAll(
65 Effect.gen(function* () {
66 yield* Effect.logInfo(
67 "OpenSearch data-plane setup: destroying previous resources",
68 );
69 yield* sharedStack.destroy();
70
71 yield* Effect.logInfo(
72 "OpenSearch data-plane setup: deploying fixture (domain takes ~15-25 minutes)",
73 );
74 const { functionUrl } = yield* sharedStack.deploy(
75 Effect.gen(function* () {
76 return yield* OpenSearchDataPlaneFunction;
77 }).pipe(Effect.provide(OpenSearchDataPlaneFunctionLive)),
78 );
79
80 expect(functionUrl).toBeTruthy();
81 baseUrl = functionUrl!.replace(/\/+$/, "");
82
83 const readinessUrl = `${baseUrl}/bindings`;
84 yield* Effect.logInfo(
85 `OpenSearch data-plane setup: probing readiness at ${readinessUrl}`,
86 );
87 yield* HttpClient.get(readinessUrl).pipe(
88 Effect.flatMap((response) =>
89 response.status === 200
90 ? Effect.succeed(response)
91 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
92 ),
93 Effect.tapError((error) =>
94 Effect.logWarning(
95 `OpenSearch data-plane setup: fixture not ready yet (${String(error)})`,
96 ),
97 ),
98 Effect.retry({ schedule: readinessPolicy }),
99 );
100 }),
101 // Domain provisioning dominates: ~15-25 minutes.
102 { timeout: 2_700_000 },
103 );
104
105 // Destroy immediately — domains bill while they exist.
106 afterAll(sharedStack.destroy(), { timeout: 600_000 });
107
108 describe(
109 "binding registration",
110 {
111 tags: [
112 "provider:aws",
113 "provider:aws:lambda",
114 "provider:aws:opensearch",
115 "live",
116 ],
117 },
118 () => {
119 test.provider("all 3 data-plane capabilities initialize", (_stack) =>
120 Effect.gen(function* () {
121 const response = (yield* send(
122 HttpClientRequest.get(`${baseUrl}/bindings`),
123 ).pipe(Effect.flatMap((r) => r.json))) as any;
124 expect(response.bound).toEqual(["reader", "writer", "client"]);
125 }),
126 );
127 },
128 );
129
130 describe(
131 "DomainWrite",
132 {
133 tags: [
134 "provider:aws",
135 "provider:aws:lambda",
136 "provider:aws:opensearch",
137 "live",
138 ],
139 },
140 () => {
141 test.provider(
142 "indexDocument writes a document (es:ESHttpPut)",
143 (_stack) =>
144 Effect.gen(function* () {
145 const response = (yield* send(
146 HttpClientRequest.post(`${baseUrl}/doc`),
147 ).pipe(Effect.flatMap((r) => r.json))) as any;
148 expect(["created", "updated"]).toContain(response.result);
149 }),
150 { timeout: 120_000 },
151 );
152
153 test.provider(
154 "bulk applies NDJSON operations (es:ESHttpPost)",
155 (_stack) =>
156 Effect.gen(function* () {
157 const response = (yield* send(
158 HttpClientRequest.post(`${baseUrl}/bulk`),
159 ).pipe(Effect.flatMap((r) => r.json))) as any;
160 expect(response.errors).toBe(false);
161 expect(response.items).toBe(1);
162 }),
163 { timeout: 120_000 },
164 );
165
166 test.provider(
167 "updateDocument partially updates (es:ESHttpPost _update)",
168 (_stack) =>
169 Effect.gen(function* () {
170 const response = (yield* send(
171 HttpClientRequest.post(`${baseUrl}/update`),
172 ).pipe(Effect.flatMap((r) => r.json))) as any;
173 expect(["updated", "noop"]).toContain(response.result);
174 }),
175 { timeout: 120_000 },
176 );
177 },
178 );
179
180 describe(
181 "DomainRead",
182 {
183 tags: [
184 "provider:aws",
185 "provider:aws:lambda",
186 "provider:aws:opensearch",
187 "live",
188 ],
189 },
190 () => {
191 test.provider(
192 "getDocument reads back the stored document (es:ESHttpGet)",
193 (_stack) =>
194 Effect.gen(function* () {
195 const response = (yield* send(
196 HttpClientRequest.get(`${baseUrl}/doc`),
197 ).pipe(Effect.flatMap((r) => r.json))) as any;
198 expect(response.found).toBe(true);
199 expect(response.title).toBe("The Wind Cries Mary");
200 }),
201 { timeout: 120_000 },
202 );
203
204 test.provider(
205 "getDocument on a missing id is found:false, not an error",
206 (_stack) =>
207 Effect.gen(function* () {
208 const response = (yield* send(
209 HttpClientRequest.get(`${baseUrl}/doc-missing`),
210 ).pipe(Effect.flatMap((r) => r.json))) as any;
211 expect(response.found).toBe(false);
212 }),
213 { timeout: 120_000 },
214 );
215
216 test.provider(
217 "existsDocument distinguishes present from missing (es:ESHttpHead)",
218 (_stack) =>
219 Effect.gen(function* () {
220 const response = (yield* send(
221 HttpClientRequest.get(`${baseUrl}/exists`),
222 ).pipe(Effect.flatMap((r) => r.json))) as any;
223 expect(response.exists).toBe(true);
224 expect(response.missing).toBe(false);
225 }),
226 { timeout: 120_000 },
227 );
228
229 test.provider(
230 "search matches via the source query parameter (es:ESHttpGet)",
231 (_stack) =>
232 Effect.gen(function* () {
233 const response = (yield* send(
234 HttpClientRequest.get(`${baseUrl}/search`),
235 ).pipe(Effect.flatMap((r) => r.json))) as any;
236 expect(response.total).toBeGreaterThanOrEqual(1);
237 expect(response.firstTitle).toBe("The Wind Cries Mary");
238 }),
239 { timeout: 120_000 },
240 );
241
242 test.provider(
243 "count counts documents in the index",
244 (_stack) =>
245 Effect.gen(function* () {
246 const response = (yield* send(
247 HttpClientRequest.get(`${baseUrl}/count`),
248 ).pipe(Effect.flatMap((r) => r.json))) as any;
249 expect(response.count).toBeGreaterThanOrEqual(1);
250 }),
251 { timeout: 120_000 },
252 );
253 },
254 );
255
256 describe(
257 "DomainReadWrite",
258 {
259 tags: [
260 "provider:aws",
261 "provider:aws:lambda",
262 "provider:aws:opensearch",
263 "live",
264 ],
265 },
266 () => {
267 test.provider(
268 "raw request reads cluster health (es:ESHttp*)",
269 (_stack) =>
270 Effect.gen(function* () {
271 const response = (yield* send(
272 HttpClientRequest.get(`${baseUrl}/health`),
273 ).pipe(Effect.flatMap((r) => r.json))) as any;
274 expect(["green", "yellow", "red"]).toContain(response.status);
275 }),
276 { timeout: 120_000 },
277 );
278
279 test.provider(
280 "deleteDocument deletes and tolerates not_found (es:ESHttpDelete)",
281 (_stack) =>
282 Effect.gen(function* () {
283 const response = (yield* send(
284 HttpClientRequest.post(`${baseUrl}/delete`),
285 ).pipe(Effect.flatMap((r) => r.json))) as any;
286 expect(response.first).toBe("deleted");
287 expect(response.second).toBe("not_found");
288 }),
289 { timeout: 120_000 },
290 );
291 },
292 );
293});