1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as storage from "@distilled.cloud/gcp/storage_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const getObject = (bucket: string, object: string) =>
19 storage
20 .getObjects({ bucket, object })
21 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
22
23const waitUntilObjectGone = (bucket: string, object: string) =>
24 getObject(bucket, object).pipe(
25 Effect.repeat({
26 schedule: Schedule.spaced("1 second"),
27 until: (found) => found === undefined,
28 times: 10,
29 }),
30 );
31
32const waitUntilBucketGone = (bucketName: string) =>
33 storage.getBuckets({ bucket: bucketName }).pipe(
34 Effect.as("found" as const),
35 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
36 Effect.repeat({
37 schedule: Schedule.spaced("1 second"),
38 until: (status) => status === "gone",
39 times: 10,
40 }),
41 );
42
43test.provider(
44 "inline content: create, update, move key, delete",
45 (stack) =>
46 Effect.gen(function* () {
47 yield* stack.destroy();
48
49 const program = (key: string, content: string, cacheControl?: string) =>
50 Effect.gen(function* () {
51 const bucket = yield* GCP.Storage.Bucket("Objects", {
52 forceDestroy: true,
53 });
54 const object = yield* GCP.Storage.Object("Page", {
55 bucketName: bucket.bucketName,
56 key,
57 content,
58 cacheControl,
59 });
60 return { bucket, object };
61 });
62
63 const created = yield* stack.deploy(program("index.html", "<h1>v1</h1>"));
64 const bucketName = created.bucket.bucketName;
65 expect(created.object.key).toEqual("index.html");
66 expect(created.object.contentType).toEqual("text/html; charset=utf-8");
67 expect(created.object.url).toEqual(
68 `https://storage.googleapis.com/${bucketName}/index.html`,
69 );
70
71 const live = yield* getObject(bucketName, "index.html");
72 expect(live?.contentType).toEqual("text/html; charset=utf-8");
73 expect(live?.size).toEqual(String("<h1>v1</h1>".length));
74 expect(live?.metadata?.["alchemy-id"]).toEqual("page");
75
76 // Redeploying identical content leaves the live generation alone.
77 const same = yield* stack.deploy(program("index.html", "<h1>v1</h1>"));
78 expect(same.object.generation).toEqual(created.object.generation);
79
80 // New content and headers converge in one upload.
81 const updated = yield* stack.deploy(
82 program("index.html", "<h1>v2</h1>", "no-cache"),
83 );
84 expect(updated.object.generation).not.toEqual(created.object.generation);
85 const liveUpdated = yield* getObject(bucketName, "index.html");
86 expect(liveUpdated?.cacheControl).toEqual("no-cache");
87 expect(liveUpdated?.md5Hash).toEqual(updated.object.md5Hash);
88
89 // Moving the key replaces the object; the old key is deleted.
90 const moved = yield* stack.deploy(
91 program("home.html", "<h1>v2</h1>", "no-cache"),
92 );
93 expect(moved.object.key).toEqual("home.html");
94 expect(yield* getObject(bucketName, "home.html")).toBeDefined();
95 yield* waitUntilObjectGone(bucketName, "index.html");
96
97 yield* stack.destroy();
98 yield* waitUntilObjectGone(bucketName, "home.html");
99 yield* waitUntilBucketGone(bucketName);
100 }).pipe(logLevel),
101 { tags: ["provider:gcp", "provider:gcp:storage", "live"], timeout: 180_000 },
102);
103
104test.provider(
105 "Files uploads a directory and re-uploads edited files",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const fs = yield* FileSystem.FileSystem;
111 const path = yield* Path.Path;
112 const dir = yield* fs.makeTempDirectoryScoped({
113 prefix: "alchemy-gcs-files-",
114 });
115 yield* fs.makeDirectory(path.join(dir, "assets"), { recursive: true });
116 yield* fs.writeFileString(path.join(dir, "index.html"), "<h1>one</h1>");
117 yield* fs.writeFileString(
118 path.join(dir, "assets", "app.css"),
119 "body{color:red}",
120 );
121 yield* fs.writeFile(
122 path.join(dir, "assets", "logo.png"),
123 new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
124 );
125
126 const program = Effect.gen(function* () {
127 const bucket = yield* GCP.Storage.Bucket("Site", {
128 forceDestroy: true,
129 });
130 const objects = yield* GCP.Storage.Files("SiteFiles", {
131 bucketName: bucket.bucketName,
132 path: dir,
133 prefix: "site",
134 cacheControl: (key) =>
135 key.endsWith(".html") ? "no-cache" : undefined,
136 });
137 return { bucket, objects };
138 });
139
140 const first = yield* stack.deploy(program);
141 const bucketName = first.bucket.bucketName;
142 const byKey = Object.fromEntries(
143 first.objects.map((object) => [object.key, object]),
144 );
145 expect(Object.keys(byKey).sort()).toEqual([
146 "site/assets/app.css",
147 "site/assets/logo.png",
148 "site/index.html",
149 ]);
150
151 const html = yield* getObject(bucketName, "site/index.html");
152 expect(html?.contentType).toEqual("text/html; charset=utf-8");
153 expect(html?.cacheControl).toEqual("no-cache");
154 const css = yield* getObject(bucketName, "site/assets/app.css");
155 expect(css?.contentType).toEqual("text/css; charset=utf-8");
156 const png = yield* getObject(bucketName, "site/assets/logo.png");
157 expect(png?.contentType).toEqual("image/png");
158
159 // Editing a file without touching any prop re-uploads only that file.
160 yield* fs.writeFileString(path.join(dir, "index.html"), "<h1>two</h1>");
161 const second = yield* stack.deploy(program);
162 const secondByKey = Object.fromEntries(
163 second.objects.map((object) => [object.key, object]),
164 );
165 expect(secondByKey["site/index.html"]!.generation).not.toEqual(
166 byKey["site/index.html"]!.generation,
167 );
168 expect(secondByKey["site/assets/app.css"]!.generation).toEqual(
169 byKey["site/assets/app.css"]!.generation,
170 );
171
172 // Removing a file deletes its object on the next deploy.
173 yield* fs.remove(path.join(dir, "assets", "logo.png"));
174 yield* stack.deploy(program);
175 yield* waitUntilObjectGone(bucketName, "site/assets/logo.png");
176
177 yield* stack.destroy();
178 yield* waitUntilBucketGone(bucketName);
179 }).pipe(Effect.scoped, logLevel),
180 { tags: ["provider:gcp", "provider:gcp:storage", "live"], timeout: 180_000 },
181);
182
183test.provider(
184 "bucket website config: set, change, remove",
185 (stack) =>
186 Effect.gen(function* () {
187 yield* stack.destroy();
188
189 const program = (website?: GCP.Storage.BucketWebsite) =>
190 GCP.Storage.Bucket("Web", { forceDestroy: true, website });
191
192 const created = yield* stack.deploy(
193 program({ mainPageSuffix: "index.html", notFoundPage: "404.html" }),
194 );
195 expect(created.website).toEqual({
196 mainPageSuffix: "index.html",
197 notFoundPage: "404.html",
198 });
199
200 const changed = yield* stack.deploy(
201 program({ mainPageSuffix: "home.html" }),
202 );
203 const live = yield* storage.getBuckets({ bucket: changed.bucketName });
204 expect(live.website?.mainPageSuffix).toEqual("home.html");
205 expect(live.website?.notFoundPage).toBeUndefined();
206
207 const removed = yield* stack.deploy(program(undefined));
208 expect(removed.website).toBeUndefined();
209 const liveRemoved = yield* storage.getBuckets({
210 bucket: removed.bucketName,
211 });
212 expect(liveRemoved.website?.mainPageSuffix).toBeUndefined();
213
214 yield* stack.destroy();
215 yield* waitUntilBucketGone(created.bucketName);
216 }).pipe(logLevel),
217 { tags: ["provider:gcp", "provider:gcp:storage", "live"], timeout: 180_000 },
218);