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 OsisTestFunctionLive, { OsisTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "OSISBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy). Retry only
32// 5xx; a genuine 4xx/assertion failure surfaces immediately.
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e) => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("500 millis"),
50 Schedule.recurs(6),
51 ]),
52 }),
53 );
54
55const getJson = (path: string) =>
56 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
57 Effect.flatMap((r) => r.json),
58 );
59
60describe.sequential(
61 "OSIS Bindings",
62 {
63 tags: ["provider:aws", "provider:aws:lambda", "provider:aws:osis", "live"],
64 },
65 () => {
66 beforeAll(
67 Effect.gen(function* () {
68 yield* Effect.logInfo("OSIS test setup: destroying previous resources");
69 yield* sharedStack.destroy();
70
71 yield* Effect.logInfo("OSIS test setup: deploying fixture");
72 const attrs = yield* sharedStack.deploy(
73 Effect.gen(function* () {
74 return yield* OsisTestFunction;
75 }).pipe(Effect.provide(OsisTestFunctionLive)),
76 );
77
78 expect(attrs.functionUrl).toBeTruthy();
79 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
80
81 const readinessUrl = `${baseUrl}/bindings`;
82 yield* Effect.logInfo(
83 `OSIS test setup: probing readiness at ${readinessUrl}`,
84 );
85 yield* HttpClient.get(readinessUrl).pipe(
86 Effect.flatMap((response) =>
87 response.status === 200
88 ? Effect.succeed(response)
89 : Effect.fail(
90 new Error(`Function not ready: ${response.status}`),
91 ),
92 ),
93 Effect.tapError((error) =>
94 Effect.logWarning(
95 `OSIS test setup: fixture not ready yet (${String(error)})`,
96 ),
97 ),
98 Effect.retry({ schedule: readinessPolicy }),
99 );
100 }),
101 { timeout: 240_000 },
102 );
103
104 afterAll(sharedStack.destroy(), { timeout: 120_000 });
105
106 describe("binding registration", () => {
107 test.provider(
108 "all four capabilities initialize in the runtime",
109 (_stack) =>
110 Effect.gen(function* () {
111 const response = (yield* getJson("/bindings")) as {
112 bound: string[];
113 };
114 expect(response.bound).toHaveLength(4);
115 }),
116 );
117 });
118
119 describe("ValidatePipeline", () => {
120 test.provider("accepts a valid configuration body", (_stack) =>
121 Effect.gen(function* () {
122 const response = (yield* getJson("/validate-good")) as {
123 isValid: boolean;
124 errors: string[];
125 };
126 expect(response.isValid).toBe(true);
127 expect(response.errors).toHaveLength(0);
128 }),
129 );
130
131 test.provider("rejects an unknown sink plugin", (_stack) =>
132 Effect.gen(function* () {
133 const response = (yield* getJson("/validate-bad")) as {
134 isValid: boolean;
135 errors: string[];
136 };
137 expect(response.isValid).toBe(false);
138 expect(response.errors.length).toBeGreaterThan(0);
139 }),
140 );
141 });
142
143 describe("ListPipelineBlueprints + GetPipelineBlueprint", () => {
144 test.provider(
145 "lists the blueprint catalog and fetches one template",
146 (_stack) =>
147 Effect.gen(function* () {
148 const { names } = (yield* getJson("/blueprints")) as {
149 names: string[];
150 };
151 expect(names.length).toBeGreaterThan(0);
152
153 const response = (yield* getJson(
154 `/blueprint?name=${encodeURIComponent(names[0]!)}`,
155 )) as { name: string; hasBody: boolean };
156 expect(response.name).toBe(names[0]);
157 expect(response.hasBody).toBe(true);
158 }),
159 );
160 });
161
162 describe("ListPipelineEndpointConnections", () => {
163 test.provider("enumerates the account's endpoint connections", (_stack) =>
164 Effect.gen(function* () {
165 const response = (yield* getJson("/endpoint-connections")) as {
166 count: number;
167 };
168 expect(response.count).toBeGreaterThanOrEqual(0);
169 }),
170 );
171 });
172 },
173);