1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as flagship from "@distilled.cloud/cloudflare/flagship";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
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
19class AppStillExists extends Data.TaggedError("AppStillExists") {}
20
21class AppNotListedYet extends Data.TaggedError("AppNotListedYet") {}
22
23// A deleted app surfaces as `FlagshipAppNotFound` — that's the success
24// condition here.
25const expectAppGone = (accountId: string, appId: string) =>
26 flagship.getApp({ accountId, appId }).pipe(
27 Effect.flatMap(() => Effect.fail(new AppStillExists())),
28 Effect.retry({
29 while: (e): e is AppStillExists => e instanceof AppStillExists,
30 schedule: Schedule.max([
31 Schedule.exponential("250 millis"),
32 Schedule.recurs(10),
33 ]),
34 }),
35 Effect.catchTag("FlagshipAppNotFound", () => Effect.void),
36 );
37
38test.provider(
39 "create, update, delete a Flagship app",
40 (stack) =>
41 Effect.gen(function* () {
42 const { accountId } = yield* yield* CloudflareEnvironment;
43
44 yield* stack.destroy();
45
46 const initial = yield* stack.deploy(
47 Effect.gen(function* () {
48 const app = yield* Cloudflare.Flagship.App("App", {
49 name: "alchemy-test-flagship-app",
50 });
51 return { app };
52 }),
53 );
54
55 expect(initial.app.appId).toBeDefined();
56 expect(initial.app.accountId).toEqual(accountId);
57 expect(initial.app.name).toEqual("alchemy-test-flagship-app");
58
59 // Verify out-of-band via the API.
60 const live = yield* flagship.getApp({
61 accountId,
62 appId: initial.app.appId,
63 });
64 expect(live.name).toEqual("alchemy-test-flagship-app");
65
66 // Rename in place — same app id.
67 const renamed = yield* stack.deploy(
68 Effect.gen(function* () {
69 const app = yield* Cloudflare.Flagship.App("App", {
70 name: "alchemy-test-flagship-app-v2",
71 });
72 return { app };
73 }),
74 );
75 expect(renamed.app.appId).toEqual(initial.app.appId);
76 expect(renamed.app.name).toEqual("alchemy-test-flagship-app-v2");
77
78 const liveRenamed = yield* flagship.getApp({
79 accountId,
80 appId: initial.app.appId,
81 });
82 expect(liveRenamed.name).toEqual("alchemy-test-flagship-app-v2");
83
84 // Redeploying identical props is a no-op (still the same app).
85 const noop = yield* stack.deploy(
86 Effect.gen(function* () {
87 const app = yield* Cloudflare.Flagship.App("App", {
88 name: "alchemy-test-flagship-app-v2",
89 });
90 return { app };
91 }),
92 );
93 expect(noop.app.appId).toEqual(initial.app.appId);
94
95 yield* stack.destroy();
96
97 yield* expectAppGone(accountId, initial.app.appId);
98 }).pipe(logLevel),
99 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
100);
101
102test.provider(
103 "recreates an app after out-of-band delete",
104 (stack) =>
105 Effect.gen(function* () {
106 const { accountId } = yield* yield* CloudflareEnvironment;
107
108 yield* stack.destroy();
109
110 const initial = yield* stack.deploy(
111 Effect.gen(function* () {
112 const app = yield* Cloudflare.Flagship.App("HealApp", {
113 name: "alchemy-test-flagship-heal",
114 });
115 return { app };
116 }),
117 );
118
119 // Delete the app out-of-band. A redeploy with identical props is a
120 // planner no-op, so change a prop to force reconcile — it must observe
121 // the app as missing and recreate it instead of failing on a 404.
122 yield* flagship.deleteApp({ accountId, appId: initial.app.appId });
123
124 const healed = yield* stack.deploy(
125 Effect.gen(function* () {
126 const app = yield* Cloudflare.Flagship.App("HealApp", {
127 name: "alchemy-test-flagship-heal-v2",
128 });
129 return { app };
130 }),
131 );
132
133 expect(healed.app.appId).not.toEqual(initial.app.appId);
134 expect(healed.app.name).toEqual("alchemy-test-flagship-heal-v2");
135
136 const live = yield* flagship.getApp({
137 accountId,
138 appId: healed.app.appId,
139 });
140 expect(live.name).toEqual("alchemy-test-flagship-heal-v2");
141
142 yield* stack.destroy();
143
144 yield* expectAppGone(accountId, healed.app.appId);
145 }).pipe(logLevel),
146 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
147);
148
149test.provider(
150 "list enumerates the deployed Flagship app",
151 (stack) =>
152 Effect.gen(function* () {
153 yield* stack.destroy();
154
155 const deployed = yield* stack.deploy(
156 Effect.gen(function* () {
157 const app = yield* Cloudflare.Flagship.App("ListApp", {
158 name: "alchemy-test-flagship-list",
159 });
160 return { app };
161 }),
162 );
163
164 const provider = yield* Provider.findProvider(Cloudflare.Flagship.App);
165
166 // The flagship apps list endpoint is eventually consistent: a freshly
167 // created app is strongly readable by id via getApp but takes a few
168 // seconds to surface in the account-wide list. Retry until it appears.
169 const all = yield* provider.list().pipe(
170 Effect.flatMap((apps) =>
171 apps.some((a) => a.appId === deployed.app.appId)
172 ? Effect.succeed(apps)
173 : Effect.fail(new AppNotListedYet()),
174 ),
175 Effect.retry({
176 while: (e): e is AppNotListedYet => e instanceof AppNotListedYet,
177 schedule: Schedule.max([
178 Schedule.exponential("500 millis"),
179 Schedule.recurs(8),
180 ]),
181 }),
182 );
183
184 expect(all.some((a) => a.appId === deployed.app.appId)).toBe(true);
185 const found = all.find((a) => a.appId === deployed.app.appId);
186 expect(found?.name).toEqual("alchemy-test-flagship-list");
187 expect(found?.accountId).toEqual(deployed.app.accountId);
188
189 yield* stack.destroy();
190 }).pipe(logLevel),
191 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
192);