1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as aas from "@distilled.cloud/aws/application-auto-scaling";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Result from "effect/Result";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import ApplicationAutoScalingTestFunctionLive, {
14 ApplicationAutoScalingTestFunction,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(
20 testOptions,
21 "ApplicationAutoScalingBindings",
22);
23
24// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
25// over 60s on a fresh deploy under parallel-suite load.
26const readinessPolicy = Schedule.max([
27 Schedule.fixed("2 seconds"),
28 Schedule.recurs(75),
29]);
30
31let baseUrl: string;
32
33class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
34 readonly status: number;
35 readonly body: string;
36}> {}
37
38// The shared Lambda fixture occasionally answers a transient 5xx under
39// full-suite parallel load (cold re-init, IAM propagation on the freshly
40// attached policy that the handler's `Effect.orDie` surfaces as a 500).
41// Retry only 5xx; a genuine 4xx/assertion failure surfaces immediately.
42const send = (request: HttpClientRequest.HttpClientRequest) =>
43 HttpClient.execute(request).pipe(
44 Effect.flatMap((response) =>
45 response.status >= 500
46 ? response.text.pipe(
47 Effect.flatMap((body) =>
48 Effect.fail(
49 new TransientUpstream({ status: response.status, body }),
50 ),
51 ),
52 )
53 : Effect.succeed(response),
54 ),
55 Effect.retry({
56 while: (e) => e._tag === "TransientUpstream",
57 schedule: Schedule.max([
58 Schedule.exponential("500 millis"),
59 Schedule.recurs(6),
60 ]),
61 }),
62 );
63
64describe.sequential(
65 "ApplicationAutoScaling Bindings",
66 {
67 tags: [
68 "provider:aws",
69 "provider:aws:applicationautoscaling",
70 "provider:aws:dynamodb",
71 "provider:aws:lambda",
72 "live",
73 ],
74 },
75 () => {
76 beforeAll(
77 Effect.gen(function* () {
78 yield* Effect.logInfo(
79 "ApplicationAutoScaling test setup: destroying previous resources",
80 );
81 yield* sharedStack.destroy();
82
83 yield* Effect.logInfo(
84 "ApplicationAutoScaling test setup: deploying fixture",
85 );
86 const { functionUrl } = yield* sharedStack.deploy(
87 Effect.gen(function* () {
88 return yield* ApplicationAutoScalingTestFunction;
89 }).pipe(Effect.provide(ApplicationAutoScalingTestFunctionLive)),
90 );
91
92 expect(functionUrl).toBeTruthy();
93 baseUrl = functionUrl!.replace(/\/+$/, "");
94
95 const readinessUrl = `${baseUrl}/bindings`;
96 yield* Effect.logInfo(
97 `ApplicationAutoScaling test setup: probing readiness at ${readinessUrl}`,
98 );
99 yield* HttpClient.get(readinessUrl).pipe(
100 Effect.flatMap((response) =>
101 response.status === 200
102 ? Effect.succeed(response)
103 : Effect.fail(
104 new Error(`Function not ready: ${response.status}`),
105 ),
106 ),
107 Effect.tapError((error) =>
108 Effect.logWarning(
109 `ApplicationAutoScaling test setup: fixture not ready yet (${String(error)})`,
110 ),
111 ),
112 Effect.retry({ schedule: readinessPolicy }),
113 );
114 }),
115 { timeout: 240_000 },
116 );
117
118 afterAll(sharedStack.destroy(), { timeout: 120_000 });
119
120 describe("binding registration", () => {
121 test.provider("both capabilities initialize in the runtime", (_stack) =>
122 Effect.gen(function* () {
123 const response = yield* send(
124 HttpClientRequest.get(`${baseUrl}/bindings`),
125 ).pipe(Effect.flatMap((r) => r.json));
126 expect((response as any).bound).toEqual([
127 "describeScalingActivities",
128 "getPredictiveScalingForecast",
129 ]);
130 }),
131 );
132 });
133
134 describe("DescribeScalingActivities", () => {
135 test.provider("lists the fixture target's scaling activities", (_stack) =>
136 Effect.gen(function* () {
137 const response = yield* send(
138 HttpClientRequest.get(`${baseUrl}/scaling-activities`),
139 ).pipe(Effect.flatMap((r) => r.json));
140 // A fresh target typically has zero activities — assert the call
141 // round-trips with a well-formed page (the identity triple was
142 // injected and the IAM grant works).
143 expect(typeof (response as any).count).toBe("number");
144 }),
145 );
146 });
147
148 describe(
149 "ScalingActivityEventSource",
150 { tags: ["provider:aws:eventbridge"] },
151 () => {
152 test.provider(
153 "consumeScalingActivityEvents created the EventBridge rule",
154 (_stack) =>
155 Effect.gen(function* () {
156 // The rule's physical name starts with the stack name but the
157 // 64-char rule-name budget truncates the long
158 // `BindingsTarget-ScalingActivityEvents` logical id, so scope by
159 // NamePrefix and match on the rule's event pattern instead
160 // (bounded manual pagination).
161 let rule: eventbridge.Rule | undefined;
162 let nextToken: string | undefined;
163 for (let page = 0; page < 10 && !rule; page++) {
164 const result = yield* eventbridge.listRules({
165 NamePrefix: "ApplicationAutoScalingBindings",
166 NextToken: nextToken,
167 });
168 rule = (result.Rules ?? []).find((candidate) =>
169 candidate.EventPattern?.includes(
170 "aws.application-autoscaling",
171 ),
172 );
173 nextToken = result.NextToken;
174 if (!nextToken) break;
175 }
176 expect(rule).toBeDefined();
177 expect(rule?.EventPattern).toContain(
178 "aws.application-autoscaling",
179 );
180 expect(rule?.EventPattern).toContain(
181 "Application Auto Scaling Scaling Activity State Change",
182 );
183 }),
184 { timeout: 60_000 },
185 );
186 },
187 );
188
189 describe("GetPredictiveScalingForecast", () => {
190 // Ungated probe (runs in the test process, which resolves distilled from
191 // patched `src/` via the bun export condition): predictive scaling
192 // exists only for ECS services, and for a dynamodb-namespace target the
193 // live API answers with the typed PredictiveScalingForecastNotSupported
194 // — a distilled synthetic error carved out of AccessDeniedException
195 // "GetPredictiveScalingForecast is not supported.". The namespace check
196 // runs before any existence check, so the probe needs no live target.
197 test.provider(
198 "distilled patch: non-ECS namespace surfaces the typed not-supported tag",
199 (_stack) =>
200 Effect.gen(function* () {
201 const result = yield* Effect.result(
202 aas.getPredictiveScalingForecast({
203 ServiceNamespace: "dynamodb",
204 ResourceId: "table/alchemy-probe-nonexistent",
205 ScalableDimension: "dynamodb:table:ReadCapacityUnits",
206 PolicyName: "alchemy-probe-nonexistent",
207 StartTime: new Date("2030-01-01T00:00:00Z"),
208 EndTime: new Date("2030-01-01T12:00:00Z"),
209 }),
210 );
211 expect(Result.isFailure(result)).toBe(true);
212 if (Result.isFailure(result)) {
213 expect(result.failure._tag).toBe(
214 "PredictiveScalingForecastNotSupported",
215 );
216 }
217 }),
218 );
219
220 // End-to-end through the deployed Lambda: the fixture's policy is on the
221 // dynamodb namespace, so the call is rejected with the platform
222 // not-supported error. An IAM gap would ALSO surface as an
223 // AccessDeniedException — the probe above plus the CLI-verified message
224 // ("GetPredictiveScalingForecast is not supported.") pin the platform
225 // behavior; here we accept either the synthetic tag (patched distilled
226 // in the bundle) or its base wire tag (the deployed bundle resolves
227 // distilled's built `lib/`, which only picks up the patch after the
228 // coordinator rebuilds distilled).
229 test.provider(
230 "runtime binding surfaces the typed rejection through the Lambda",
231 (_stack) =>
232 Effect.gen(function* () {
233 const response = yield* send(
234 HttpClientRequest.get(`${baseUrl}/forecast`),
235 ).pipe(Effect.flatMap((r) => r.json));
236 expect([
237 "PredictiveScalingForecastNotSupported",
238 "AccessDeniedException",
239 ]).toContain((response as any).tag);
240 }),
241 );
242 });
243 },
244);