1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as redis from "@distilled.cloud/gcp/redis_v1";
5import * as run from "@distilled.cloud/gcp/run_v2";
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";
15import { LIMIT, WINDOW_SECONDS } from "../src/Api.ts";
16
17const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
18 providers: GCP.providers(),
19 state: Alchemy.localState(),
20});
21
22// Out-of-band calls to the Google APIs resolve the same stored credentials
23// the deploy uses, so the test runs against the configured profile.
24const GcpHttp = Layer.mergeAll(
25 GCP.GcpAuth,
26 GCP.fromAuthProvider(),
27 FetchHttpClient.layer,
28);
29
30// The service is built from `main`, which needs a local image build.
31const dockerAvailable =
32 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status ===
33 0;
34
35interface Counter {
36 key: string;
37 count: number;
38 limit: number;
39 remaining: number;
40 limited: boolean;
41 resetInSeconds: number | null;
42}
43
44// Deploy, tests and destroy all sit behind the same Docker guard.
45describe.skipIf(!dockerAvailable)("gcp-memorystore-redis", () => {
46 // Memorystore takes several minutes to create; the image build and the
47 // Cloud Run rollout run after it.
48 const stack = beforeAll(deploy(Stack), { timeout: 1_200_000 });
49
50 afterAll.skipIf(!!process.env.NO_DESTROY)(
51 Effect.gen(function* () {
52 const outputs = yield* stack;
53 yield* destroy(Stack);
54 if (outputs === undefined) return;
55
56 // Destroy waits for the delete operation, so the instance is gone,
57 // not just DELETING.
58 const instance = yield* redis
59 .getProjectsLocationsInstances({ name: outputs.instanceName })
60 .pipe(
61 Effect.map(() => "present" as const),
62 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
63 Effect.orDie,
64 Effect.provide(GcpHttp),
65 );
66 expect(instance).toBe("gone");
67
68 const service = yield* run
69 .getProjectsLocationsServices({ name: outputs.serviceName })
70 .pipe(
71 Effect.map(() => "present" as const),
72 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
73 Effect.orDie,
74 Effect.provide(GcpHttp),
75 );
76 expect(service).toBe("gone");
77 }),
78 { timeout: 900_000 },
79 );
80
81 const baseUrlOf = (url: string | undefined) => {
82 if (url === undefined) throw new Error("the service has no URL");
83 return url.replace(/\/+$/, "");
84 };
85
86 /**
87 * Wait until the service answers *and* can reach Redis: `/count` hits
88 * the instance over the VPC, and answers 500 until the path is up.
89 */
90 const ready = (baseUrl: string) =>
91 HttpClient.execute(HttpClientRequest.get(`${baseUrl}/count/warmup`)).pipe(
92 Effect.repeat({
93 schedule: Schedule.spaced("5 seconds"),
94 until: (response) => response.status === 200,
95 times: 36,
96 }),
97 );
98
99 const hit = (baseUrl: string, key: string) =>
100 HttpClient.execute(HttpClientRequest.post(`${baseUrl}/hit/${key}`));
101
102 const count = (baseUrl: string, key: string) =>
103 HttpClient.execute(HttpClientRequest.get(`${baseUrl}/count/${key}`)).pipe(
104 Effect.flatMap((response) => response.json),
105 Effect.map((body) => body as unknown as Counter),
106 );
107
108 test(
109 "provisions a BASIC 1 GiB instance with AUTH on a private IP",
110 Effect.gen(function* () {
111 const { instanceName, redisHost } = yield* stack;
112 const instance = yield* redis
113 .getProjectsLocationsInstances({ name: instanceName })
114 .pipe(Effect.orDie, Effect.provide(GcpHttp));
115 expect(instance.state).toBe("READY");
116 expect(instance.tier).toBe("BASIC");
117 expect(instance.memorySizeGb).toBe(1);
118 expect(instance.authEnabled).toBe(true);
119 expect(instance.host).toEqual(redisHost);
120 // Memorystore only has a private address.
121 expect(redisHost).toMatch(/^(10|172|192)\./);
122 }),
123 { timeout: 60_000 },
124 );
125
126 test(
127 "counts hits, sets the window TTL, and enforces the limit",
128 Effect.gen(function* () {
129 const { url } = yield* stack;
130 expect(url).toMatch(/^https:\/\//);
131 const baseUrl = baseUrlOf(url);
132 const warm = yield* ready(baseUrl);
133 expect(warm.status).toBe(200);
134
135 const key = `integ-${crypto.randomUUID()}`;
136
137 const fresh = yield* count(baseUrl, key);
138 expect(fresh).toMatchObject({ key, count: 0, resetInSeconds: null });
139
140 for (let n = 1; n <= LIMIT; n++) {
141 const response = yield* hit(baseUrl, key);
142 expect(response.status).toBe(200);
143 const body = (yield* response.json) as unknown as Counter;
144 expect(body).toMatchObject({
145 key,
146 count: n,
147 limit: LIMIT,
148 remaining: LIMIT - n,
149 limited: false,
150 });
151 expect(body.resetInSeconds).toBeGreaterThan(0);
152 expect(body.resetInSeconds).toBeLessThanOrEqual(WINDOW_SECONDS);
153 }
154
155 const over = yield* hit(baseUrl, key);
156 expect(over.status).toBe(429);
157 expect(Number(over.headers["retry-after"])).toBeGreaterThan(0);
158 expect((yield* over.json) as unknown as Counter).toMatchObject({
159 count: LIMIT + 1,
160 remaining: 0,
161 limited: true,
162 });
163
164 // The window's TTL is set once, on the first hit, and ticks down.
165 const after = yield* count(baseUrl, key);
166 expect(after.count).toBe(LIMIT + 1);
167 expect(after.resetInSeconds).toBeGreaterThan(0);
168 expect(after.resetInSeconds).toBeLessThanOrEqual(WINDOW_SECONDS);
169
170 // Keys count independently.
171 const other = yield* hit(baseUrl, `${key}-other`);
172 expect(other.status).toBe(200);
173 expect(((yield* other.json) as unknown as Counter).count).toBe(1);
174 }),
175 { timeout: 300_000 },
176 );
177
178 test(
179 "rejects malformed keys and unknown routes",
180 Effect.gen(function* () {
181 const { url } = yield* stack;
182 const baseUrl = baseUrlOf(url);
183 yield* ready(baseUrl);
184
185 const bad = yield* hit(baseUrl, "no%20spaces");
186 expect(bad.status).toBe(404);
187 const unknown = yield* HttpClient.execute(
188 HttpClientRequest.get(`${baseUrl}/hit/abc`),
189 );
190 expect(unknown.status).toBe(404);
191 }),
192 { timeout: 300_000 },
193 );
194});