| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import { deepEqual } from "@/Diff"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import { isResourceState, State, type ResourceState } from "@/State"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import { stripNullFields, stripUndefinedFields } from "@/Util/data"; |
| 9 | import * as zaraz from "@distilled.cloud/cloudflare/zaraz"; |
| 10 | import { describe, expect } from "alchemy-test"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import { MinimumLogLevel } from "effect/References"; |
| 13 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 14 | |
| 15 | const logLevel = Effect.provideService( |
| 16 | MinimumLogLevel, |
| 17 | process.env.DEBUG ? "Debug" : "Info", |
| 18 | ); |
| 19 | |
| 20 | const zoneId = process.env.CLOUDFLARE_TEST_ZARAZ_ZONE_ID; |
| 21 | const zoneName = |
| 22 | process.env.CLOUDFLARE_TEST_ZARAZ_ZONE_NAME ?? "alchemy-test-2.us"; |
| 23 | |
| 24 | // Zaraz needs no entitlement (available on all plans), but its config is a |
| 25 | // zone-wide singleton these tests mutate in place — they only run against a |
| 26 | // dedicated opt-in zone via CLOUDFLARE_TEST_ZARAZ_ZONE_ID (+ optional _NAME). |
| 27 | // All cases mutate the same zone-wide Zaraz config singleton; run them |
| 28 | // serially so they don't corrupt each other under the global concurrent |
| 29 | // test config. |
| 30 | describe.sequential( |
| 31 | "Config", |
| 32 | { tags: ["provider:cloudflare", "provider:cloudflare:zaraz", "live"] }, |
| 33 | () => { |
| 34 | test.provider.skipIf(!zoneId)( |
| 35 | "updates and retains a zone-level Zaraz config", |
| 36 | (stack) => |
| 37 | Effect.gen(function* () { |
| 38 | yield* stack.destroy(); |
| 39 | |
| 40 | const original = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 41 | const toggledDataLayer = !original.dataLayer; |
| 42 | |
| 43 | yield* Effect.gen(function* () { |
| 44 | const updated = yield* stack.deploy( |
| 45 | Effect.gen(function* () { |
| 46 | return yield* Cloudflare.Zaraz.Config("Config", { |
| 47 | zone: { zoneId: zoneId!, name: zoneName }, |
| 48 | dataLayer: toggledDataLayer, |
| 49 | }); |
| 50 | }), |
| 51 | ); |
| 52 | |
| 53 | expect(updated.zoneId).toEqual(zoneId); |
| 54 | expect(updated.dataLayer).toEqual(toggledDataLayer); |
| 55 | |
| 56 | const liveUpdated = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 57 | expect(liveUpdated.dataLayer).toEqual(toggledDataLayer); |
| 58 | |
| 59 | const restored = yield* stack.deploy( |
| 60 | Effect.gen(function* () { |
| 61 | return yield* Cloudflare.Zaraz.Config("Config", { |
| 62 | zone: { zoneId: zoneId!, name: zoneName }, |
| 63 | dataLayer: original.dataLayer, |
| 64 | }); |
| 65 | }), |
| 66 | ); |
| 67 | |
| 68 | expect(restored.dataLayer).toEqual(original.dataLayer); |
| 69 | |
| 70 | yield* stack.destroy(); |
| 71 | |
| 72 | const liveRetained = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 73 | expect(liveRetained.dataLayer).toEqual(original.dataLayer); |
| 74 | }).pipe( |
| 75 | Effect.ensuring( |
| 76 | zaraz |
| 77 | .putConfig(toPutConfig(zoneId!, original)) |
| 78 | .pipe(Effect.ignore), |
| 79 | ), |
| 80 | ); |
| 81 | }).pipe(logLevel), |
| 82 | { timeout: 120_000 }, |
| 83 | ); |
| 84 | |
| 85 | // Same gate as above: mutates the zone-wide Zaraz singleton, so it requires |
| 86 | // the dedicated opt-in zone from CLOUDFLARE_TEST_ZARAZ_ZONE_ID. |
| 87 | test.provider.skipIf(!zoneId)( |
| 88 | "delete true resets Zaraz config to defaults", |
| 89 | (stack) => |
| 90 | Effect.gen(function* () { |
| 91 | yield* stack.destroy(); |
| 92 | |
| 93 | const original = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 94 | const originalWorkflow = yield* zaraz.getWorkflow({ |
| 95 | zoneId: zoneId!, |
| 96 | }); |
| 97 | const defaults = yield* zaraz.getDefault({ zoneId: zoneId! }); |
| 98 | |
| 99 | yield* Effect.gen(function* () { |
| 100 | yield* stack.deploy( |
| 101 | Effect.gen(function* () { |
| 102 | return yield* Cloudflare.Zaraz.Config("Config", { |
| 103 | zone: { zoneId: zoneId!, name: zoneName }, |
| 104 | dataLayer: !defaults.dataLayer, |
| 105 | workflow: "preview", |
| 106 | delete: true, |
| 107 | }); |
| 108 | }), |
| 109 | ); |
| 110 | |
| 111 | const liveUpdated = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 112 | expect(liveUpdated.dataLayer).toEqual(!defaults.dataLayer); |
| 113 | |
| 114 | yield* stack.destroy(); |
| 115 | |
| 116 | const liveDeleted = yield* zaraz.getConfig({ zoneId: zoneId! }); |
| 117 | expect(liveDeleted.dataLayer).toEqual(defaults.dataLayer); |
| 118 | const liveDeletedWorkflow = yield* zaraz.getWorkflow({ |
| 119 | zoneId: zoneId!, |
| 120 | }); |
| 121 | expect(liveDeletedWorkflow).toEqual("realtime"); |
| 122 | }).pipe( |
| 123 | Effect.ensuring( |
| 124 | Effect.gen(function* () { |
| 125 | yield* zaraz |
| 126 | .putConfig(toPutConfig(zoneId!, original)) |
| 127 | .pipe(Effect.ignore); |
| 128 | yield* zaraz |
| 129 | .putZaraz({ zoneId: zoneId!, workflow: originalWorkflow }) |
| 130 | .pipe(Effect.ignore); |
| 131 | }), |
| 132 | ), |
| 133 | ); |
| 134 | }).pipe(logLevel), |
| 135 | { timeout: 120_000 }, |
| 136 | ); |
| 137 | |
| 138 | // Same gate as above: mutates the zone-wide Zaraz singleton, so it requires |
| 139 | // the dedicated opt-in zone from CLOUDFLARE_TEST_ZARAZ_ZONE_ID. |
| 140 | test.provider.skipIf(!zoneId)( |
| 141 | "updates and retains Zaraz workflow mode", |
| 142 | (stack) => |
| 143 | Effect.gen(function* () { |
| 144 | yield* stack.destroy(); |
| 145 | |
| 146 | const original = yield* zaraz.getWorkflow({ zoneId: zoneId! }); |
| 147 | const workflow = original === "realtime" ? "preview" : "realtime"; |
| 148 | |
| 149 | yield* Effect.gen(function* () { |
| 150 | const updated = yield* stack.deploy( |
| 151 | Effect.gen(function* () { |
| 152 | return yield* Cloudflare.Zaraz.Config("Config", { |
| 153 | zone: { zoneId: zoneId!, name: zoneName }, |
| 154 | workflow, |
| 155 | }); |
| 156 | }), |
| 157 | ); |
| 158 | |
| 159 | expect(updated.workflow).toEqual(workflow); |
| 160 | |
| 161 | const liveUpdated = yield* zaraz.getWorkflow({ zoneId: zoneId! }); |
| 162 | expect(liveUpdated).toEqual(workflow); |
| 163 | |
| 164 | yield* stack.destroy(); |
| 165 | |
| 166 | const liveRetained = yield* zaraz.getWorkflow({ zoneId: zoneId! }); |
| 167 | expect(liveRetained).toEqual(workflow); |
| 168 | }).pipe( |
| 169 | Effect.ensuring( |
| 170 | zaraz |
| 171 | .putZaraz({ zoneId: zoneId!, workflow: original }) |
| 172 | .pipe(Effect.ignore), |
| 173 | ), |
| 174 | ); |
| 175 | }).pipe(logLevel), |
| 176 | { timeout: 120_000 }, |
| 177 | ); |
| 178 | |
| 179 | // Canonical `list()` test (zone-scoped singleton): there is no account-wide |
| 180 | // API for the per-zone Zaraz config, so `list()` enumerates every zone via |
| 181 | // `listAllZones` and reads the singleton in each. Read-only — asserts the |
| 182 | // result is well-typed, non-empty, and contains the standing test zone. |
| 183 | test.provider( |
| 184 | "list enumerates Zaraz config across all zones", |
| 185 | (stack) => |
| 186 | Effect.gen(function* () { |
| 187 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 188 | const zone = yield* findZoneByName({ accountId, name: zoneName }); |
| 189 | if (!zone) { |
| 190 | return yield* Effect.die( |
| 191 | new Error(`zone "${zoneName}" not found in account`), |
| 192 | ); |
| 193 | } |
| 194 | |
| 195 | yield* stack.destroy(); |
| 196 | |
| 197 | const provider = yield* Provider.findProvider( |
| 198 | Cloudflare.Zaraz.Config, |
| 199 | ); |
| 200 | const all = yield* provider.list(); |
| 201 | |
| 202 | expect(all.length).toBeGreaterThan(0); |
| 203 | const row = all.find((c) => c.zoneId === zone.id); |
| 204 | expect(row).toBeDefined(); |
| 205 | expect(typeof row!.zoneId).toBe("string"); |
| 206 | expect(typeof row!.dataLayer).toBe("boolean"); |
| 207 | expect(typeof row!.zarazVersion).toBe("number"); |
| 208 | expect(row!.settings).toBeDefined(); |
| 209 | expect(row!.workflow).toBeDefined(); |
| 210 | |
| 211 | yield* stack.destroy(); |
| 212 | }).pipe(logLevel), |
| 213 | { tags: ["provider:cloudflare:zone"], timeout: 120_000 }, |
| 214 | ); |
| 215 | |
| 216 | // Ungated like the `list()` test: the deploy passes NO mutable props, so |
| 217 | // reconcile observes the zone config, computes an identical desired config, |
| 218 | // and skips putConfig/putZaraz entirely — the standing zone's singleton is |
| 219 | // never mutated (capture-and-restore below is just a safety net). |
| 220 | test.provider( |
| 221 | "recovers a half-created config whose creating-state lost Output-valued props (#736)", |
| 222 | (stack) => |
| 223 | Effect.gen(function* () { |
| 224 | yield* stack.destroy(); |
| 225 | |
| 226 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 227 | const zone = yield* findZoneByName({ accountId, name: zoneName }); |
| 228 | if (!zone) { |
| 229 | return yield* Effect.die( |
| 230 | new Error(`zone "${zoneName}" not found in account`), |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | const original = yield* zaraz.getConfig({ zoneId: zone.id }); |
| 235 | |
| 236 | yield* Effect.gen(function* () { |
| 237 | const deployConfig = () => |
| 238 | stack.deploy( |
| 239 | Effect.gen(function* () { |
| 240 | // No mutable props: reconcile is a pure observe (no put). |
| 241 | return yield* Cloudflare.Zaraz.Config("Config", { |
| 242 | zone: { zoneId: zone.id, name: zoneName }, |
| 243 | }); |
| 244 | }), |
| 245 | ); |
| 246 | |
| 247 | const created = yield* deployConfig(); |
| 248 | expect(created.zoneId).toEqual(zone.id); |
| 249 | |
| 250 | // Rewrite the persisted row into the wedged shape an interrupted |
| 251 | // deploy leaves behind: `creating`, no attributes, and the |
| 252 | // Output-valued `zone` prop lost in the round-trip (#736). |
| 253 | const state = yield* yield* State; |
| 254 | const stage = stack.stage; |
| 255 | const fqns = yield* state.list({ stack: stack.name, stage }); |
| 256 | const rows = yield* Effect.forEach(fqns, (fqn) => |
| 257 | state |
| 258 | .get({ stack: stack.name, stage, fqn }) |
| 259 | .pipe(Effect.map((row) => ({ fqn, row }))), |
| 260 | ); |
| 261 | const wedged = rows.find( |
| 262 | (r): r is { fqn: string; row: ResourceState } => |
| 263 | isResourceState(r.row) && |
| 264 | r.row.resourceType === "Cloudflare.Zaraz.Config", |
| 265 | ); |
| 266 | if (!wedged) { |
| 267 | return yield* Effect.die( |
| 268 | new Error( |
| 269 | "no Cloudflare.Zaraz.Config state row found after deploy", |
| 270 | ), |
| 271 | ); |
| 272 | } |
| 273 | yield* state.set({ |
| 274 | stack: stack.name, |
| 275 | stage, |
| 276 | fqn: wedged.fqn, |
| 277 | value: { |
| 278 | ...wedged.row, |
| 279 | status: "creating", |
| 280 | attr: undefined, |
| 281 | props: { |
| 282 | ...wedged.row.props, |
| 283 | zone: undefined, |
| 284 | }, |
| 285 | }, |
| 286 | }); |
| 287 | |
| 288 | // Before the fix this crashed in read's recovery path with |
| 289 | // `TypeError: undefined is not an object (evaluating 'zone.name')`. |
| 290 | const recovered = yield* deployConfig(); |
| 291 | expect(recovered.zoneId).toEqual(created.zoneId); |
| 292 | expect(recovered.dataLayer).toEqual(created.dataLayer); |
| 293 | expect(recovered.workflow).toEqual(created.workflow); |
| 294 | |
| 295 | yield* stack.destroy(); |
| 296 | }).pipe( |
| 297 | Effect.ensuring( |
| 298 | // Safety net: restore only if something actually changed, so the |
| 299 | // happy path leaves the standing zone's singleton untouched. |
| 300 | Effect.gen(function* () { |
| 301 | const current = yield* zaraz.getConfig({ zoneId: zone.id }); |
| 302 | if (!deepEqual(current, original)) { |
| 303 | yield* zaraz.putConfig(toPutConfig(zone.id, original)); |
| 304 | } |
| 305 | }).pipe(Effect.ignore), |
| 306 | ), |
| 307 | ); |
| 308 | }).pipe(logLevel), |
| 309 | { tags: ["provider:cloudflare:zone"], timeout: 240_000 }, |
| 310 | ); |
| 311 | }, |
| 312 | ); |
| 313 | |
| 314 | type ConfigResponse = zaraz.GetConfigResponse | zaraz.PutConfigResponse; |
| 315 | |
| 316 | const toPutConfig = ( |
| 317 | zoneId: string, |
| 318 | config: ConfigResponse, |
| 319 | ): zaraz.PutConfigRequest => |
| 320 | stripUndefinedFields({ |
| 321 | zoneId, |
| 322 | dataLayer: config.dataLayer, |
| 323 | debugKey: config.debugKey, |
| 324 | settings: stripNullFields(config.settings), |
| 325 | tools: config.tools, |
| 326 | triggers: config.triggers, |
| 327 | variables: config.variables, |
| 328 | zarazVersion: config.zarazVersion, |
| 329 | analytics: config.analytics ? stripNullFields(config.analytics) : undefined, |
| 330 | consent: config.consent ? stripNullFields(config.consent) : undefined, |
| 331 | historyChange: config.historyChange ?? undefined, |
| 332 | }) as zaraz.PutConfigRequest; |