1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import type { TestScheduleAttributes } from "@/Cloudflare/Speed/TestSchedule";
5import { findZoneByName } from "@/Cloudflare/Zone/lookup";
6import * as Provider from "@/Provider";
7import * as Test from "@/Test/Alchemy";
8import * as speed from "@distilled.cloud/cloudflare/speed";
9import { expect } from "alchemy-test";
10import * as Cause from "effect/Cause";
11import * as Effect from "effect/Effect";
12import { MinimumLogLevel } from "effect/References";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17// Cloudflare counts schedule *creations* against a per-day quota and does NOT
18// refund it on delete (Observatory docs: "Deleted tests are still counted as
19// part of the quota"), so neither the trailing `stack.destroy()` nor
20// `bun nuke` can reclaim budget. These tests each burn 1–2 creations per run
21// and exhaust `3/3` after ~1–2 same-day runs, so they are skipped by default.
22// Set RUN_SPEED_SCHEDULE_TESTS=1 to run them against an account/zone with
23// fresh daily budget.
24const runSpeedScheduleTests = !!process.env.RUN_SPEED_SCHEDULE_TESTS;
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31const zoneName =
32 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
33
34// Deterministic per-test URLs. Schedules are keyed per (url, region), so each
35// test owns a disjoint page path — reruns and parallel runs never collide and
36// the same identity is reused on every run.
37//
38// NOTE: Cloudflare caps schedule creations per URL at 3 per day
39// (`speed.errors.Schedule daily quota reached 3 / 3`, typed as
40// `TestScheduleQuotaReached`). Each test tolerates an exhausted quota by
41// skipping its assertions gracefully, so same-day reruns stay green.
42const URL_CREATE = `${zoneName}/speed-create`;
43const URL_FREQ = `${zoneName}/speed-freq`;
44const URL_REGION = `${zoneName}/speed-region`;
45const URL_TAKEOVER = `${zoneName}/speed-takeover`;
46const URL_LIST = `${zoneName}/speed-list`;
47
48const resolveZoneId = Effect.gen(function* () {
49 const { accountId } = yield* yield* CloudflareEnvironment;
50 const zone = yield* findZoneByName({ accountId, name: zoneName });
51 if (!zone) {
52 return yield* Effect.die(
53 new Error(`zone "${zoneName}" not found in account`),
54 );
55 }
56 return zone.id;
57});
58
59// The scoped API token the test harness mints propagates eventually-
60// consistently across Cloudflare's edge — a fresh token intermittently 403s.
61// Ride out the blips on the test's own out-of-band verification calls by
62// retrying the typed `Forbidden` error (part of each speed operation's error
63// union via distilled patches).
64const forbiddenRetrySchedule = Schedule.exponential("500 millis");
65
66/** Out-of-band read: the live schedule for (url, region), or undefined. */
67const findSchedule = (
68 zoneId: string,
69 url: string,
70 region: speed.GetScheduleRequest["region"] = "us-central1",
71) =>
72 speed.getSchedule({ zoneId, url, region }).pipe(
73 Effect.map((s): speed.GetScheduleResponse | undefined => s),
74 Effect.catchTag("TestScheduleNotFound", () => Effect.succeed(undefined)),
75 Effect.retry({
76 while: (e) => e._tag === "Forbidden",
77 schedule: forbiddenRetrySchedule,
78 times: 8,
79 }),
80 );
81
82/** Purge a schedule left behind by an interrupted run. */
83const purgeSchedule = (
84 zoneId: string,
85 url: string,
86 region: speed.DeleteScheduleRequest["region"] = "us-central1",
87) =>
88 speed.deleteSchedule({ zoneId, url, region }).pipe(
89 Effect.catchTag("TestScheduleNotFound", () => Effect.void),
90 Effect.retry({
91 while: (e) => e._tag === "Forbidden",
92 schedule: forbiddenRetrySchedule,
93 times: 8,
94 }),
95 );
96
97/**
98 * Deploy, but converge a `TestScheduleQuotaReached` failure (Cloudflare's
99 * per-URL daily schedule-creation cap) to `undefined` so the calling test
100 * can skip its assertions gracefully instead of failing on same-day reruns.
101 */
102const deployUnlessQuotaReached = (
103 stack: Test.ScratchStack,
104 eff: Effect.Effect<any, any, any>,
105): Effect.Effect<TestScheduleAttributes | undefined, any, any> =>
106 stack
107 .deploy(eff)
108 .pipe(
109 Effect.catchCause(
110 (
111 cause,
112 ): Effect.Effect<TestScheduleAttributes | undefined, any, never> =>
113 findQuotaError(cause)
114 ? Effect.succeed(undefined)
115 : Effect.failCause(cause),
116 ),
117 );
118
119const logQuotaSkip = (what: string) =>
120 Effect.logWarning(
121 `skipping ${what}: Cloudflare's daily schedule-creation quota for this URL is exhausted (TestScheduleQuotaReached)`,
122 );
123
124test.provider.skipIf(!runSpeedScheduleTests)(
125 "create and delete a scheduled speed test",
126 (stack) =>
127 Effect.gen(function* () {
128 const zoneId = yield* resolveZoneId;
129
130 yield* stack.destroy();
131 yield* purgeSchedule(zoneId, URL_CREATE);
132
133 const schedule = yield* deployUnlessQuotaReached(
134 stack,
135 Effect.gen(function* () {
136 return yield* Cloudflare.Speed.TestSchedule("DefaultSchedule", {
137 zoneId,
138 url: URL_CREATE,
139 frequency: "WEEKLY",
140 }).pipe(adopt(true));
141 }),
142 );
143
144 if (schedule === undefined) {
145 yield* logQuotaSkip("create/delete assertions");
146 yield* stack.destroy();
147 return;
148 }
149
150 expect(schedule.zoneId).toEqual(zoneId);
151 expect(schedule.url).toEqual(URL_CREATE);
152 expect(schedule.region).toEqual("us-central1");
153 expect(schedule.frequency).toEqual("WEEKLY");
154
155 const live = yield* findSchedule(zoneId, URL_CREATE);
156 expect(live).toBeDefined();
157 expect(live?.url).toEqual(URL_CREATE);
158 expect(live?.frequency).toEqual("WEEKLY");
159
160 yield* stack.destroy();
161
162 const gone = yield* findSchedule(zoneId, URL_CREATE);
163 expect(gone).toBeUndefined();
164 }).pipe(logLevel),
165 {
166 tags: [
167 "provider:cloudflare",
168 "provider:cloudflare:speed",
169 "provider:cloudflare:zone",
170 "live",
171 ],
172 timeout: 120_000,
173 },
174);
175
176test.provider.skipIf(!runSpeedScheduleTests)(
177 "changing the frequency converges in place",
178 (stack) =>
179 Effect.gen(function* () {
180 const zoneId = yield* resolveZoneId;
181
182 yield* stack.destroy();
183 yield* purgeSchedule(zoneId, URL_FREQ);
184
185 const initial = yield* deployUnlessQuotaReached(
186 stack,
187 Effect.gen(function* () {
188 return yield* Cloudflare.Speed.TestSchedule("UpdateSchedule", {
189 zoneId,
190 url: URL_FREQ,
191 frequency: "WEEKLY",
192 }).pipe(adopt(true));
193 }),
194 );
195 if (initial === undefined) {
196 yield* logQuotaSkip("frequency-update test");
197 yield* stack.destroy();
198 return;
199 }
200 expect(initial.frequency).toEqual("WEEKLY");
201
202 const updated = yield* deployUnlessQuotaReached(
203 stack,
204 Effect.gen(function* () {
205 return yield* Cloudflare.Speed.TestSchedule("UpdateSchedule", {
206 zoneId,
207 url: URL_FREQ,
208 frequency: "DAILY",
209 }).pipe(adopt(true));
210 }),
211 );
212
213 if (updated === undefined) {
214 yield* logQuotaSkip("frequency-update assertions");
215 // The provider restores the previous WEEKLY schedule when the
216 // post-delete re-create is quota-rejected (if even the restore was
217 // quota-rejected, the schedule is gone — both are acceptable here).
218 const restored = yield* findSchedule(zoneId, URL_FREQ);
219 if (restored) expect(restored.frequency).toEqual("WEEKLY");
220 yield* stack.destroy();
221 yield* purgeSchedule(zoneId, URL_FREQ);
222 return;
223 }
224
225 // Same identity — the schedule was converged, not replaced.
226 expect(updated.url).toEqual(URL_FREQ);
227 expect(updated.region).toEqual("us-central1");
228 expect(updated.frequency).toEqual("DAILY");
229
230 const live = yield* findSchedule(zoneId, URL_FREQ);
231 expect(live?.frequency).toEqual("DAILY");
232
233 yield* stack.destroy();
234
235 const gone = yield* findSchedule(zoneId, URL_FREQ);
236 expect(gone).toBeUndefined();
237 }).pipe(logLevel),
238 {
239 tags: [
240 "provider:cloudflare",
241 "provider:cloudflare:speed",
242 "provider:cloudflare:zone",
243 "live",
244 ],
245 timeout: 120_000,
246 },
247);
248
249test.provider.skipIf(!runSpeedScheduleTests)(
250 "changing the region triggers replacement",
251 (stack) =>
252 Effect.gen(function* () {
253 const zoneId = yield* resolveZoneId;
254
255 yield* stack.destroy();
256 yield* purgeSchedule(zoneId, URL_REGION, "us-central1");
257 yield* purgeSchedule(zoneId, URL_REGION, "us-east1");
258
259 const initial = yield* deployUnlessQuotaReached(
260 stack,
261 Effect.gen(function* () {
262 return yield* Cloudflare.Speed.TestSchedule("ReplaceSchedule", {
263 zoneId,
264 url: URL_REGION,
265 region: "us-central1",
266 frequency: "WEEKLY",
267 }).pipe(adopt(true));
268 }),
269 );
270 if (initial === undefined) {
271 yield* logQuotaSkip("region-replacement test");
272 yield* stack.destroy();
273 return;
274 }
275 expect(initial.region).toEqual("us-central1");
276
277 const replaced = yield* deployUnlessQuotaReached(
278 stack,
279 Effect.gen(function* () {
280 return yield* Cloudflare.Speed.TestSchedule("ReplaceSchedule", {
281 zoneId,
282 url: URL_REGION,
283 region: "us-east1",
284 frequency: "WEEKLY",
285 }).pipe(adopt(true));
286 }),
287 );
288 if (replaced === undefined) {
289 yield* logQuotaSkip("region-replacement assertions");
290 yield* stack.destroy();
291 yield* purgeSchedule(zoneId, URL_REGION, "us-central1");
292 yield* purgeSchedule(zoneId, URL_REGION, "us-east1");
293 return;
294 }
295
296 // (url, region) is the schedule's identity — a new schedule exists in
297 // the new region.
298 expect(replaced.region).toEqual("us-east1");
299
300 const live = yield* findSchedule(zoneId, URL_REGION, "us-east1");
301 expect(live).toBeDefined();
302
303 // The old region's schedule was deleted as part of the replacement.
304 const old = yield* findSchedule(zoneId, URL_REGION, "us-central1");
305 expect(old).toBeUndefined();
306
307 yield* stack.destroy();
308
309 const gone = yield* findSchedule(zoneId, URL_REGION, "us-east1");
310 expect(gone).toBeUndefined();
311 }).pipe(logLevel),
312 {
313 tags: [
314 "provider:cloudflare",
315 "provider:cloudflare:speed",
316 "provider:cloudflare:zone",
317 "live",
318 ],
319 timeout: 120_000,
320 },
321);
322
323test.provider(
324 "adoption — existing schedule errors without adopt, takes over with adopt(true)",
325 (stack) =>
326 Effect.gen(function* () {
327 const zoneId = yield* resolveZoneId;
328
329 yield* stack.destroy();
330 yield* purgeSchedule(zoneId, URL_TAKEOVER);
331
332 // Create the schedule out-of-band so the stack has no state of its own
333 // for it — exactly the "the schedule already exists" scenario.
334 const pre = yield* speed
335 .createSchedule({
336 zoneId,
337 url: URL_TAKEOVER,
338 region: "us-central1",
339 frequency: "WEEKLY",
340 })
341 .pipe(
342 Effect.map((r): speed.CreateScheduleResponse | undefined => r),
343 Effect.catchTag("TestScheduleQuotaReached", () =>
344 Effect.succeed(undefined),
345 ),
346 Effect.retry({
347 while: (e) => e._tag === "Forbidden",
348 schedule: forbiddenRetrySchedule,
349 times: 8,
350 }),
351 );
352 if (pre === undefined) {
353 yield* logQuotaSkip("adoption test");
354 return;
355 }
356 expect(pre.schedule?.url).toEqual(URL_TAKEOVER);
357
358 // Without `adopt`: schedules carry no ownership markers, so the engine
359 // cannot prove we created it and refuses to take it over.
360 // (Frequency stays WEEKLY throughout — converging the frequency is
361 // covered by the update test and would burn extra creation quota.)
362 const error = yield* stack
363 .deploy(
364 Effect.gen(function* () {
365 return yield* Cloudflare.Speed.TestSchedule("AdoptedSchedule", {
366 zoneId,
367 url: URL_TAKEOVER,
368 frequency: "WEEKLY",
369 });
370 }),
371 )
372 .pipe(
373 Effect.as(undefined),
374 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
375 );
376 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
377
378 // With `adopt(true)`: the engine takes over the pre-existing schedule
379 // (a no-op reconcile — no extra creation, so no quota concern).
380 const adopted = yield* stack.deploy(
381 Effect.gen(function* () {
382 return yield* Cloudflare.Speed.TestSchedule("AdoptedSchedule", {
383 zoneId,
384 url: URL_TAKEOVER,
385 frequency: "WEEKLY",
386 }).pipe(adopt(true));
387 }),
388 );
389
390 expect(adopted.url).toEqual(URL_TAKEOVER);
391 expect(adopted.frequency).toEqual("WEEKLY");
392
393 const live = yield* findSchedule(zoneId, URL_TAKEOVER);
394 expect(live?.frequency).toEqual("WEEKLY");
395
396 yield* stack.destroy();
397
398 const gone = yield* findSchedule(zoneId, URL_TAKEOVER);
399 expect(gone).toBeUndefined();
400 }).pipe(logLevel),
401 {
402 tags: [
403 "provider:cloudflare",
404 "provider:cloudflare:speed",
405 "provider:cloudflare:zone",
406 "live",
407 ],
408 timeout: 120_000,
409 },
410);
411
412test.provider.skipIf(!runSpeedScheduleTests)(
413 "list enumerates the deployed schedule across zones",
414 (stack) =>
415 Effect.gen(function* () {
416 const zoneId = yield* resolveZoneId;
417
418 yield* stack.destroy();
419 yield* purgeSchedule(zoneId, URL_LIST);
420
421 const schedule = yield* deployUnlessQuotaReached(
422 stack,
423 Effect.gen(function* () {
424 return yield* Cloudflare.Speed.TestSchedule("ListSchedule", {
425 zoneId,
426 url: URL_LIST,
427 frequency: "WEEKLY",
428 }).pipe(adopt(true));
429 }),
430 );
431
432 if (schedule === undefined) {
433 yield* logQuotaSkip("list assertions");
434 yield* stack.destroy();
435 return;
436 }
437
438 const provider = yield* Provider.findProvider(
439 Cloudflare.Speed.TestSchedule,
440 );
441 // Ride out fresh-token 403 blips on the account-wide enumeration.
442 const all = yield* provider.list().pipe(
443 Effect.retry({
444 while: (e) => e._tag === "Forbidden",
445 schedule: forbiddenRetrySchedule,
446 times: 8,
447 }),
448 );
449
450 // Each element is the exact `read` Attributes shape, usable by delete.
451 expect(
452 all.some(
453 (s) =>
454 s.zoneId === schedule.zoneId &&
455 s.url === schedule.url &&
456 s.region === schedule.region,
457 ),
458 ).toBe(true);
459
460 yield* stack.destroy();
461
462 const gone = yield* findSchedule(zoneId, URL_LIST);
463 expect(gone).toBeUndefined();
464 }).pipe(logLevel),
465 {
466 tags: [
467 "provider:cloudflare",
468 "provider:cloudflare:speed",
469 "provider:cloudflare:zone",
470 "live",
471 ],
472 timeout: 120_000,
473 },
474);
475
476/**
477 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
478 * whether the engine raised it as a typed failure or a defect.
479 */
480const findOwnedError = (
481 cause: Cause.Cause<unknown>,
482): OwnedBySomeoneElse | undefined =>
483 cause.reasons
484 .map((reason) =>
485 Cause.isFailReason(reason)
486 ? reason.error
487 : Cause.isDieReason(reason)
488 ? reason.defect
489 : undefined,
490 )
491 .find(
492 (value): value is OwnedBySomeoneElse =>
493 value instanceof OwnedBySomeoneElse,
494 );
495
496/**
497 * Pull the typed {@link speed.TestScheduleQuotaReached} value out of a Cause
498 * regardless of whether the engine raised it as a typed failure or a defect.
499 */
500const findQuotaError = (
501 cause: Cause.Cause<unknown>,
502): speed.TestScheduleQuotaReached | undefined =>
503 cause.reasons
504 .map((reason) =>
505 Cause.isFailReason(reason)
506 ? reason.error
507 : Cause.isDieReason(reason)
508 ? reason.defect
509 : undefined,
510 )
511 .find(
512 (value): value is speed.TestScheduleQuotaReached =>
513 value instanceof speed.TestScheduleQuotaReached,
514 );