1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as calls from "@distilled.cloud/cloudflare/calls";
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// The scoped API token the test harness mints propagates eventually-
20// consistently across Cloudflare's edge — ride out 403 blips
21// (`Forbidden`, declared in the distilled error union) on the test's
22// own out-of-band verification calls.
23const getApp = (accountId: string, appId: string) =>
24 calls.getSfu({ accountId, appId }).pipe(
25 Effect.retry({
26 while: (e) => e._tag === "Forbidden",
27 schedule: Schedule.exponential("500 millis"),
28 times: 8,
29 }),
30 );
31
32const expectGone = (accountId: string, appId: string) =>
33 getApp(accountId, appId).pipe(
34 Effect.flatMap(() => Effect.fail({ _tag: "AppNotDeleted" } as const)),
35 // A missing app surfaces as `CallsAppNotFound` (Cloudflare error
36 // code 20007) — that's the success condition here.
37 Effect.catchTag("CallsAppNotFound", () => Effect.void),
38 Effect.retry({
39 while: (e) => e._tag === "AppNotDeleted",
40 schedule: Schedule.max([
41 Schedule.exponential("500 millis"),
42 Schedule.recurs(10),
43 ]),
44 }),
45 );
46
47test.provider(
48 "create and delete an app with default name",
49 (stack) =>
50 Effect.gen(function* () {
51 const { accountId } = yield* yield* CloudflareEnvironment;
52
53 yield* stack.destroy();
54
55 const app = yield* stack.deploy(Cloudflare.Calls.App("DefaultApp", {}));
56
57 expect(app.appId).toBeTruthy();
58 expect(Redacted.value(app.secret)).toBeTruthy();
59 expect(app.accountId).toEqual(accountId);
60 expect(app.name).toBeTruthy();
61
62 const live = yield* getApp(accountId, app.appId);
63 expect(live.uid).toEqual(app.appId);
64 expect(live.name).toEqual(app.name);
65
66 yield* stack.destroy();
67
68 yield* expectGone(accountId, app.appId);
69 }).pipe(logLevel),
70 { tags: ["provider:cloudflare", "provider:cloudflare:calls", "live"] },
71);
72
73test.provider(
74 "update name in place (same appId, secret preserved)",
75 (stack) =>
76 Effect.gen(function* () {
77 const { accountId } = yield* yield* CloudflareEnvironment;
78
79 yield* stack.destroy();
80
81 const initial = yield* stack.deploy(
82 Cloudflare.Calls.App("UpdateApp", {
83 name: "alchemy-calls-app-update",
84 }),
85 );
86
87 expect(initial.name).toEqual("alchemy-calls-app-update");
88 const initialSecret = Redacted.value(initial.secret);
89 expect(initialSecret).toBeTruthy();
90
91 const updated = yield* stack.deploy(
92 Cloudflare.Calls.App("UpdateApp", {
93 name: "alchemy-calls-app-update-v2",
94 }),
95 );
96
97 // Same app mutated in place — not a replacement — and the
98 // create-only secret is carried forward across the update.
99 expect(updated.appId).toEqual(initial.appId);
100 expect(updated.name).toEqual("alchemy-calls-app-update-v2");
101 expect(Redacted.value(updated.secret)).toEqual(initialSecret);
102
103 const live = yield* getApp(accountId, updated.appId);
104 expect(live.name).toEqual("alchemy-calls-app-update-v2");
105
106 // Redeploying identical props is a no-op (still the same app).
107 const noop = yield* stack.deploy(
108 Cloudflare.Calls.App("UpdateApp", {
109 name: "alchemy-calls-app-update-v2",
110 }),
111 );
112 expect(noop.appId).toEqual(initial.appId);
113 expect(Redacted.value(noop.secret)).toEqual(initialSecret);
114
115 yield* stack.destroy();
116
117 yield* expectGone(accountId, initial.appId);
118 }).pipe(logLevel),
119 { tags: ["provider:cloudflare", "provider:cloudflare:calls", "live"] },
120);
121
122test.provider(
123 "list enumerates the deployed app",
124 (stack) =>
125 Effect.gen(function* () {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127
128 yield* stack.destroy();
129
130 const app = yield* stack.deploy(
131 Cloudflare.Calls.App("ListApp", {
132 name: "alchemy-calls-app-list",
133 }),
134 );
135
136 const provider = yield* Provider.findProvider(Cloudflare.Calls.App);
137 const all = yield* provider.list();
138
139 // The account-scoped enumeration must contain the just-deployed app.
140 const found = all.find((a) => a.appId === app.appId);
141 expect(found).toBeDefined();
142 expect(found?.accountId).toEqual(accountId);
143 expect(found?.name).toEqual("alchemy-calls-app-list");
144
145 yield* stack.destroy();
146
147 yield* expectGone(accountId, app.appId);
148 }).pipe(logLevel),
149 { tags: ["provider:cloudflare", "provider:cloudflare:calls", "live"] },
150);
151
152test.provider(
153 "recreates after out-of-band delete",
154 (stack) =>
155 Effect.gen(function* () {
156 const { accountId } = yield* yield* CloudflareEnvironment;
157
158 yield* stack.destroy();
159
160 const app = yield* stack.deploy(
161 Cloudflare.Calls.App("HealApp", {
162 name: "alchemy-calls-app-heal",
163 }),
164 );
165
166 // Delete the app out-of-band. A redeploy with identical props is a
167 // planner no-op, so change a prop to force reconcile — it must observe
168 // the app as missing and recreate it instead of failing on the 20007.
169 yield* calls.deleteSfu({ accountId, appId: app.appId }).pipe(
170 Effect.retry({
171 while: (e) => e._tag === "Forbidden",
172 schedule: Schedule.exponential("500 millis"),
173 times: 8,
174 }),
175 );
176
177 const healed = yield* stack.deploy(
178 Cloudflare.Calls.App("HealApp", {
179 name: "alchemy-calls-app-heal-v2",
180 }),
181 );
182
183 expect(healed.appId).not.toEqual(app.appId);
184 expect(Redacted.value(healed.secret)).toBeTruthy();
185 expect(Redacted.value(healed.secret)).not.toEqual(
186 Redacted.value(app.secret),
187 );
188 const live = yield* getApp(accountId, healed.appId);
189 expect(live.name).toEqual("alchemy-calls-app-heal-v2");
190
191 yield* stack.destroy();
192
193 yield* expectGone(accountId, healed.appId);
194 }).pipe(logLevel),
195 { tags: ["provider:cloudflare", "provider:cloudflare:calls", "live"] },
196);