| 1 | import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import type { TestScheduleAttributes } from "@/Cloudflare/Speed/TestSchedule"; |
| 5 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 6 | import * as Provider from "@/Provider"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import * as speed from "@distilled.cloud/cloudflare/speed"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Cause from "effect/Cause"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import { MinimumLogLevel } from "effect/References"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | |
| 15 | const { 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. |
| 24 | const runSpeedScheduleTests = !!process.env.RUN_SPEED_SCHEDULE_TESTS; |
| 25 | |
| 26 | const logLevel = Effect.provideService( |
| 27 | MinimumLogLevel, |
| 28 | process.env.DEBUG ? "Debug" : "Info", |
| 29 | ); |
| 30 | |
| 31 | const 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. |
| 42 | const URL_CREATE = `${zoneName}/speed-create`; |
| 43 | const URL_FREQ = `${zoneName}/speed-freq`; |
| 44 | const URL_REGION = `${zoneName}/speed-region`; |
| 45 | const URL_TAKEOVER = `${zoneName}/speed-takeover`; |
| 46 | const URL_LIST = `${zoneName}/speed-list`; |
| 47 | |
| 48 | const 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). |
| 64 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 65 | |
| 66 | /** Out-of-band read: the live schedule for (url, region), or undefined. */ |
| 67 | const 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. */ |
| 83 | const 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 | */ |
| 102 | const 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 | |
| 119 | const logQuotaSkip = (what: string) => |
| 120 | Effect.logWarning( |
| 121 | `skipping ${what}: Cloudflare's daily schedule-creation quota for this URL is exhausted (TestScheduleQuotaReached)`, |
| 122 | ); |
| 123 | |
| 124 | test.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 | |
| 176 | test.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 | |
| 249 | test.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 | |
| 323 | test.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 | |
| 412 | test.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 | */ |
| 480 | const 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 | */ |
| 500 | const 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 | ); |