1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
4import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
5import * as storage from "@distilled.cloud/gcp/storage_v1";
6import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as HttpClient from "effect/http/HttpClient";
13import EffectFunction, { Marker, Pings } from "./fixtures/effect-function.ts";
14
15const { test } = Test.make({ providers: GCP.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22class NotReady extends Data.TaggedError("NotReady")<{ status: number }> {}
23
24test.provider.skipIf(!!process.env.FAST)(
25 "effect-native function serves fetch with bindings and consumes a topic",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 const out = yield* stack.deploy(
31 Effect.gen(function* () {
32 const fn = yield* EffectFunction;
33 const bucket = yield* Marker;
34 const topic = yield* Pings;
35 // Let the test call the function without an identity token.
36 yield* GCP.IAM.Member("PublicInvoker", {
37 kind: "run.service",
38 name: fn.service.as<string>(),
39 role: "roles/run.invoker",
40 member: "allUsers",
41 });
42 return {
43 name: fn.name,
44 url: fn.url,
45 project: fn.project,
46 runtime: fn.runtime,
47 entryPoint: fn.entryPoint,
48 serviceAccount: fn.serviceAccountEmail,
49 managed: fn.managedServiceAccount,
50 bucket: bucket.bucketName,
51 topic: topic.name,
52 };
53 }),
54 );
55
56 expect(out.runtime).toEqual("nodejs22");
57 expect(out.entryPoint).toEqual("handler");
58 expect(out.managed).toEqual(true);
59
60 // fetch runs the Effect program with its Storage bindings.
61 const client = yield* HttpClient.HttpClient;
62 const response = yield* client.get(out.url!).pipe(
63 Effect.flatMap((response) =>
64 response.status === 200
65 ? Effect.succeed(response)
66 : Effect.fail(new NotReady({ status: response.status })),
67 ),
68 Effect.retry({
69 while: (error) => error._tag === "NotReady",
70 schedule: Schedule.spaced("5 seconds"),
71 times: 18,
72 }),
73 );
74 const body = (yield* response.json) as { read: string; node: string };
75 expect(body.read).toEqual("from-function");
76 expect(body.node).toMatch(/^22\./);
77
78 // A published message is pushed to the function and recorded.
79 const published = yield* pubsub.publishProjectsTopics({
80 topic: out.topic,
81 body: { messages: [{ data: btoa("ping!") }] },
82 });
83 const messageId = published.messageIds?.[0];
84 expect(messageId).toEqual(expect.any(String));
85 const marker = yield* storage
86 .getObjects({ bucket: out.bucket, object: `ping-${messageId}.txt` })
87 .pipe(
88 Effect.map((object) => object.size),
89 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
90 Effect.repeat({
91 schedule: Schedule.spaced("5 seconds"),
92 until: (size) => size !== undefined,
93 times: 24,
94 }),
95 );
96 expect(marker).toEqual("5");
97
98 yield* stack.destroy();
99
100 const gone = yield* cloudfunctions
101 .getProjectsLocationsFunctions({ name: out.name })
102 .pipe(
103 Effect.as("found" as const),
104 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
105 );
106 expect(gone).toEqual("gone");
107 const saGone = yield* iam
108 .getProjectsServiceAccounts({
109 name: `projects/${out.project}/serviceAccounts/${out.serviceAccount}`,
110 })
111 .pipe(
112 Effect.as("found" as const),
113 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
114 );
115 expect(saGone).toEqual("gone");
116 }).pipe(logLevel),
117 {
118 tags: ["provider:gcp", "provider:gcp:cloudfunctions", "live"],
119 timeout: 900_000,
120 },
121);