1import * as Alchemy from "alchemy";
2import * as Drizzle from "alchemy/Drizzle";
3import * as Fly from "alchemy/Fly";
4import * as Test from "alchemy/Test/Bun";
5import { expect } from "bun:test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as fs from "node:fs";
12import * as path from "node:path";
13import Stack from "../alchemy.run.ts";
14import type { User } from "../src/schema.ts";
15import { MIGRATE_TOKEN } from "../src/shared.ts";
16
17/** A `User` row as it arrives over JSON — `createdAt` is an ISO string. */
18type SerializedUser = Omit<User, "createdAt"> & { createdAt: string };
19
20const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
21 providers: Layer.mergeAll(Fly.providers(), Drizzle.providers()),
22 state: Alchemy.localState(),
23 profile: process.env.ALCHEMY_PROFILE,
24});
25
26/**
27 * Generated migration SQL, in journal order. `Drizzle.Schema` writes these
28 * during the deploy in `beforeAll`, so read lazily inside the test.
29 */
30const readMigrationSql = (): string[] => {
31 const dir = path.resolve(import.meta.dirname, "..", "migrations");
32 return fs
33 .readdirSync(dir)
34 .filter((entry) => fs.existsSync(path.join(dir, entry, "migration.sql")))
35 .sort()
36 .map((entry) =>
37 fs.readFileSync(path.join(dir, entry, "migration.sql"), "utf8"),
38 );
39};
40
41/** Run a request, retrying non-200s through the fresh app's warmup. */
42const executeOk = (request: HttpClientRequest.HttpClientRequest) =>
43 Effect.gen(function* () {
44 const client = yield* HttpClient.HttpClient;
45 return yield* client.execute(request).pipe(
46 Effect.flatMap((res) =>
47 res.status === 200
48 ? Effect.succeed(res)
49 : Effect.fail(new Error(`HTTP ${res.status}`)),
50 ),
51 // Bounded (~90s): machine boot + route warmup. Never use unbounded
52 // exponential growth here — a genuinely broken route should surface
53 // well inside the test timeout.
54 Effect.retry({
55 schedule: Schedule.spaced("2 seconds"),
56 times: 45,
57 }),
58 );
59 });
60
61const stack = beforeAll(deploy(Stack), { timeout: 300_000 });
62
63afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
64 timeout: 180_000,
65});
66
67test(
68 "Service connects to Managed Postgres through Drizzle",
69 Effect.gen(function* () {
70 const out = yield* stack;
71 expect(out.clusterId).toBeString();
72 expect(out.region).toBe("iad");
73 expect(out.apiUrl).toBe(`https://${out.appName}.fly.dev`);
74
75 // Apply the generated schema THROUGH the deployed Service. MPG is only
76 // reachable on the org's private network, so the in-network Service is
77 // the data plane for DDL too — the test never connects to the cluster
78 // directly. The `/migrate` route is idempotent, so warmup retries are
79 // safe.
80 const migrations = readMigrationSql();
81 expect(migrations.length).toBeGreaterThan(0);
82 for (const sqlText of migrations) {
83 const migrated = yield* executeOk(
84 HttpClientRequest.post(`${out.apiUrl}/migrate`).pipe(
85 HttpClientRequest.setHeader("x-migrate-token", MIGRATE_TOKEN),
86 HttpClientRequest.bodyText(sqlText),
87 ),
88 );
89 const { applied } = (yield* migrated.json) as { applied: number };
90 expect(applied).toBeGreaterThan(0);
91 }
92
93 const health = yield* executeOk(
94 HttpClientRequest.get(`${out.apiUrl}/health`),
95 );
96 expect(health.status).toBe(200);
97 const body = (yield* health.json) as { ok: boolean; users: number };
98 expect(body.ok).toBe(true);
99 expect(body.users).toBeNumber();
100
101 const created = yield* HttpClient.execute(
102 HttpClientRequest.post(`${out.apiUrl}/users`),
103 );
104 expect(created.status).toBe(200);
105 const createdBody = (yield* created.json) as { user: SerializedUser[] };
106 expect(createdBody.user).toHaveLength(1);
107 expect(createdBody.user[0]?.id).toBeNumber();
108 }),
109 { timeout: 240_000 },
110);