1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";
4import * as Neon from "alchemy/Neon";
5import * as Test from "alchemy/Test/Bun";
6import { expect } from "bun:test";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import Stack from "../alchemy.run.ts";
14import type { Post, User } from "../src/schema.ts";
15
16const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
17 providers: Layer.mergeAll(
18 Cloudflare.providers(),
19 Drizzle.providers(),
20 Neon.providers(),
21 ),
22 state: Alchemy.localState(),
23});
24
25const stack = beforeAll(deploy(Stack), { timeout: 240_000 });
26
27afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
28 timeout: 180_000,
29});
30
31// Fresh `workers.dev` URLs transiently 404 (route still propagating) or 5xx
32// (Hyperdrive/Neon binding still settling). `Test.getWhenReady` fails on that
33// cold-start window and retries until the worker answers — but one 200 does
34// not mean the route has converged everywhere: for the first ~30s after
35// "Enabling workers.dev subdomain" consecutive requests can interleave 200s
36// with edge-generated HTML 404s. The worker itself only ever answers JSON
37// (including its own 400/405/500), so guard the client on content-type: any
38// HTML edge page is rejected and retried, while the worker's real statuses
39// stay observable for the assertions below.
40const { getWhenReady } = Test;
41const jsonClient = Test.guardedFetchLayer("application/json", { times: 10 });
42
43test(
44 "worker exposes a URL, hyperdrive id, and neon branch id",
45 Effect.gen(function* () {
46 const { url, branchId, hyperdriveId } = yield* stack;
47
48 expect(url).toBeString();
49 expect(branchId).toBeString();
50 expect(hyperdriveId).toBeString();
51 }),
52);
53
54test(
55 "worker exposes user CRUD through Drizzle / Hyperdrive / Neon",
56 Effect.gen(function* () {
57 const { url } = yield* stack;
58 const baseUrl = url.replace(/\/+$/, "");
59
60 const initialResponse = yield* getWhenReady(baseUrl);
61 expect(initialResponse.status).toBe(200);
62
63 const initialBody = (yield* initialResponse.json) as unknown as {
64 users: User[];
65 };
66 expect(Array.isArray(initialBody.users)).toBe(true);
67
68 const createResponse = yield* HttpClient.execute(
69 HttpClientRequest.post(baseUrl),
70 );
71 expect(createResponse.status).toBe(200);
72
73 const createBody = (yield* createResponse.json) as unknown as {
74 user: User[];
75 };
76 expect(createBody.user).toHaveLength(1);
77
78 const [createdUser] = createBody.user;
79 expect(createdUser.id).toBeNumber();
80 expect(createdUser.email).toBeString();
81 expect(createdUser.name).toBeString();
82 expect(createdUser.createdAt).toBeString();
83
84 const readResponse = yield* HttpClient.get(`${baseUrl}/${createdUser.id}`);
85 expect(readResponse.status).toBe(200);
86
87 const readBody = (yield* readResponse.json) as unknown as {
88 user: User & { posts: Post[] };
89 };
90 expect(readBody.user).toMatchObject({
91 id: createdUser.id,
92 email: createdUser.email,
93 name: createdUser.name,
94 posts: [],
95 });
96
97 const invalidReadResponse = yield* HttpClient.get(`${baseUrl}/not-a-user`);
98 expect(invalidReadResponse.status).toBe(400);
99 expect(yield* invalidReadResponse.json).toEqual({
100 error: "Invalid user ID",
101 });
102
103 const methodResponse = yield* HttpClient.execute(
104 HttpClientRequest.patch(baseUrl),
105 );
106 expect(methodResponse.status).toBe(405);
107 expect(yield* methodResponse.json).toEqual({
108 error: "Method not allowed",
109 });
110
111 const deleteResponse = yield* HttpClient.execute(
112 HttpClientRequest.delete(`${baseUrl}/${createdUser.id}`),
113 );
114 expect(deleteResponse.status).toBe(200);
115
116 const deleteBody = (yield* deleteResponse.json) as unknown as {
117 user: User;
118 };
119 expect(deleteBody.user).toMatchObject({
120 id: createdUser.id,
121 email: createdUser.email,
122 name: createdUser.name,
123 });
124
125 const finalResponse = yield* HttpClient.get(baseUrl);
126 expect(finalResponse.status).toBe(200);
127 const finalBody = (yield* finalResponse.json) as unknown as {
128 users: User[];
129 };
130 expect(finalBody.users.some((user) => user.id === createdUser.id)).toBe(
131 false,
132 );
133 }).pipe(Effect.provide(jsonClient)),
134 // The cold-start `getWhenReady` window plus a full CRUD round-trip against a
135 // freshly-warmed Neon/Hyperdrive connection routinely exceeds 20s. Match the
136 // sequential-query case's budget.
137 { timeout: 120_000 },
138);
139
140test(
141 "worker handles 100 sequential queries spaced 100-500ms apart",
142 Effect.gen(function* () {
143 const { url } = yield* stack;
144 const baseUrl = url.replace(/\/+$/, "");
145
146 yield* getWhenReady(baseUrl);
147
148 const queryOnce = Effect.gen(function* () {
149 const response = yield* HttpClient.get(baseUrl);
150 expect(response.status).toBe(200);
151 const body = (yield* response.json) as unknown as { users: User[] };
152 expect(Array.isArray(body.users)).toBe(true);
153 });
154
155 const jitter = Effect.sync(
156 () => Math.floor(Math.random() * 401) + 100,
157 ).pipe(Effect.flatMap((ms) => Effect.sleep(Duration.millis(ms))));
158
159 yield* queryOnce.pipe(
160 Effect.zip(jitter),
161 Effect.repeat(Schedule.recurs(99)),
162 );
163 }).pipe(Effect.provide(jsonClient)),
164 { timeout: 120_000 },
165);