1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
5import * as pricing from "@distilled.cloud/aws/pricing";
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 PricingTestFunctionLive, { PricingTestFunction } from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "PricingBindings");
18
19// The Price List Query API is only served from us-east-1 — pin the region for
20// out-of-band probes made directly from the test process.
21const withPricingRegion = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
22 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
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// Retry transient 5xx only; a genuine 4xx/assertion failure surfaces
39// immediately.
40const send = (request: HttpClientRequest.HttpClientRequest) =>
41 HttpClient.execute(request).pipe(
42 Effect.flatMap((response) =>
43 response.status >= 500
44 ? response.text.pipe(
45 Effect.flatMap((body) =>
46 Effect.fail(
47 new TransientUpstream({ status: response.status, body }),
48 ),
49 ),
50 )
51 : Effect.succeed(response),
52 ),
53 Effect.retry({
54 while: (e) => e._tag === "TransientUpstream",
55 schedule: Schedule.max([
56 Schedule.exponential("1 second"),
57 Schedule.recurs(5),
58 ]),
59 }),
60 );
61
62// Ungated probes against the real API (read-only, near-zero cost).
63//
64// Observed live behavior: an unknown ServiceCode does NOT error on ANY
65// pricing operation (GetProducts / DescribeServices / GetAttributeValues all
66// succeed with empty results). The API's documented NotFoundException is not
67// reachable via bogus codes; a constraint violation (MaxResults=0) is the
68// cheap deterministic typed error, proving distilled's error mapping and the
69// us-east-1 region pin.
70test.provider(
71 "getProducts with a bogus ServiceCode succeeds with an empty PriceList",
72 (_stack) =>
73 Effect.gen(function* () {
74 const result = yield* withPricingRegion(
75 pricing.getProducts({
76 ServiceCode: "AlchemyBogusServiceCodeProbe",
77 MaxResults: 1,
78 }),
79 );
80
81 expect(result.PriceList ?? []).toHaveLength(0);
82 }),
83 { tags: ["provider:aws", "provider:aws:pricing", "live"], timeout: 60_000 },
84);
85
86test.provider(
87 "getProducts with an out-of-range MaxResults returns a typed InvalidParameterException",
88 (_stack) =>
89 Effect.gen(function* () {
90 const result = yield* Effect.result(
91 withPricingRegion(
92 pricing.getProducts({
93 ServiceCode: "AmazonEC2",
94 MaxResults: 0,
95 }),
96 ),
97 );
98
99 expect(Result.isFailure(result)).toBe(true);
100 if (Result.isFailure(result)) {
101 expect(result.failure._tag).toBe("InvalidParameterException");
102 }
103 }),
104 { tags: ["provider:aws", "provider:aws:pricing", "live"], timeout: 60_000 },
105);
106
107describe(
108 "Pricing Bindings",
109 {
110 tags: [
111 "provider:aws",
112 "provider:aws:lambda",
113 "provider:aws:pricing",
114 "live",
115 ],
116 },
117 () => {
118 beforeAll(
119 Effect.gen(function* () {
120 yield* Effect.logInfo(
121 "Pricing test setup: destroying previous resources",
122 );
123 yield* sharedStack.destroy();
124
125 yield* Effect.logInfo("Pricing test setup: deploying fixture");
126 const { functionUrl } = yield* sharedStack.deploy(
127 Effect.gen(function* () {
128 return yield* PricingTestFunction;
129 }).pipe(Effect.provide(PricingTestFunctionLive)),
130 );
131
132 expect(functionUrl).toBeTruthy();
133 baseUrl = functionUrl!.replace(/\/+$/, "");
134 const readinessUrl = `${baseUrl}/ping`;
135
136 yield* Effect.logInfo(
137 `Pricing test setup: probing readiness at ${readinessUrl}`,
138 );
139 yield* HttpClient.get(readinessUrl).pipe(
140 Effect.flatMap((response) =>
141 response.status === 200
142 ? Effect.succeed(response)
143 : Effect.fail(
144 new Error(`Function not ready: ${response.status}`),
145 ),
146 ),
147 Effect.tapError((error) =>
148 Effect.logWarning(
149 `Pricing test setup: fixture not ready yet (${String(error)})`,
150 ),
151 ),
152 Effect.retry({ schedule: readinessPolicy }),
153 );
154 }),
155 { timeout: 240_000 },
156 );
157
158 afterAll(sharedStack.destroy(), { timeout: 120_000 });
159
160 describe("GetProducts", () => {
161 test.provider(
162 "returns a non-empty price list for AmazonEC2 t3.micro",
163 (_stack) =>
164 Effect.gen(function* () {
165 const response = (yield* send(
166 HttpClientRequest.get(`${baseUrl}/products`),
167 ).pipe(Effect.flatMap((r) => r.json))) as {
168 count: number;
169 formatVersion: string | undefined;
170 firstServiceCode: string | undefined;
171 firstInstanceType: string | undefined;
172 };
173
174 expect(response.count).toBeGreaterThan(0);
175 expect(response.firstServiceCode).toBe("AmazonEC2");
176 expect(response.firstInstanceType).toBe("t3.micro");
177 }),
178 { timeout: 120_000 },
179 );
180 });
181
182 describe("DescribeServices", () => {
183 test.provider(
184 "describes AmazonEC2 with its filterable attribute names",
185 (_stack) =>
186 Effect.gen(function* () {
187 const response = (yield* send(
188 HttpClientRequest.get(`${baseUrl}/services`),
189 ).pipe(Effect.flatMap((r) => r.json))) as {
190 services: Array<{
191 serviceCode: string;
192 attributeNameCount: number;
193 }>;
194 };
195
196 expect(response.services.length).toBe(1);
197 expect(response.services[0].serviceCode).toBe("AmazonEC2");
198 expect(response.services[0].attributeNameCount).toBeGreaterThan(0);
199 }),
200 { timeout: 120_000 },
201 );
202 });
203
204 describe("ListPriceLists + GetPriceListFileUrl", () => {
205 test.provider(
206 "lists EC2 price lists and presigns a bulk file URL",
207 (_stack) =>
208 Effect.gen(function* () {
209 const response = (yield* send(
210 HttpClientRequest.get(`${baseUrl}/price-list-file-url`),
211 ).pipe(Effect.flatMap((r) => r.json))) as {
212 count: number;
213 priceListArn: string;
214 regionCode: string | undefined;
215 currencyCode: string | undefined;
216 fileFormats: string[];
217 url: string | undefined;
218 };
219
220 expect(response.count).toBeGreaterThan(0);
221 expect(response.priceListArn).toContain("arn:aws:pricing:");
222 expect(response.regionCode).toBe("us-east-1");
223 expect(response.currencyCode).toBe("USD");
224 expect(response.fileFormats.length).toBeGreaterThan(0);
225 expect(response.url).toMatch(/^https:\/\//);
226 }),
227 { timeout: 120_000 },
228 );
229 });
230
231 describe("GetAttributeValues", () => {
232 test.provider(
233 "lists volumeType attribute values for AmazonEC2",
234 (_stack) =>
235 Effect.gen(function* () {
236 const response = (yield* send(
237 HttpClientRequest.get(`${baseUrl}/attribute-values`),
238 ).pipe(Effect.flatMap((r) => r.json))) as {
239 values: string[];
240 };
241
242 expect(response.values.length).toBeGreaterThan(0);
243 // gp2/gp3 are stable, long-standing EBS volume types.
244 expect(
245 response.values.some((value) =>
246 value.startsWith("General Purpose"),
247 ),
248 ).toBe(true);
249 }),
250 { timeout: 120_000 },
251 );
252 });
253 },
254);