1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Test from "@/Test/Alchemy";
4import * as r2 from "@distilled.cloud/cloudflare/r2";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({
12 providers: Cloudflare.providers(),
13 state: Cloudflare.state(),
14});
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const zoneName =
22 process.env.CLOUDFLARE_TEST_R2_DOMAIN_ZONE_NAME ?? "alchemy-test-2.us";
23const suffix = process.env.PULL_REQUEST ?? process.env.USER;
24// A custom-domain hostname maps one-to-one to a bucket at the Cloudflare zone
25// level, so it is a *global* resource. These suites run concurrently
26// (`sequence.concurrent`), so every test must claim a hostname no other test
27// uses — otherwise the tests race to attach the same hostname to different
28// buckets and lose with `Conflict: Domain already in use`.
29const domain = zoneName
30 ? `alchemy-r2-test-single-${suffix}.${zoneName}`
31 : undefined;
32const domain2 = zoneName
33 ? `alchemy-r2-test-multi-a-${suffix}.${zoneName}`
34 : undefined;
35const domain3 = zoneName
36 ? `alchemy-r2-test-multi-b-${suffix}.${zoneName}`
37 : undefined;
38
39test.provider(
40 "creates, updates, and deletes a bucket custom domain",
41 (stack) =>
42 Effect.gen(function* () {
43 const { accountId } = yield* yield* CloudflareEnvironment;
44
45 yield* stack.destroy();
46
47 const bucket = yield* stack.deploy(
48 Effect.gen(function* () {
49 return yield* Cloudflare.R2.Bucket("DomainBucket", {
50 forceDestroy: true,
51 domains: [{ name: domain! }],
52 });
53 }),
54 );
55
56 expect(bucket.domains).toHaveLength(1);
57 expect(bucket.domains[0]?.domain).toEqual(domain);
58 expect(bucket.domains[0]?.enabled).toEqual(true);
59
60 const actual = yield* r2.getBucketDomainCustom({
61 accountId,
62 bucketName: bucket.bucketName,
63 domain: domain!,
64 jurisdiction: bucket.jurisdiction,
65 });
66 expect(actual.domain).toEqual(domain);
67
68 const updated = yield* stack.deploy(
69 Effect.gen(function* () {
70 return yield* Cloudflare.R2.Bucket("DomainBucket", {
71 forceDestroy: true,
72 domains: [{ name: domain!, enabled: false }],
73 });
74 }),
75 );
76
77 expect(updated.domains[0]?.enabled).toEqual(false);
78
79 yield* stack.destroy();
80
81 const deleted = yield* r2
82 .getBucketDomainCustom({
83 accountId,
84 bucketName: bucket.bucketName,
85 domain: domain!,
86 jurisdiction: bucket.jurisdiction,
87 })
88 .pipe(
89 Effect.map(() => false),
90 Effect.catchTag("DomainNotFound", () => Effect.succeed(true)),
91 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
92 );
93 expect(deleted).toEqual(true);
94
95 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
96 }).pipe(logLevel),
97 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
98);
99
100test.provider(
101 "creates, updates, and deletes a bucket with multiple custom domains",
102 (stack) =>
103 Effect.gen(function* () {
104 const { accountId } = yield* yield* CloudflareEnvironment;
105
106 yield* stack.destroy();
107
108 const bucket = yield* stack.deploy(
109 Effect.gen(function* () {
110 return yield* Cloudflare.R2.Bucket("MultiDomainBucket", {
111 forceDestroy: true,
112 domains: [{ name: domain2! }, { name: domain3! }],
113 });
114 }),
115 );
116
117 expect(bucket.domains).toHaveLength(2);
118 const domainNames = bucket.domains.map((d) => d.domain).sort();
119 expect(domainNames).toEqual([domain2, domain3].sort());
120 expect(bucket.domains.every((d) => d.enabled)).toEqual(true);
121
122 for (const name of [domain2!, domain3!]) {
123 const actual = yield* r2.getBucketDomainCustom({
124 accountId,
125 bucketName: bucket.bucketName,
126 domain: name,
127 jurisdiction: bucket.jurisdiction,
128 });
129 expect(actual.domain).toEqual(name);
130 }
131
132 const updated = yield* stack.deploy(
133 Effect.gen(function* () {
134 return yield* Cloudflare.R2.Bucket("MultiDomainBucket", {
135 forceDestroy: true,
136 domains: [{ name: domain3!, enabled: false }, { name: domain2! }],
137 });
138 }),
139 );
140
141 const updatedByName = Object.fromEntries(
142 updated.domains.map((d) => [d.domain, d]),
143 );
144 expect(updatedByName[domain3!]?.enabled).toEqual(false);
145 expect(updatedByName[domain2!]?.enabled).toEqual(true);
146
147 const removed = yield* stack.deploy(
148 Effect.gen(function* () {
149 return yield* Cloudflare.R2.Bucket("MultiDomainBucket", {
150 forceDestroy: true,
151 domains: [{ name: domain2! }],
152 });
153 }),
154 );
155
156 expect(removed.domains).toHaveLength(1);
157 expect(removed.domains[0]?.domain).toEqual(domain2);
158
159 const firstRemoved = yield* r2
160 .getBucketDomainCustom({
161 accountId,
162 bucketName: bucket.bucketName,
163 domain: domain3!,
164 jurisdiction: bucket.jurisdiction,
165 })
166 .pipe(
167 Effect.map(() => false),
168 Effect.catchTag("DomainNotFound", () => Effect.succeed(true)),
169 );
170 expect(firstRemoved).toEqual(true);
171
172 yield* stack.destroy();
173
174 for (const name of [domain2!, domain3!]) {
175 const deleted = yield* r2
176 .getBucketDomainCustom({
177 accountId,
178 bucketName: bucket.bucketName,
179 domain: name,
180 jurisdiction: bucket.jurisdiction,
181 })
182 .pipe(
183 Effect.map(() => false),
184 Effect.catchTag("DomainNotFound", () => Effect.succeed(true)),
185 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
186 );
187 expect(deleted).toEqual(true);
188 }
189
190 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
191 }).pipe(logLevel),
192 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
193);
194
195const waitForBucketToBeDeleted = Effect.fn(function* (
196 bucketName: string,
197 accountId: string,
198) {
199 yield* r2
200 .getBucket({
201 accountId,
202 bucketName,
203 })
204 .pipe(
205 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
206 Effect.retry({
207 while: (e): e is BucketStillExists => e instanceof BucketStillExists,
208 schedule: Schedule.exponential(100),
209 }),
210 Effect.catchTag("NoSuchBucket", () => Effect.void),
211 );
212});
213
214class BucketStillExists extends Data.TaggedError("BucketStillExists") {}