1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// Load Balancing is a paid add-on subscription. The testing account does
19// not have it: pool creation is rejected with "Internal error creating or
20// modifying pool. Access Failed." (Cloudflare code 1002), surfaced as the
21// typed `PoolAccessFailed` error. Set CLOUDFLARE_LB_ENABLED=1 once the
22// subscription is provisioned to run the full lifecycle tests.
23const lbEnabled = !!process.env.CLOUDFLARE_LB_ENABLED;
24
25// Deterministic per-test physical names — reused on every run.
26const NAME_ENTITLEMENT = "alchemy-lb-pool-entitlement-probe";
27const NAME_LIFECYCLE = "alchemy-lb-pool-lifecycle";
28const NAME_MONITOR = "alchemy-lb-pool-lifecycle-monitor";
29
30// Freshly minted scoped tokens propagate eventually-consistently across
31// Cloudflare's edge — retry the typed `Forbidden` blips on out-of-band calls.
32const forbiddenRetrySchedule = Schedule.exponential("500 millis");
33
34const getPool = (accountId: string, poolId: string) =>
35 loadBalancers.getPool({ accountId, poolId }).pipe(
36 Effect.retry({
37 while: (e) => e._tag === "Forbidden",
38 schedule: forbiddenRetrySchedule,
39 times: 8,
40 }),
41 );
42
43const expectGone = (accountId: string, poolId: string) =>
44 getPool(accountId, poolId).pipe(
45 Effect.flatMap(() => Effect.fail({ _tag: "PoolNotDeleted" } as const)),
46 // A missing pool surfaces as the typed `PoolNotFound` (Cloudflare error
47 // code 1001) — that's the success condition here.
48 Effect.catchTag("PoolNotFound", () => Effect.void),
49 Effect.retry({
50 while: (e) => e._tag === "PoolNotDeleted",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(10),
54 ]),
55 }),
56 );
57
58// Unentitlement probe: pins the typed plan-gate rejection, so it must skip
59// on entitled accounts — there the create would succeed instead of failing.
60test.provider.skipIf(lbEnabled)(
61 "surfaces the typed PoolAccessFailed error without the LB subscription",
62 (stack) =>
63 Effect.gen(function* () {
64 const { accountId } = yield* yield* CloudflareEnvironment;
65
66 yield* stack.destroy();
67
68 const error = yield* loadBalancers
69 .createPool({
70 accountId,
71 name: NAME_ENTITLEMENT,
72 origins: [{ name: "origin-1", address: "203.0.113.10" }],
73 })
74 .pipe(
75 Effect.retry({
76 while: (e) => e._tag === "Forbidden",
77 schedule: forbiddenRetrySchedule,
78 times: 8,
79 }),
80 Effect.flip,
81 );
82 expect(error._tag).toEqual("PoolAccessFailed");
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 { tags: ["provider:cloudflare", "provider:cloudflare:loadbalancer", "live"] },
87);
88
89test.provider.skipIf(!lbEnabled)(
90 "create, update in place, and destroy a health-checked pool",
91 (stack) =>
92 Effect.gen(function* () {
93 const { accountId } = yield* yield* CloudflareEnvironment;
94
95 yield* stack.destroy();
96
97 const initial = yield* stack.deploy(
98 Effect.gen(function* () {
99 const monitor = yield* Cloudflare.LoadBalancer.Monitor("Monitor", {
100 description: NAME_MONITOR,
101 type: "https",
102 path: "/health",
103 expectedCodes: "2xx",
104 });
105 const pool = yield* Cloudflare.LoadBalancer.Pool("Pool", {
106 name: NAME_LIFECYCLE,
107 origins: [{ name: "origin-1", address: "203.0.113.10" }],
108 monitor: monitor.monitorId,
109 description: "v1",
110 });
111 return { monitor, pool };
112 }),
113 );
114
115 expect(initial.pool.poolId).toBeTruthy();
116 expect(initial.pool.accountId).toEqual(accountId);
117 expect(initial.pool.name).toEqual(NAME_LIFECYCLE);
118 expect(initial.pool.monitor).toEqual(initial.monitor.monitorId);
119
120 const live = yield* getPool(accountId, initial.pool.poolId);
121 expect(live.name).toEqual(NAME_LIFECYCLE);
122 expect(live.description).toEqual("v1");
123 expect((live.origins ?? []).map((o) => o.address)).toEqual([
124 "203.0.113.10",
125 ]);
126
127 // Mutable props (origins, weights, description) update in place —
128 // same poolId. Keep the monitor deployed across every step so the
129 // engine never has to replace and drop a dependency in one deploy.
130 const updated = yield* stack.deploy(
131 Effect.gen(function* () {
132 const monitor = yield* Cloudflare.LoadBalancer.Monitor("Monitor", {
133 description: NAME_MONITOR,
134 type: "https",
135 path: "/health",
136 expectedCodes: "2xx",
137 });
138 const pool = yield* Cloudflare.LoadBalancer.Pool("Pool", {
139 name: NAME_LIFECYCLE,
140 origins: [
141 { name: "origin-1", address: "203.0.113.10", weight: 0.7 },
142 { name: "origin-2", address: "203.0.113.11", weight: 0.3 },
143 ],
144 monitor: monitor.monitorId,
145 description: "v2",
146 });
147 return { monitor, pool };
148 }),
149 );
150 expect(updated.pool.poolId).toEqual(initial.pool.poolId);
151
152 const synced = yield* getPool(accountId, updated.pool.poolId);
153 expect(synced.description).toEqual("v2");
154 expect((synced.origins ?? []).map((o) => o.address).sort()).toEqual([
155 "203.0.113.10",
156 "203.0.113.11",
157 ]);
158
159 yield* stack.destroy();
160
161 yield* expectGone(accountId, initial.pool.poolId);
162 }).pipe(logLevel),
163 {
164 tags: ["provider:cloudflare", "provider:cloudflare:loadbalancer", "live"],
165 timeout: 120_000,
166 },
167);
168
169// Account-collection list: enumerate every pool in the account and assert
170// the deployed one is present. Gated like the lifecycle test — without the
171// LB subscription, the deploy fails with the typed `PoolAccessFailed`.
172test.provider.skipIf(!lbEnabled)(
173 "list enumerates the deployed pool",
174 (stack) =>
175 Effect.gen(function* () {
176 yield* stack.destroy();
177
178 const deployed = yield* stack.deploy(
179 Effect.gen(function* () {
180 return yield* Cloudflare.LoadBalancer.Pool("ListPool", {
181 name: NAME_LIFECYCLE,
182 origins: [{ name: "origin-1", address: "203.0.113.10" }],
183 });
184 }),
185 );
186
187 const provider = yield* Provider.findProvider(
188 Cloudflare.LoadBalancer.Pool,
189 );
190 const all = yield* provider.list();
191
192 expect(all.some((p) => p.poolId === deployed.poolId)).toBe(true);
193
194 yield* stack.destroy();
195 }).pipe(logLevel),
196 {
197 tags: ["provider:cloudflare", "provider:cloudflare:loadbalancer", "live"],
198 timeout: 120_000,
199 },
200);