1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import * as lambda from "@distilled.cloud/aws/lambda";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import DevOpsGuruTestFunctionLive, { DevOpsGuruTestFunction } from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "DevOpsGuruBindings");
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;
26let functionArn: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33class FunctionStillExists extends Data.TaggedError("FunctionStillExists") {}
34
35// The shared Lambda fixture occasionally answers a transient 5xx under load
36// (cold re-init, IAM propagation on the freshly attached policy that the
37// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
38// 4xx/assertion failure surfaces immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61const getJson = (path: string) =>
62 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
63 Effect.flatMap((r) => r.json),
64 );
65
66describe.sequential(
67 "DevOpsGuru Bindings",
68 {
69 tags: [
70 "provider:aws",
71 "provider:aws:devopsguru",
72 "provider:aws:eventbridge",
73 "provider:aws:lambda",
74 "live",
75 ],
76 },
77 () => {
78 beforeAll(
79 Effect.gen(function* () {
80 yield* Effect.logInfo(
81 "DevOpsGuru test setup: destroying previous resources",
82 );
83 yield* sharedStack.destroy();
84
85 yield* Effect.logInfo("DevOpsGuru test setup: deploying fixture");
86 const attrs = yield* sharedStack.deploy(
87 Effect.gen(function* () {
88 return yield* DevOpsGuruTestFunction;
89 }).pipe(Effect.provide(DevOpsGuruTestFunctionLive)),
90 );
91
92 expect(attrs.functionUrl).toBeTruthy();
93 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
94 functionArn = attrs.functionArn;
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `DevOpsGuru test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `DevOpsGuru test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(
120 Effect.gen(function* () {
121 yield* sharedStack.destroy();
122 // Assert gone (skipped when beforeAll never got far enough to deploy):
123 // the fixture Lambda answers with the typed not-found tag, and the
124 // event-source's EventBridge rule no longer targets it. afterAll runs
125 // outside `test.provider`'s layer, so raw distilled calls need the
126 // provider layer (credentials, region) supplied explicitly.
127 if (functionArn) {
128 yield* Core.withProviders(
129 Effect.gen(function* () {
130 yield* lambda.getFunction({ FunctionName: functionArn }).pipe(
131 Effect.flatMap(() => Effect.fail(new FunctionStillExists())),
132 Effect.retry({
133 while: (error) => error._tag === "FunctionStillExists",
134 schedule: Schedule.exponential("500 millis"),
135 times: 8,
136 }),
137 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
138 );
139 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
140 TargetArn: functionArn,
141 });
142 expect(RuleNames ?? []).toHaveLength(0);
143 }),
144 testOptions,
145 sharedStack.name,
146 );
147 }
148 }),
149 { timeout: 120_000 },
150 );
151
152 describe("binding registration", () => {
153 test.provider("all 22 capabilities initialize in the runtime", (_stack) =>
154 Effect.gen(function* () {
155 const response = (yield* getJson("/bindings")) as { bound: string[] };
156 expect(response.bound).toHaveLength(22);
157 expect(response.bound).toContain("describeAccountHealth");
158 expect(response.bound).toContain("searchInsights");
159 expect(response.bound).toContain("putFeedback");
160 }),
161 );
162 });
163
164 describe("DescribeAccountHealth", () => {
165 test.provider("reads the account's insight counters", (_stack) =>
166 Effect.gen(function* () {
167 const response = (yield* getJson("/health")) as {
168 openReactiveInsights: number;
169 openProactiveInsights: number;
170 metricsAnalyzed: number;
171 };
172 expect(response.openReactiveInsights).toBeGreaterThanOrEqual(0);
173 expect(response.openProactiveInsights).toBeGreaterThanOrEqual(0);
174 expect(response.metricsAnalyzed).toBeGreaterThanOrEqual(0);
175 }),
176 );
177 });
178
179 describe("DescribeAccountOverview", () => {
180 test.provider("summarizes the trailing 24 hours", (_stack) =>
181 Effect.gen(function* () {
182 const response = (yield* getJson("/overview")) as {
183 reactiveInsights: number;
184 proactiveInsights: number;
185 };
186 expect(response.reactiveInsights).toBeGreaterThanOrEqual(0);
187 expect(response.proactiveInsights).toBeGreaterThanOrEqual(0);
188 }),
189 );
190 });
191
192 describe("ListInsights", () => {
193 test.provider("lists ongoing reactive insights", (_stack) =>
194 Effect.gen(function* () {
195 const response = (yield* getJson("/insights")) as { count: number };
196 expect(response.count).toBeGreaterThanOrEqual(0);
197 }),
198 );
199 });
200
201 describe("SearchInsights", () => {
202 test.provider("searches reactive insights by time range", (_stack) =>
203 Effect.gen(function* () {
204 const response = (yield* getJson("/search")) as { count: number };
205 expect(response.count).toBeGreaterThanOrEqual(0);
206 }),
207 );
208 });
209
210 describe("DescribeResourceCollectionHealth", () => {
211 test.provider("reads per-stack coverage health", (_stack) =>
212 Effect.gen(function* () {
213 const response = (yield* getJson("/collection-health")) as {
214 stacks: number;
215 };
216 expect(response.stacks).toBeGreaterThanOrEqual(0);
217 }),
218 );
219 });
220
221 describe("ListMonitoredResources", () => {
222 test.provider(
223 "lists analyzed resources (typed not-found -> zero coverage)",
224 (_stack) =>
225 Effect.gen(function* () {
226 const response = (yield* getJson("/monitored")) as {
227 count: number;
228 };
229 expect(response.count).toBeGreaterThanOrEqual(0);
230 }),
231 );
232 });
233
234 describe("consumeInsightEvents", () => {
235 test.provider(
236 "the deploy created an EventBridge rule targeting the function",
237 (_stack) =>
238 Effect.gen(function* () {
239 // Out-of-band via distilled: the fixture's consumeInsightEvents
240 // must have materialized as a rule on the default bus with the
241 // Lambda as target.
242 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
243 TargetArn: functionArn,
244 });
245 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
246 }),
247 );
248 });
249 },
250);