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 getWebhook = (accountId: string, appId: string, webhookId: string) =>
31 realtimeKit.getWebhookByIdWebhook({ accountId, appId, webhookId }).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 webhook is gone after destroy — Cloudflare answers GET for
41// a missing webhook with the typed `RealtimeKitWebhookNotFound` (404).
42const expectGone = (accountId: string, appId: string, webhookId: string) =>
43 getWebhook(accountId, appId, webhookId).pipe(
44 Effect.asSome,
45 Effect.catchTag("RealtimeKitWebhookNotFound", () => Effect.succeedNone),
46 Effect.repeat({
47 schedule: Schedule.exponential("500 millis"),
48 until: Option.isNone,
49 times: 8,
50 }),
51 Effect.map((webhook) => expect(Option.isNone(webhook)).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 webhook
56// name AND url: the suites run concurrently (`sequence.concurrent`) and both
57// adopt the same singleton app, so they'd otherwise create webhooks in the
58// same app. RealtimeKit enforces a unique webhook url per app, so two suites
59// sharing a url race to a 409 (a shared name alone is not enough to collide).
60const APP_NAME = "alchemy-rtk-test-app";
61const LIFECYCLE_WEBHOOK_NAME = "alchemy-rtk-test-webhook-lifecycle";
62const LIST_WEBHOOK_NAME = "alchemy-rtk-test-webhook-list";
63const LIFECYCLE_WEBHOOK_URL =
64 "https://example.com/alchemy-rtk-webhook-lifecycle";
65const LIST_WEBHOOK_URL = "https://example.com/alchemy-rtk-webhook-list";
66
67test.provider(
68 "create, verify out-of-band, update in place, destroy",
69 (stack) =>
70 Effect.gen(function* () {
71 const entitled = yield* probeEntitlement;
72 if (!entitled) {
73 yield* Effect.logInfo(
74 "account is not RealtimeKit-entitled; skipping lifecycle",
75 );
76 return;
77 }
78
79 const { accountId } = yield* yield* CloudflareEnvironment;
80
81 yield* stack.destroy();
82
83 // Create — meeting lifecycle events to a fixed URL.
84 const v1 = yield* stack.deploy(
85 Effect.gen(function* () {
86 const app = yield* Cloudflare.RealtimeKit.App("App", {
87 name: APP_NAME,
88 });
89 return yield* Cloudflare.RealtimeKit.Webhook("Webhook", {
90 appId: app.appId,
91 name: LIFECYCLE_WEBHOOK_NAME,
92 url: LIFECYCLE_WEBHOOK_URL,
93 events: ["meeting.started", "meeting.ended"],
94 });
95 }),
96 );
97
98 expect(v1.webhookId).toBeTruthy();
99 expect(v1.accountId).toEqual(accountId);
100 expect(v1.name).toEqual(LIFECYCLE_WEBHOOK_NAME);
101 expect(v1.url).toEqual(LIFECYCLE_WEBHOOK_URL);
102 expect([...v1.events].sort()).toEqual([
103 "meeting.ended",
104 "meeting.started",
105 ]);
106 expect(v1.enabled).toBe(true);
107
108 // Out-of-band verification via the distilled API.
109 const live = yield* getWebhook(accountId, v1.appId, v1.webhookId);
110 expect(live.name).toEqual(LIFECYCLE_WEBHOOK_NAME);
111 expect(live.url).toEqual(LIFECYCLE_WEBHOOK_URL);
112
113 // In-place update — pause delivery and change the event set. Same
114 // webhook (no replacement).
115 const v2 = yield* stack.deploy(
116 Effect.gen(function* () {
117 const app = yield* Cloudflare.RealtimeKit.App("App", {
118 name: APP_NAME,
119 });
120 return yield* Cloudflare.RealtimeKit.Webhook("Webhook", {
121 appId: app.appId,
122 name: LIFECYCLE_WEBHOOK_NAME,
123 url: LIFECYCLE_WEBHOOK_URL,
124 events: ["recording.statusUpdate"],
125 enabled: false,
126 });
127 }),
128 );
129
130 expect(v2.webhookId).toEqual(v1.webhookId);
131 expect(v2.enabled).toBe(false);
132 expect(v2.events).toEqual(["recording.statusUpdate"]);
133
134 const updated = yield* getWebhook(accountId, v2.appId, v2.webhookId);
135 expect(updated.enabled).toBe(false);
136 expect([...updated.events]).toEqual(["recording.statusUpdate"]);
137
138 // Idempotent re-deploy — reconcile must detect the no-op.
139 const v3 = yield* stack.deploy(
140 Effect.gen(function* () {
141 const app = yield* Cloudflare.RealtimeKit.App("App", {
142 name: APP_NAME,
143 });
144 return yield* Cloudflare.RealtimeKit.Webhook("Webhook", {
145 appId: app.appId,
146 name: LIFECYCLE_WEBHOOK_NAME,
147 url: LIFECYCLE_WEBHOOK_URL,
148 events: ["recording.statusUpdate"],
149 enabled: false,
150 });
151 }),
152 );
153 expect(v3.webhookId).toEqual(v1.webhookId);
154
155 // Destroy — the webhook must be deleted (the app remains; it has no
156 // delete API).
157 yield* stack.destroy();
158 yield* expectGone(accountId, v1.appId, v1.webhookId);
159 }).pipe(logLevel),
160 {
161 tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"],
162 timeout: 120_000,
163 },
164);
165
166// `list()` fans out over every RealtimeKit app in the account and flattens
167// each app's webhooks. When the account is RealtimeKit-entitled, deploy a
168// webhook and assert it appears; when unentitled the apps endpoint 403s
169// (typed `Forbidden`) and `list()` returns a well-typed empty array.
170test.provider(
171 "list enumerates webhooks across the account's apps",
172 (stack) =>
173 Effect.gen(function* () {
174 yield* stack.destroy();
175
176 const provider = yield* Provider.findProvider(
177 Cloudflare.RealtimeKit.Webhook,
178 );
179
180 const entitled = yield* probeEntitlement;
181 if (!entitled) {
182 yield* Effect.logInfo(
183 "account is not RealtimeKit-entitled; asserting empty list",
184 );
185 const empty = yield* provider.list();
186 expect(empty).toEqual([]);
187 yield* stack.destroy();
188 return;
189 }
190
191 const deployed = yield* stack.deploy(
192 Effect.gen(function* () {
193 const app = yield* Cloudflare.RealtimeKit.App("App", {
194 name: APP_NAME,
195 });
196 return yield* Cloudflare.RealtimeKit.Webhook("Webhook", {
197 appId: app.appId,
198 name: LIST_WEBHOOK_NAME,
199 url: LIST_WEBHOOK_URL,
200 events: ["meeting.started", "meeting.ended"],
201 });
202 }),
203 );
204
205 const all = yield* provider.list();
206 expect(all.some((w) => w.webhookId === deployed.webhookId)).toBe(true);
207 const found = all.find((w) => w.webhookId === deployed.webhookId);
208 expect(found?.name).toEqual(LIST_WEBHOOK_NAME);
209 expect(found?.url).toEqual(LIST_WEBHOOK_URL);
210 expect(found?.appId).toEqual(deployed.appId);
211
212 yield* stack.destroy();
213 }).pipe(logLevel),
214 {
215 tags: ["provider:cloudflare", "provider:cloudflare:realtimekit", "live"],
216 timeout: 120_000,
217 },
218);