1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as firestore from "@distilled.cloud/gcp/firestore_v1";
5import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
6import { describe, expect } from "bun:test";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import { spawnSync } from "node:child_process";
14import Stack from "../alchemy.run.ts";
15
16const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
17 providers: GCP.providers(),
18 state: Alchemy.localState(),
19});
20
21const { getWhenReady } = Test;
22
23// Out-of-band calls to the Google APIs resolve the same stored credentials
24// the deploy uses, so the test runs against the configured profile.
25const GcpHttp = Layer.mergeAll(
26 GCP.GcpAuth,
27 GCP.fromAuthProvider(),
28 FetchHttpClient.layer,
29);
30
31// The service is built from `main`, which needs a local image build.
32const dockerAvailable =
33 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status ===
34 0;
35
36const API_KEY = "integ-test-key";
37
38// Deploy, tests and destroy all sit behind the same Docker guard.
39describe.skipIf(!dockerAvailable)("gcp-cloud-run-api", () => {
40 const stack = beforeAll(
41 Effect.gen(function* () {
42 const outputs = yield* deploy(Stack);
43 // The secret is created empty on purpose — the value is an operator
44 // step. Add the version the service will read.
45 yield* secretmanager
46 .addVersionProjectsSecrets({
47 parent: outputs.secretName,
48 body: { payload: { data: btoa(API_KEY) } },
49 })
50 .pipe(Effect.orDie, Effect.provide(GcpHttp));
51 return outputs;
52 }),
53 { timeout: 900_000 },
54 );
55
56 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
57 timeout: 600_000,
58 });
59
60 const baseUrlOf = (url: string | undefined) => {
61 if (url === undefined) throw new Error("the service has no URL");
62 return url.replace(/\/+$/, "");
63 };
64
65 const createLink = (baseUrl: string, target: string, key = API_KEY) =>
66 HttpClient.execute(
67 HttpClientRequest.post(`${baseUrl}/links`).pipe(
68 HttpClientRequest.setHeader("x-api-key", key),
69 HttpClientRequest.bodyJsonUnsafe({ url: target }),
70 ),
71 );
72
73 test(
74 "serves the health route on a public Cloud Run URL",
75 Effect.gen(function* () {
76 const { url } = yield* stack;
77 expect(url).toMatch(/^https:\/\//);
78 const res = yield* getWhenReady(`${baseUrlOf(url)}/`);
79 expect(res.status).toBe(200);
80 }),
81 { timeout: 120_000 },
82 );
83
84 test(
85 "rejects a create without the Secret Manager key",
86 Effect.gen(function* () {
87 const { url } = yield* stack;
88 const baseUrl = baseUrlOf(url);
89 yield* getWhenReady(`${baseUrl}/`);
90
91 const missing = yield* HttpClient.execute(
92 HttpClientRequest.post(`${baseUrl}/links`).pipe(
93 HttpClientRequest.bodyJsonUnsafe({ url: "https://example.com" }),
94 ),
95 );
96 expect(missing.status).toBe(401);
97
98 const wrong = yield* createLink(baseUrl, "https://example.com", "nope");
99 expect(wrong.status).toBe(401);
100 }),
101 { timeout: 120_000 },
102 );
103
104 test(
105 "mints a code, redirects, counts the click, and deletes",
106 Effect.gen(function* () {
107 const { url, databaseName } = yield* stack;
108 const baseUrl = baseUrlOf(url);
109 yield* getWhenReady(`${baseUrl}/`);
110
111 const target = "https://alchemy.run/gcp";
112 // A fresh deploy's Firestore grant (a project binding under an IAM
113 // Condition) can take several minutes to propagate; until then the
114 // service answers 500 (Forbidden from Firestore).
115 const created = yield* createLink(baseUrl, target).pipe(
116 Effect.repeat({
117 schedule: Schedule.spaced("10 seconds"),
118 until: (response) => response.status !== 500,
119 times: 42,
120 }),
121 );
122 expect(created.status).toBe(201);
123 const { code, shortUrl } = (yield* created.json) as {
124 code: string;
125 shortUrl: string;
126 };
127 expect(code).toMatch(/^[0-9A-Za-z]{7}$/);
128 expect(shortUrl).toEqual(`${baseUrl}/l/${code}`);
129
130 // The row the service wrote is a real Firestore document.
131 const document = yield* firestore
132 .getProjectsDatabasesDocuments({
133 name: `${databaseName}/documents/links/${code}`,
134 })
135 .pipe(Effect.orDie, Effect.provide(GcpHttp));
136 expect(document.fields?.url?.stringValue).toEqual(target);
137
138 const before = yield* HttpClient.execute(
139 HttpClientRequest.get(`${baseUrl}/links/${code}`),
140 );
141 expect(before.status).toBe(200);
142 expect((yield* before.json) as { clicks: number }).toMatchObject({
143 url: target,
144 clicks: 0,
145 });
146
147 // Inspect the redirect itself instead of following it to the target.
148 const redirect = yield* HttpClient.execute(
149 HttpClientRequest.get(`${baseUrl}/l/${code}`),
150 ).pipe(
151 Effect.provideService(FetchHttpClient.RequestInit, {
152 redirect: "manual",
153 }),
154 );
155 expect(redirect.status).toBe(302);
156 expect(redirect.headers.location).toEqual(target);
157
158 const after = yield* HttpClient.execute(
159 HttpClientRequest.get(`${baseUrl}/links/${code}`),
160 );
161 expect((yield* after.json) as { clicks: number }).toMatchObject({
162 clicks: 1,
163 });
164
165 const deleted = yield* HttpClient.execute(
166 HttpClientRequest.delete(`${baseUrl}/links/${code}`).pipe(
167 HttpClientRequest.setHeader("x-api-key", API_KEY),
168 ),
169 );
170 expect(deleted.status).toBe(204);
171
172 const gone = yield* HttpClient.execute(
173 HttpClientRequest.get(`${baseUrl}/links/${code}`),
174 );
175 expect(gone.status).toBe(404);
176 }),
177 { timeout: 180_000 },
178 );
179});