| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 12 | |
| 13 | const logLevel = Effect.provideService( |
| 14 | MinimumLogLevel, |
| 15 | process.env.DEBUG ? "Debug" : "Info", |
| 16 | ); |
| 17 | |
| 18 | // RealtimeKit (the acquired Dyte platform) is in beta and must be enabled |
| 19 | // per account. On unentitled accounts every call fails with the typed |
| 20 | // `Forbidden` error (HTTP 403). Probe once per test: either the account can |
| 21 | // list apps (entitled) or we pin the typed tag and no-op the lifecycle. |
| 22 | const probeEntitlement = Effect.gen(function* () { |
| 23 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 24 | return yield* realtimeKit.getApp({ accountId }).pipe( |
| 25 | Effect.as(true), |
| 26 | Effect.catchTag("Forbidden", () => Effect.succeed(false)), |
| 27 | ); |
| 28 | }); |
| 29 | |
| 30 | // The scoped API token the test harness mints propagates eventually- |
| 31 | // consistently — ride out 403 blips on out-of-band verification calls. |
| 32 | const listApps = (accountId: string) => |
| 33 | realtimeKit.getApp({ accountId }).pipe( |
| 34 | Effect.retry({ |
| 35 | while: (e) => e._tag === "Forbidden", |
| 36 | schedule: Schedule.exponential("500 millis"), |
| 37 | times: 8, |
| 38 | }), |
| 39 | Effect.map((res) => res.data ?? []), |
| 40 | ); |
| 41 | |
| 42 | // Deterministic name — RealtimeKit apps cannot be deleted, so every run of |
| 43 | // this suite adopts the same app instead of leaking a new one. The Preset |
| 44 | // and Webhook suites share this name and adopt the same app. |
| 45 | const APP_NAME = "alchemy-rtk-test-app"; |
| 46 | |
| 47 | // Adoption is deterministic: among same-named apps (duplicates can exist on |
| 48 | // Cloudflare's side), the provider picks the oldest by createdAt. |
| 49 | const oldestNamed = ( |
| 50 | apps: readonly ({ |
| 51 | id?: string | null; |
| 52 | name?: string | null; |
| 53 | createdAt?: string | null; |
| 54 | } | null)[], |
| 55 | ) => |
| 56 | apps |
| 57 | .filter((a) => a?.name === APP_NAME) |
| 58 | .sort((a, b) => (a?.createdAt ?? "").localeCompare(b?.createdAt ?? "")) |
| 59 | .at(0); |
| 60 | |
| 61 | test.provider( |
| 62 | "unentitled accounts surface the typed Forbidden error", |
| 63 | (stack) => |
| 64 | Effect.gen(function* () { |
| 65 | yield* stack.destroy(); |
| 66 | |
| 67 | const entitled = yield* probeEntitlement; |
| 68 | if (entitled) { |
| 69 | yield* Effect.logInfo( |
| 70 | "account is RealtimeKit-entitled; probe test is a no-op", |
| 71 | ); |
| 72 | return; |
| 73 | } |
| 74 | |
| 75 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 76 | // The typed tag — not UnknownCloudflareError, not a status check. |
| 77 | const error = yield* realtimeKit.getApp({ accountId }).pipe(Effect.flip); |
| 78 | expect(error._tag).toEqual("Forbidden"); |
| 79 | |
| 80 | yield* stack.destroy(); |
| 81 | }).pipe(logLevel), |
| 82 | { |
| 83 | tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"], |
| 84 | timeout: 120_000, |
| 85 | }, |
| 86 | ); |
| 87 | |
| 88 | test.provider( |
| 89 | "create (or adopt), verify out-of-band, destroy forgets but keeps the app", |
| 90 | (stack) => |
| 91 | Effect.gen(function* () { |
| 92 | const entitled = yield* probeEntitlement; |
| 93 | if (!entitled) { |
| 94 | yield* Effect.logInfo( |
| 95 | "account is not RealtimeKit-entitled; skipping lifecycle", |
| 96 | ); |
| 97 | return; |
| 98 | } |
| 99 | |
| 100 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 101 | |
| 102 | yield* stack.destroy(); |
| 103 | |
| 104 | // If an app with the deterministic name already exists (from any |
| 105 | // previous run), the deploy must adopt the oldest one instead of |
| 106 | // creating a duplicate. |
| 107 | const preexisting = oldestNamed(yield* listApps(accountId)); |
| 108 | |
| 109 | // Create — or adopt the app left over from a previous run (apps can |
| 110 | // never be deleted, so adoption-by-name is the designed behavior). |
| 111 | const app = yield* stack.deploy( |
| 112 | Cloudflare.RealtimeKit.App("App", { name: APP_NAME }), |
| 113 | ); |
| 114 | |
| 115 | expect(app.appId).toBeTruthy(); |
| 116 | expect(app.accountId).toEqual(accountId); |
| 117 | expect(app.name).toEqual(APP_NAME); |
| 118 | if (preexisting) { |
| 119 | expect(app.appId).toEqual(preexisting.id); |
| 120 | } |
| 121 | |
| 122 | // Out-of-band verification via the distilled API. |
| 123 | const live = (yield* listApps(accountId)).find( |
| 124 | (a) => a?.id === app.appId, |
| 125 | ); |
| 126 | expect(live?.name).toEqual(APP_NAME); |
| 127 | |
| 128 | // Re-deploy is a no-op update — same app, same id. |
| 129 | const again = yield* stack.deploy( |
| 130 | Cloudflare.RealtimeKit.App("App", { name: APP_NAME }), |
| 131 | ); |
| 132 | expect(again.appId).toEqual(app.appId); |
| 133 | |
| 134 | // Destroy only forgets the app from state (no delete API) — the app |
| 135 | // must still exist on the account afterwards. |
| 136 | yield* stack.destroy(); |
| 137 | const after = (yield* listApps(accountId)).find( |
| 138 | (a) => a?.id === app.appId, |
| 139 | ); |
| 140 | expect(after?.id).toEqual(app.appId); |
| 141 | |
| 142 | // A fresh deploy after destroy adopts by name instead of creating a |
| 143 | // duplicate — deterministically the oldest same-named app (which may |
| 144 | // predate the one this run touched if duplicates leaked before |
| 145 | // adoption-by-name existed). |
| 146 | const expectedAdoptee = oldestNamed(yield* listApps(accountId)); |
| 147 | const adopted = yield* stack.deploy( |
| 148 | Cloudflare.RealtimeKit.App("App", { name: APP_NAME }), |
| 149 | ); |
| 150 | expect(adopted.appId).toEqual(expectedAdoptee?.id); |
| 151 | |
| 152 | yield* stack.destroy(); |
| 153 | }).pipe(logLevel), |
| 154 | { |
| 155 | tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"], |
| 156 | timeout: 120_000, |
| 157 | }, |
| 158 | ); |
| 159 | |
| 160 | test.provider( |
| 161 | "list enumerates the deployed RealtimeKit app", |
| 162 | (stack) => |
| 163 | Effect.gen(function* () { |
| 164 | const entitled = yield* probeEntitlement; |
| 165 | if (!entitled) { |
| 166 | // RealtimeKit beta is entitlement-gated: an unentitled account gets the |
| 167 | // typed `Forbidden` (403) on `getApp`, which `list()` propagates. Skip |
| 168 | // the live assertion; the probe test above pins the typed tag. |
| 169 | yield* Effect.logInfo( |
| 170 | "account is not RealtimeKit-entitled; skipping list", |
| 171 | ); |
| 172 | return; |
| 173 | } |
| 174 | |
| 175 | yield* stack.destroy(); |
| 176 | |
| 177 | // Create — or adopt the same-named app left over from a previous run |
| 178 | // (apps can never be deleted, so adoption-by-name is the designed |
| 179 | // behavior). |
| 180 | const deployed = yield* stack.deploy( |
| 181 | Cloudflare.RealtimeKit.App("App", { name: APP_NAME }), |
| 182 | ); |
| 183 | |
| 184 | const provider = yield* Provider.findProvider(Cloudflare.RealtimeKit.App); |
| 185 | const all = yield* provider.list(); |
| 186 | |
| 187 | expect(all.some((a) => a.appId === deployed.appId)).toBe(true); |
| 188 | const found = all.find((a) => a.appId === deployed.appId); |
| 189 | expect(found?.name).toEqual(APP_NAME); |
| 190 | expect(found?.accountId).toEqual(deployed.accountId); |
| 191 | |
| 192 | // Destroy only forgets the app (no delete API) — it still exists, so a |
| 193 | // subsequent list() would still observe it. That's the expected residue. |
| 194 | yield* stack.destroy(); |
| 195 | }).pipe(logLevel), |
| 196 | { |
| 197 | tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"], |
| 198 | timeout: 120_000, |
| 199 | }, |
| 200 | ); |