1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as alerting from "@distilled.cloud/cloudflare/alerting";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as pathe from "pathe";
11import { expectUrlContains } from "../Utils/Http.ts";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const main = pathe.resolve(import.meta.dirname, "fixtures/webhook-receiver.ts");
21
22// Cloudflare fires a test POST at the webhook URL on create/update, so the
23// destination worker must be deployed and serving before the webhook
24// resource is created.
25const Receiver = () =>
26 Cloudflare.Worker("WebhookReceiver", {
27 main,
28 workersDev: true,
29 });
30
31test.provider(
32 "create, update, delete webhook destination",
33 (stack) =>
34 Effect.gen(function* () {
35 const { accountId } = yield* yield* CloudflareEnvironment;
36
37 yield* stack.destroy();
38
39 // Phase 1: deploy the receiving worker alone and wait until the
40 // workers.dev URL actually serves (fresh subdomains take a few
41 // seconds to propagate; the webhook test POST must hit a live 200).
42 const receiver = yield* stack.deploy(Receiver());
43 expect(receiver.url).toBeDefined();
44 yield* expectUrlContains(receiver.url!, "webhook-ok", {
45 label: "webhook receiver",
46 });
47
48 // Phase 2: create the webhook destination pointing at the worker.
49 const webhook = yield* stack.deploy(
50 Effect.gen(function* () {
51 const worker = yield* Receiver();
52 return yield* Cloudflare.Alerting.NotificationWebhook(
53 "AlertWebhook",
54 {
55 url: worker.url.as<string>(),
56 },
57 );
58 }),
59 );
60
61 expect(webhook.webhookId).toBeDefined();
62 expect(webhook.accountId).toEqual(accountId);
63 expect(webhook.name).toBeDefined();
64 expect(webhook.url).toEqual(receiver.url);
65
66 // Verify out-of-band via the API.
67 const actual = yield* alerting.getDestinationWebhook({
68 accountId,
69 webhookId: webhook.webhookId,
70 });
71 expect(actual.name).toEqual(webhook.name);
72 expect(actual.url).toEqual(receiver.url);
73
74 // Phase 3: rename (mutable prop) — same id, new name.
75 const renamed = yield* stack.deploy(
76 Effect.gen(function* () {
77 const worker = yield* Receiver();
78 return yield* Cloudflare.Alerting.NotificationWebhook(
79 "AlertWebhook",
80 {
81 name: "alchemy-test-alerting-webhook-renamed",
82 url: worker.url.as<string>(),
83 },
84 );
85 }),
86 );
87 expect(renamed.webhookId).toEqual(webhook.webhookId);
88 expect(renamed.name).toEqual("alchemy-test-alerting-webhook-renamed");
89
90 const afterRename = yield* alerting.getDestinationWebhook({
91 accountId,
92 webhookId: webhook.webhookId,
93 });
94 expect(afterRename.name).toEqual("alchemy-test-alerting-webhook-renamed");
95
96 yield* stack.destroy();
97
98 yield* waitForWebhookDeleted(accountId, webhook.webhookId);
99 }).pipe(logLevel),
100 {
101 tags: [
102 "provider:cloudflare",
103 "provider:cloudflare:alerting",
104 "provider:cloudflare:worker",
105 "live",
106 ],
107 },
108);
109
110test.provider(
111 "list enumerates the deployed webhook destination",
112 (stack) =>
113 Effect.gen(function* () {
114 yield* stack.destroy();
115
116 // Deploy the receiving worker, wait for it to serve, then create the
117 // webhook destination pointing at it.
118 const receiver = yield* stack.deploy(Receiver());
119 expect(receiver.url).toBeDefined();
120 yield* expectUrlContains(receiver.url!, "webhook-ok", {
121 label: "webhook receiver",
122 });
123
124 const webhook = yield* stack.deploy(
125 Effect.gen(function* () {
126 const worker = yield* Receiver();
127 return yield* Cloudflare.Alerting.NotificationWebhook("ListWebhook", {
128 url: worker.url.as<string>(),
129 });
130 }),
131 );
132
133 const provider = yield* Provider.findProvider(
134 Cloudflare.Alerting.NotificationWebhook,
135 );
136 const all = yield* provider.list();
137
138 const match = all.find((w) => w.webhookId === webhook.webhookId);
139 expect(match).toBeDefined();
140 expect(match!.name).toEqual(webhook.name);
141 expect(match!.url).toEqual(webhook.url);
142
143 yield* stack.destroy();
144
145 yield* waitForWebhookDeleted(webhook.accountId, webhook.webhookId);
146 }).pipe(logLevel),
147 {
148 tags: [
149 "provider:cloudflare",
150 "provider:cloudflare:alerting",
151 "provider:cloudflare:worker",
152 "live",
153 ],
154 },
155);
156
157const waitForWebhookDeleted = (accountId: string, webhookId: string) =>
158 alerting.getDestinationWebhook({ accountId, webhookId }).pipe(
159 Effect.flatMap(() => Effect.fail({ _tag: "WebhookNotDeleted" } as const)),
160 Effect.catchTag("WebhookNotFound", () => Effect.void),
161 Effect.retry({
162 while: (e) => e._tag === "WebhookNotDeleted",
163 schedule: Schedule.max([
164 Schedule.exponential("500 millis"),
165 Schedule.recurs(10),
166 ]),
167 }),
168 );