1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as stream from "@distilled.cloud/cloudflare/stream";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
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// Ride out 403 blips (`Forbidden`) from scoped-token propagation on the
20// test's own out-of-band verification calls.
21const getWebhook = (accountId: string) =>
22 stream.getWebhook({ accountId }).pipe(
23 Effect.retry({
24 while: (e) => e._tag === "Forbidden",
25 schedule: Schedule.exponential("500 millis"),
26 times: 8,
27 }),
28 );
29
30const expectGone = (accountId: string) =>
31 getWebhook(accountId).pipe(
32 Effect.flatMap(() => Effect.fail({ _tag: "WebhookNotDeleted" } as const)),
33 // An unconfigured webhook surfaces as `WebhookNotFound` (Cloudflare
34 // error code 10003) — that's the success condition here.
35 Effect.catchTag("WebhookNotFound", () => Effect.void),
36 Effect.retry({
37 while: (e) => e._tag === "WebhookNotDeleted",
38 schedule: Schedule.max([
39 Schedule.exponential("500 millis"),
40 Schedule.recurs(10),
41 ]),
42 }),
43 );
44
45// The Stream webhook is an account-level singleton, so the whole
46// lifecycle lives in one sequential test to avoid the tests fighting
47// over the single slot.
48test.provider(
49 "configure, update, and delete the account webhook",
50 (stack) =>
51 Effect.gen(function* () {
52 const { accountId } = yield* yield* CloudflareEnvironment;
53
54 yield* stack.destroy();
55
56 const webhook = yield* stack.deploy(
57 Cloudflare.Stream.Webhook("Notifications", {
58 notificationUrl: "https://example.com/hooks/stream",
59 }),
60 );
61
62 expect(webhook.accountId).toEqual(accountId);
63 expect(webhook.notificationUrl).toEqual(
64 "https://example.com/hooks/stream",
65 );
66 expect(Redacted.value(webhook.secret)).toBeTruthy();
67
68 const live = yield* getWebhook(accountId);
69 expect(live.notificationUrl).toEqual("https://example.com/hooks/stream");
70
71 // Update the URL in place — PUT is a true upsert.
72 const updated = yield* stack.deploy(
73 Cloudflare.Stream.Webhook("Notifications", {
74 notificationUrl: "https://example.com/hooks/stream-v2",
75 }),
76 );
77
78 expect(updated.notificationUrl).toEqual(
79 "https://example.com/hooks/stream-v2",
80 );
81
82 const observed = yield* getWebhook(accountId);
83 expect(observed.notificationUrl).toEqual(
84 "https://example.com/hooks/stream-v2",
85 );
86
87 // Redeploying identical props is a no-op.
88 const noop = yield* stack.deploy(
89 Cloudflare.Stream.Webhook("Notifications", {
90 notificationUrl: "https://example.com/hooks/stream-v2",
91 }),
92 );
93 expect(noop.notificationUrl).toEqual(
94 "https://example.com/hooks/stream-v2",
95 );
96
97 yield* stack.destroy();
98
99 yield* expectGone(accountId);
100 }).pipe(logLevel),
101 {
102 tags: ["provider:cloudflare", "provider:cloudflare:stream", "live"],
103 timeout: 120_000,
104 },
105);
106
107// Canonical `list()` test (account-level singleton): the account has at most
108// one Stream webhook, so `list()` reads that single slot and returns a
109// one-element array when configured or `[]` when unset — exactly mirroring
110// `read`. This is read-only and runs without the Stream subscription
111// entitlement (mutating the webhook is covered by the gated lifecycle test
112// above, which fails with the typed Cloudflare `StreamSubscriptionRequired`
113// error code 10010 on un-entitled accounts).
114test.provider(
115 "list enumerates the account Stream webhook",
116 (stack) =>
117 Effect.gen(function* () {
118 const { accountId } = yield* yield* CloudflareEnvironment;
119
120 yield* stack.destroy();
121
122 const provider = yield* Provider.findProvider(Cloudflare.Stream.Webhook);
123 const all = yield* provider.list();
124
125 // Account singleton: zero or one webhook, each well-typed Attributes
126 // for the ambient account.
127 expect(Array.isArray(all)).toBe(true);
128 expect(all.length).toBeLessThanOrEqual(1);
129 for (const webhook of all) {
130 expect(webhook.accountId).toEqual(accountId);
131 expect(typeof webhook.notificationUrl).toEqual("string");
132 expect(Redacted.isRedacted(webhook.secret)).toBe(true);
133 }
134
135 yield* stack.destroy();
136 }).pipe(logLevel),
137 {
138 tags: ["provider:cloudflare", "provider:cloudflare:stream", "live"],
139 timeout: 120_000,
140 },
141);