1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit";
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// 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.
22const 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.
32const 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.
45const 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.
49const 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
61test.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
88test.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
160test.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);