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 BudgetsTestFunctionLive, {
11 BudgetsTestFunction,
12 fixtureBudgetName,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "BudgetsBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33// The shared Lambda fixture occasionally answers a transient 5xx under load
34// (cold re-init, IAM propagation on the freshly attached policy that the
35// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
36// 4xx/assertion failure surfaces immediately.
37const send = (request: HttpClientRequest.HttpClientRequest) =>
38 HttpClient.execute(request).pipe(
39 Effect.flatMap((response) =>
40 response.status >= 500
41 ? response.text.pipe(
42 Effect.flatMap((body) =>
43 Effect.fail(
44 new TransientUpstream({ status: response.status, body }),
45 ),
46 ),
47 )
48 : Effect.succeed(response),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "TransientUpstream",
52 schedule: Schedule.max([
53 Schedule.exponential("500 millis"),
54 Schedule.recurs(6),
55 ]),
56 }),
57 );
58
59describe.sequential(
60 "Budgets Bindings",
61 {
62 tags: [
63 "provider:aws",
64 "provider:aws:budgets",
65 "provider:aws:iam",
66 "provider:aws:lambda",
67 "live",
68 ],
69 },
70 () => {
71 beforeAll(
72 Effect.gen(function* () {
73 yield* Effect.logInfo(
74 "Budgets test setup: destroying previous resources",
75 );
76 yield* sharedStack.destroy();
77
78 yield* Effect.logInfo("Budgets test setup: deploying fixture");
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* BudgetsTestFunction;
82 }).pipe(Effect.provide(BudgetsTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87
88 const readinessUrl = `${baseUrl}/bindings`;
89 yield* Effect.logInfo(
90 `Budgets test setup: probing readiness at ${readinessUrl}`,
91 );
92 yield* HttpClient.get(readinessUrl).pipe(
93 Effect.flatMap((response) =>
94 response.status === 200
95 ? Effect.succeed(response)
96 : Effect.fail(
97 new Error(`Function not ready: ${response.status}`),
98 ),
99 ),
100 Effect.tapError((error) =>
101 Effect.logWarning(
102 `Budgets test setup: fixture not ready yet (${String(error)})`,
103 ),
104 ),
105 Effect.retry({ schedule: readinessPolicy }),
106 );
107 }),
108 { timeout: 300_000 },
109 );
110
111 afterAll(sharedStack.destroy(), { timeout: 180_000 });
112
113 describe("binding registration", () => {
114 test.provider("all 7 capabilities initialize in the runtime", (_stack) =>
115 Effect.gen(function* () {
116 const response = yield* send(
117 HttpClientRequest.get(`${baseUrl}/bindings`),
118 ).pipe(Effect.flatMap((r) => r.json));
119 expect((response as any).bound).toHaveLength(7);
120 }),
121 );
122 });
123
124 describe("DescribeBudget", () => {
125 test.provider("reads the fixture budget's definition", (_stack) =>
126 Effect.gen(function* () {
127 const response = yield* send(
128 HttpClientRequest.get(`${baseUrl}/budget`),
129 ).pipe(Effect.flatMap((r) => r.json));
130 expect((response as any).name).toBe(fixtureBudgetName);
131 expect(Number((response as any).limitAmount)).toBe(1_000_000);
132 expect((response as any).limitUnit).toBe("USD");
133 expect((response as any).timeUnit).toBe("MONTHLY");
134 }),
135 );
136 });
137
138 describe("DescribeBudgetPerformanceHistory", () => {
139 test.provider(
140 "reads the budget's history (fresh budget: >= 0 periods)",
141 (_stack) =>
142 Effect.gen(function* () {
143 const response = yield* send(
144 HttpClientRequest.get(`${baseUrl}/history`),
145 ).pipe(Effect.flatMap((r) => r.json));
146 expect(typeof (response as any).periods).toBe("number");
147 expect((response as any).periods).toBeGreaterThanOrEqual(0);
148 }),
149 );
150 });
151
152 describe("DescribeNotificationsForBudget", () => {
153 test.provider("lists the fixture notification threshold", (_stack) =>
154 Effect.gen(function* () {
155 const response = yield* send(
156 HttpClientRequest.get(`${baseUrl}/notifications`),
157 ).pipe(Effect.flatMap((r) => r.json));
158 expect((response as any).thresholds).toContain(80);
159 }),
160 );
161 });
162
163 describe("DescribeSubscribersForNotification", () => {
164 test.provider("lists the fixture notification's subscriber", (_stack) =>
165 Effect.gen(function* () {
166 const response = yield* send(
167 HttpClientRequest.get(`${baseUrl}/subscribers`),
168 ).pipe(Effect.flatMap((r) => r.json));
169 expect((response as any).count).toBe(1);
170 expect((response as any).subscriptionTypes).toContain("EMAIL");
171 }),
172 );
173 });
174
175 describe("DescribeBudgetActionsForBudget", () => {
176 test.provider("lists the fixture action in STANDBY", (_stack) =>
177 Effect.gen(function* () {
178 const response = yield* send(
179 HttpClientRequest.get(`${baseUrl}/actions`),
180 ).pipe(Effect.flatMap((r) => r.json));
181 const actions = (response as any).actions as {
182 actionId: string;
183 status: string;
184 actionType: string;
185 }[];
186 expect(actions.length).toBeGreaterThanOrEqual(1);
187 expect(actions[0]!.actionType).toBe("APPLY_IAM_POLICY");
188 expect(actions[0]!.status).toBe("STANDBY");
189 }),
190 );
191 });
192
193 describe("DescribeBudgetActionHistories", () => {
194 test.provider(
195 "reads the action's event history (>= CREATE_ACTION)",
196 (_stack) =>
197 Effect.gen(function* () {
198 const response = yield* send(
199 HttpClientRequest.get(`${baseUrl}/action-histories`),
200 ).pipe(Effect.flatMap((r) => r.json));
201 expect((response as any).count).toBeGreaterThanOrEqual(1);
202 expect((response as any).eventTypes).toContain("CREATE_ACTION");
203 }),
204 );
205 });
206
207 describe("ExecuteBudgetAction", () => {
208 test.provider(
209 "the grant works — execute returns a typed budgets outcome",
210 (_stack) =>
211 Effect.gen(function* () {
212 const response = yield* send(
213 HttpClientRequest.get(`${baseUrl}/execute-reset`),
214 ).pipe(Effect.flatMap((r) => r.json));
215 expect([
216 "ok",
217 "ResourceLockedException",
218 "InvalidParameterException",
219 ]).toContain((response as any).outcome);
220 }),
221 );
222 });
223 },
224);