1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
5import * as firestore from "@distilled.cloud/gcp/firestore_v1";
6import { 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 Stack from "../alchemy.run.ts";
14
15const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
16 providers: GCP.providers(),
17 state: Alchemy.localState(),
18});
19
20// Out-of-band calls to the Google APIs resolve the same stored credentials
21// the deploy uses, so the test runs against the configured profile.
22const GcpHttp = Layer.mergeAll(
23 GCP.GcpAuth,
24 GCP.fromAuthProvider(),
25 FetchHttpClient.layer,
26);
27
28interface Note {
29 id: string;
30 title: string;
31 body: string;
32 createdAt: string;
33}
34
35const stack = beforeAll(deploy(Stack), { timeout: 900_000 });
36
37afterAll.skipIf(!!process.env.NO_DESTROY)(
38 Effect.gen(function* () {
39 const { functionName, databaseName } = yield* stack;
40 yield* destroy(Stack);
41
42 const fn = yield* cloudfunctions
43 .getProjectsLocationsFunctions({ name: functionName })
44 .pipe(
45 Effect.as("found" as const),
46 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
47 Effect.orDie,
48 Effect.provide(GcpHttp),
49 );
50 expect(fn).toEqual("gone");
51
52 // Firestore soft-deletes: the database either 404s or reports a
53 // `deleteTime` until it is purged.
54 const db = yield* firestore
55 .getProjectsDatabases({ name: databaseName })
56 .pipe(
57 Effect.map((database) =>
58 database.deleteTime === undefined ? "found" : "gone",
59 ),
60 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
61 Effect.orDie,
62 Effect.provide(GcpHttp),
63 );
64 expect(db).toEqual("gone");
65 }),
66 { timeout: 600_000 },
67);
68
69const baseUrlOf = (url: string | undefined) => {
70 if (url === undefined) throw new Error("the function has no URL");
71 return url.replace(/\/+$/, "");
72};
73
74test(
75 "creates, reads, lists, and deletes notes over public HTTP",
76 Effect.gen(function* () {
77 const { url, databaseName } = yield* stack;
78 expect(url).toMatch(/^https:\/\//);
79 const baseUrl = baseUrlOf(url);
80
81 // Right after a deploy the public invoker grant (403) and the
82 // project-level Firestore grant (500) can each take minutes to
83 // propagate. Poll the list route until both have landed.
84 const ready = yield* HttpClient.execute(
85 HttpClientRequest.get(`${baseUrl}/notes`),
86 ).pipe(
87 Effect.repeat({
88 schedule: Schedule.spaced("10 seconds"),
89 until: (response) => response.status === 200,
90 times: 48,
91 }),
92 );
93 expect(ready.status).toBe(200);
94
95 const unknown = yield* HttpClient.execute(
96 HttpClientRequest.get(`${baseUrl}/notes/does-not-exist`),
97 );
98 expect(unknown.status).toBe(404);
99
100 const created = yield* HttpClient.execute(
101 HttpClientRequest.post(`${baseUrl}/notes`).pipe(
102 HttpClientRequest.bodyJsonUnsafe({
103 title: "groceries",
104 body: "eggs, milk",
105 }),
106 ),
107 );
108 expect(created.status).toBe(201);
109 const note = (yield* created.json) as unknown as Note;
110 expect(note).toMatchObject({ title: "groceries", body: "eggs, milk" });
111 expect(note.id).toMatch(/^[0-9a-f-]{36}$/);
112
113 // The row the function wrote is a real Firestore document.
114 const document = yield* firestore
115 .getProjectsDatabasesDocuments({
116 name: `${databaseName}/documents/notes/${note.id}`,
117 })
118 .pipe(Effect.orDie, Effect.provide(GcpHttp));
119 expect(document.fields?.title?.stringValue).toEqual("groceries");
120 expect(document.fields?.body?.stringValue).toEqual("eggs, milk");
121 expect(document.fields?.createdAt?.timestampValue).toEqual(
122 expect.any(String),
123 );
124
125 const read = yield* HttpClient.execute(
126 HttpClientRequest.get(`${baseUrl}/notes/${note.id}`),
127 );
128 expect(read.status).toBe(200);
129 expect((yield* read.json) as unknown as Note).toEqual(note);
130
131 const listed = yield* HttpClient.execute(
132 HttpClientRequest.get(`${baseUrl}/notes`),
133 );
134 expect(listed.status).toBe(200);
135 const { notes } = (yield* listed.json) as unknown as { notes: Note[] };
136 expect(notes.map((n) => n.id)).toContain(note.id);
137
138 const invalid = yield* HttpClient.execute(
139 HttpClientRequest.post(`${baseUrl}/notes`).pipe(
140 HttpClientRequest.bodyJsonUnsafe({ body: "no title" }),
141 ),
142 );
143 expect(invalid.status).toBe(400);
144
145 const deleted = yield* HttpClient.execute(
146 HttpClientRequest.delete(`${baseUrl}/notes/${note.id}`),
147 );
148 expect(deleted.status).toBe(204);
149
150 const gone = yield* HttpClient.execute(
151 HttpClientRequest.get(`${baseUrl}/notes/${note.id}`),
152 );
153 expect(gone.status).toBe(404);
154
155 const documentGone = yield* firestore
156 .getProjectsDatabasesDocuments({
157 name: `${databaseName}/documents/notes/${note.id}`,
158 })
159 .pipe(
160 Effect.as("found" as const),
161 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
162 Effect.orDie,
163 Effect.provide(GcpHttp),
164 );
165 expect(documentGone).toEqual("gone");
166 }),
167 { timeout: 600_000 },
168);