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 * as Option from "effect/Option";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// RealtimeKit is beta / entitlement-gated — unentitled accounts get the
20// typed `Forbidden` (403) on every call. Probe and no-op when unentitled;
21// the App suite pins the typed tag.
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
30const getPreset = (accountId: string, appId: string, presetId: string) =>
31 realtimeKit.getPresetByIdPreset({ accountId, appId, presetId }).pipe(
32 Effect.retry({
33 while: (e) => e._tag === "Forbidden",
34 schedule: Schedule.exponential("500 millis"),
35 times: 8,
36 }),
37 Effect.map((res) => res.data),
38 );
39
40// Poll until the preset is gone after destroy — Cloudflare answers GET for
41// a missing preset with the typed `RealtimeKitPresetNotFound` (404).
42const expectGone = (accountId: string, appId: string, presetId: string) =>
43 getPreset(accountId, appId, presetId).pipe(
44 Effect.asSome,
45 Effect.catchTag("RealtimeKitPresetNotFound", () => Effect.succeedNone),
46 Effect.repeat({
47 schedule: Schedule.exponential("500 millis"),
48 until: Option.isNone,
49 times: 8,
50 }),
51 Effect.map((preset) => expect(Option.isNone(preset)).toBe(true)),
52 );
53
54// Deterministic names — apps cannot be deleted, so every run adopts the
55// same app instead of leaking a new one. Each test gets its own preset
56// name: the suites run concurrently (`sequence.concurrent`) and a shared
57// preset name would make two stacks create the same preset in the same app
58// and race to a 409.
59const APP_NAME = "alchemy-rtk-test-app";
60const LIFECYCLE_PRESET_NAME = "alchemy-rtk-test-preset-lifecycle";
61const LIST_PRESET_NAME = "alchemy-rtk-test-preset-list";
62
63test.provider(
64 "create with defaults, verify out-of-band, update in place, destroy",
65 (stack) =>
66 Effect.gen(function* () {
67 const entitled = yield* probeEntitlement;
68 if (!entitled) {
69 yield* Effect.logInfo(
70 "account is not RealtimeKit-entitled; skipping lifecycle",
71 );
72 return;
73 }
74
75 const { accountId } = yield* yield* CloudflareEnvironment;
76
77 yield* stack.destroy();
78
79 // Create — preset with all-default config / ui / permissions.
80 const v1 = yield* stack.deploy(
81 Effect.gen(function* () {
82 const app = yield* Cloudflare.RealtimeKit.App("App", {
83 name: APP_NAME,
84 });
85 return yield* Cloudflare.RealtimeKit.Preset("Preset", {
86 appId: app.appId,
87 name: LIFECYCLE_PRESET_NAME,
88 });
89 }),
90 );
91
92 expect(v1.presetId).toBeTruthy();
93 expect(v1.accountId).toEqual(accountId);
94 expect(v1.name).toEqual(LIFECYCLE_PRESET_NAME);
95 expect(v1.config.viewType).toEqual("GROUP_CALL");
96 expect(v1.permissions?.canRecord).toBe(false);
97
98 // Out-of-band verification via the distilled API.
99 const live = yield* getPreset(accountId, v1.appId, v1.presetId);
100 expect(live.name).toEqual(LIFECYCLE_PRESET_NAME);
101 expect(live.config.viewType).toEqual("GROUP_CALL");
102
103 // In-place update — grant recording + moderation. Same preset (no
104 // replacement).
105 const v2 = yield* stack.deploy(
106 Effect.gen(function* () {
107 const app = yield* Cloudflare.RealtimeKit.App("App", {
108 name: APP_NAME,
109 });
110 return yield* Cloudflare.RealtimeKit.Preset("Preset", {
111 appId: app.appId,
112 name: LIFECYCLE_PRESET_NAME,
113 permissions: {
114 ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetPermissions(),
115 canRecord: true,
116 kickParticipant: true,
117 pinParticipant: true,
118 },
119 });
120 }),
121 );
122
123 expect(v2.presetId).toEqual(v1.presetId);
124 expect(v2.permissions?.canRecord).toBe(true);
125 expect(v2.permissions?.kickParticipant).toBe(true);
126
127 const updated = yield* getPreset(accountId, v2.appId, v2.presetId);
128 expect(updated.permissions?.canRecord).toBe(true);
129
130 // Idempotent re-deploy — reconcile must detect the no-op.
131 const v3 = yield* stack.deploy(
132 Effect.gen(function* () {
133 const app = yield* Cloudflare.RealtimeKit.App("App", {
134 name: APP_NAME,
135 });
136 return yield* Cloudflare.RealtimeKit.Preset("Preset", {
137 appId: app.appId,
138 name: LIFECYCLE_PRESET_NAME,
139 permissions: {
140 ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetPermissions(),
141 canRecord: true,
142 kickParticipant: true,
143 pinParticipant: true,
144 },
145 });
146 }),
147 );
148 expect(v3.presetId).toEqual(v1.presetId);
149
150 // Destroy — the preset must be deleted (the app remains; it has no
151 // delete API).
152 yield* stack.destroy();
153 yield* expectGone(accountId, v1.appId, v1.presetId);
154 }).pipe(logLevel),
155 {
156 tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"],
157 timeout: 120_000,
158 },
159);
160
161test.provider(
162 "list enumerates the deployed preset",
163 (stack) =>
164 Effect.gen(function* () {
165 const entitled = yield* probeEntitlement;
166 if (!entitled) {
167 // RealtimeKit beta is entitlement-gated: an unentitled account gets the
168 // typed `Forbidden` (403) on `getApp`, which `list()` propagates. Skip
169 // the live assertion; the App suite pins the typed tag.
170 yield* Effect.logInfo(
171 "account is not RealtimeKit-entitled; skipping list",
172 );
173 return;
174 }
175
176 yield* stack.destroy();
177
178 const deployed = yield* stack.deploy(
179 Effect.gen(function* () {
180 const app = yield* Cloudflare.RealtimeKit.App("App", {
181 name: APP_NAME,
182 });
183 return yield* Cloudflare.RealtimeKit.Preset("Preset", {
184 appId: app.appId,
185 name: LIST_PRESET_NAME,
186 });
187 }),
188 );
189
190 const provider = yield* Provider.findProvider(
191 Cloudflare.RealtimeKit.Preset,
192 );
193 const all = yield* provider.list();
194
195 expect(
196 all.some(
197 (p) => p.presetId === deployed.presetId && p.appId === deployed.appId,
198 ),
199 ).toBe(true);
200 const found = all.find((p) => p.presetId === deployed.presetId);
201 expect(found?.name).toEqual(LIST_PRESET_NAME);
202 expect(found?.config.viewType).toEqual("GROUP_CALL");
203
204 yield* stack.destroy();
205 yield* expectGone(deployed.accountId, deployed.appId, deployed.presetId);
206 }).pipe(logLevel),
207 {
208 tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"],
209 timeout: 120_000,
210 },
211);