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 CostExplorerTestFunctionLive, {
11 CostExplorerTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "CostExplorerBindings");
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// 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
58const getJson = (path: string) =>
59 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
60 Effect.flatMap((r) => r.json),
61 );
62
63describe.sequential(
64 "CostExplorer Bindings",
65 {
66 tags: [
67 "provider:aws",
68 "provider:aws:costexplorer",
69 "provider:aws:lambda",
70 "live",
71 ],
72 },
73 () => {
74 beforeAll(
75 Effect.gen(function* () {
76 yield* Effect.logInfo(
77 "CostExplorer test setup: destroying previous resources",
78 );
79 yield* sharedStack.destroy();
80
81 yield* Effect.logInfo("CostExplorer test setup: deploying fixture");
82 const { functionUrl } = yield* sharedStack.deploy(
83 Effect.gen(function* () {
84 return yield* CostExplorerTestFunction;
85 }).pipe(Effect.provide(CostExplorerTestFunctionLive)),
86 );
87
88 expect(functionUrl).toBeTruthy();
89 baseUrl = functionUrl!.replace(/\/+$/, "");
90
91 const readinessUrl = `${baseUrl}/bindings`;
92 yield* Effect.logInfo(
93 `CostExplorer test setup: probing readiness at ${readinessUrl}`,
94 );
95 yield* HttpClient.get(readinessUrl).pipe(
96 Effect.flatMap((response) =>
97 response.status === 200
98 ? Effect.succeed(response)
99 : Effect.fail(
100 new Error(`Function not ready: ${response.status}`),
101 ),
102 ),
103 Effect.tapError((error) =>
104 Effect.logWarning(
105 `CostExplorer test setup: fixture not ready yet (${String(error)})`,
106 ),
107 ),
108 Effect.retry({ schedule: readinessPolicy }),
109 );
110 }),
111 { timeout: 240_000 },
112 );
113
114 afterAll(sharedStack.destroy(), { timeout: 120_000 });
115
116 describe("binding registration", () => {
117 test.provider("all 31 capabilities initialize in the runtime", (_stack) =>
118 Effect.gen(function* () {
119 const response = yield* getJson("/bindings");
120 expect((response as any).bound).toHaveLength(31);
121 }),
122 );
123 });
124
125 describe("GetCostAndUsage", () => {
126 test.provider(
127 "queries last month's unblended cost",
128 (_stack) =>
129 Effect.gen(function* () {
130 const response = (yield* getJson("/cost-and-usage")) as any;
131 // A fresh account may legitimately have no ingested data yet — the
132 // typed DataUnavailableException tag is the accepted alternative.
133 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
134 if (response.tag === "Ok") {
135 expect(response.results).toBeGreaterThan(0);
136 expect(response.amount).not.toBeNull();
137 }
138 }),
139 { timeout: 60_000 },
140 );
141 });
142
143 describe("GetDimensionValues", () => {
144 test.provider(
145 "lists the SERVICE dimension values",
146 (_stack) =>
147 Effect.gen(function* () {
148 const response = (yield* getJson("/dimension-values")) as any;
149 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
150 if (response.tag === "Ok") {
151 expect(response.count).toBeGreaterThan(0);
152 }
153 }),
154 { timeout: 60_000 },
155 );
156 });
157
158 describe("GetTags", () => {
159 test.provider(
160 "lists cost allocation tag keys",
161 (_stack) =>
162 Effect.gen(function* () {
163 const response = (yield* getJson("/tag-keys")) as any;
164 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
165 expect(typeof response.count).toBe("number");
166 }),
167 { timeout: 60_000 },
168 );
169 });
170
171 describe("GetCostCategories", () => {
172 test.provider(
173 "lists cost category names (fixture category present after processing)",
174 (_stack) =>
175 Effect.gen(function* () {
176 const response = (yield* getJson("/cost-categories")) as any;
177 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
178 expect(Array.isArray(response.names)).toBe(true);
179 }),
180 { timeout: 60_000 },
181 );
182 });
183
184 describe("GetCostForecast", () => {
185 test.provider(
186 "forecasts next month's spend (or reports insufficient data, typed)",
187 (_stack) =>
188 Effect.gen(function* () {
189 const response = (yield* getJson("/forecast")) as any;
190 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
191 }),
192 { timeout: 60_000 },
193 );
194 });
195
196 describe("GetApproximateUsageRecords", () => {
197 test.provider(
198 "estimates usage record volume per service",
199 (_stack) =>
200 Effect.gen(function* () {
201 const response = (yield* getJson("/approximate-usage")) as any;
202 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
203 expect(typeof response.total).toBe("number");
204 }),
205 { timeout: 60_000 },
206 );
207 });
208
209 describe("GetRightsizingRecommendation", () => {
210 test.provider(
211 "returns EC2 rightsizing recommendations (or the typed opt-in rejection)",
212 (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* getJson("/rightsizing")) as any;
215 // Rightsizing is an opt-in Cost Explorer preference on the payer
216 // account; without the opt-in the API rejects with
217 // RightsizingRecommendationNotEnabled — the AccessDeniedException
218 // "opt-in only feature" rejection patched into distilled as a
219 // specific typed tag. Either outcome proves the binding + grant.
220 expect(["Ok", "RightsizingRecommendationNotEnabled"]).toContain(
221 response.tag,
222 );
223 expect(response.count).toBeGreaterThanOrEqual(0);
224 }),
225 { timeout: 60_000 },
226 );
227 });
228
229 describe("GetReservationPurchaseRecommendation", () => {
230 test.provider(
231 "returns EC2 reservation purchase recommendations",
232 (_stack) =>
233 Effect.gen(function* () {
234 const response = (yield* getJson(
235 "/reservation-recommendation",
236 )) as any;
237 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
238 expect(response.count).toBeGreaterThanOrEqual(0);
239 }),
240 { timeout: 60_000 },
241 );
242 });
243
244 describe("GetSavingsPlansUtilization", () => {
245 test.provider(
246 "reads Savings Plans utilization (or typed data-unavailable)",
247 (_stack) =>
248 Effect.gen(function* () {
249 const response = (yield* getJson(
250 "/savings-plans-utilization",
251 )) as any;
252 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
253 }),
254 { timeout: 60_000 },
255 );
256 });
257
258 describe("ListSavingsPlansPurchaseRecommendationGeneration", () => {
259 test.provider(
260 "lists recent recommendation generations",
261 (_stack) =>
262 Effect.gen(function* () {
263 const response = (yield* getJson(
264 "/recommendation-generations",
265 )) as any;
266 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
267 expect(response.count).toBeGreaterThanOrEqual(0);
268 }),
269 { timeout: 60_000 },
270 );
271 });
272
273 describe("ListCommitmentPurchaseAnalyses", () => {
274 test.provider(
275 "lists commitment purchase analyses",
276 (_stack) =>
277 Effect.gen(function* () {
278 const response = (yield* getJson("/commitment-analyses")) as any;
279 expect(["Ok", "DataUnavailableException"]).toContain(response.tag);
280 expect(response.count).toBeGreaterThanOrEqual(0);
281 }),
282 { timeout: 60_000 },
283 );
284 });
285
286 describe("ListCostAllocationTags", () => {
287 test.provider(
288 "lists cost allocation tags",
289 (_stack) =>
290 Effect.gen(function* () {
291 const response = (yield* getJson("/allocation-tags")) as any;
292 expect(response.count).toBeGreaterThanOrEqual(0);
293 }),
294 { timeout: 60_000 },
295 );
296 });
297
298 describe("ListCostAllocationTagBackfillHistory", () => {
299 test.provider(
300 "lists backfill requests",
301 (_stack) =>
302 Effect.gen(function* () {
303 const response = (yield* getJson("/backfill-history")) as any;
304 expect(response.count).toBeGreaterThanOrEqual(0);
305 }),
306 { timeout: 60_000 },
307 );
308 });
309
310 describe("GetAnomalies", () => {
311 test.provider(
312 "lists the fixture monitor's anomalies (proving MonitorArn injection + the monitor-scoped grant)",
313 (_stack) =>
314 Effect.gen(function* () {
315 const response = (yield* getJson("/anomalies")) as any;
316 expect(response.count).toBeGreaterThanOrEqual(0);
317 }),
318 { timeout: 60_000 },
319 );
320 });
321
322 describe("ProvideAnomalyFeedback", () => {
323 test.provider(
324 "surfaces the typed ValidationException for a nonexistent anomaly (proving the grant)",
325 (_stack) =>
326 Effect.gen(function* () {
327 const response = (yield* getJson(
328 "/anomaly-feedback-invalid",
329 )) as any;
330 expect(response.tag).toBe("ValidationException");
331 }),
332 { timeout: 60_000 },
333 );
334 });
335
336 describe("ListCostCategoryResourceAssociations", () => {
337 test.provider(
338 "lists the fixture category's resource associations (proving CostCategoryArn injection)",
339 (_stack) =>
340 Effect.gen(function* () {
341 const response = (yield* getJson("/category-associations")) as any;
342 expect(response.count).toBeGreaterThanOrEqual(0);
343 }),
344 { timeout: 60_000 },
345 );
346 });
347 },
348);