1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";
4import * as Planetscale from "alchemy/Planetscale";
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 Planetscale.providers(),
21 ),
22 state: Alchemy.localState(),
23});
24
25const stack = beforeAll(deploy(Stack), { timeout: 1_800_000 });
26
27afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
28 timeout: 1_800_000,
29});
30
31test(
32 "worker exposes a URL, hyperdrive id, and planetscale identifiers",
33 Effect.gen(function* () {
34 const { url, databaseId, branchName, hyperdriveId } = yield* stack;
35
36 expect(url).toBeString();
37 expect(databaseId).toBeString();
38 expect(branchName).toBeString();
39 expect(hyperdriveId).toBeString();
40 }),
41);
42
43// Fresh `workers.dev` URLs transiently 404 (route still propagating) or 5xx
44// (Hyperdrive/PlanetScale binding still settling). `Test.getWhenReady` fails on
45// that cold-start window and retries until the worker answers; the first hit in
46// each test rides it, subsequent requests run against the warmed worker.
47const { getWhenReady } = Test;
48
49test(
50 "worker exposes user CRUD through Drizzle / Hyperdrive / PlanetScale",
51 Effect.gen(function* () {
52 const { url } = yield* stack;
53 const baseUrl = url.replace(/\/+$/, "");
54
55 const initialResponse = yield* getWhenReady(baseUrl);
56 expect(initialResponse.status).toBe(200);
57
58 const initialBody = (yield* initialResponse.json) as unknown as {
59 users: User[];
60 };
61 expect(Array.isArray(initialBody.users)).toBe(true);
62
63 const createResponse = yield* HttpClient.execute(
64 HttpClientRequest.post(baseUrl),
65 );
66 expect(createResponse.status).toBe(200);
67
68 const createBody = (yield* createResponse.json) as unknown as {
69 user: User[];
70 };
71 expect(createBody.user).toHaveLength(1);
72
73 const [createdUser] = createBody.user;
74 expect(createdUser.id).toBeNumber();
75 expect(createdUser.email).toBeString();
76 expect(createdUser.name).toBeString();
77 expect(createdUser.createdAt).toBeString();
78
79 const readResponse = yield* HttpClient.get(`${baseUrl}/${createdUser.id}`);
80 expect(readResponse.status).toBe(200);
81
82 const readBody = (yield* readResponse.json) as unknown as {
83 user: User & { posts: Post[] };
84 };
85 expect(readBody.user).toMatchObject({
86 id: createdUser.id,
87 email: createdUser.email,
88 name: createdUser.name,
89 posts: [],
90 });
91
92 const invalidReadResponse = yield* HttpClient.get(`${baseUrl}/not-a-user`);
93 expect(invalidReadResponse.status).toBe(400);
94 expect(yield* invalidReadResponse.json).toEqual({
95 error: "Invalid user ID",
96 });
97
98 const methodResponse = yield* HttpClient.execute(
99 HttpClientRequest.patch(baseUrl),
100 );
101 expect(methodResponse.status).toBe(405);
102 expect(yield* methodResponse.json).toEqual({
103 error: "Method not allowed",
104 });
105
106 const deleteResponse = yield* HttpClient.execute(
107 HttpClientRequest.delete(`${baseUrl}/${createdUser.id}`),
108 );
109 expect(deleteResponse.status).toBe(200);
110
111 const deleteBody = (yield* deleteResponse.json) as unknown as {
112 user: User;
113 };
114 expect(deleteBody.user).toMatchObject({
115 id: createdUser.id,
116 email: createdUser.email,
117 name: createdUser.name,
118 });
119
120 const finalResponse = yield* HttpClient.get(baseUrl);
121 expect(finalResponse.status).toBe(200);
122 const finalBody = (yield* finalResponse.json) as unknown as {
123 users: User[];
124 };
125 expect(finalBody.users.some((user) => user.id === createdUser.id)).toBe(
126 false,
127 );
128 }),
129 { timeout: 20_000 },
130);
131
132test(
133 "worker handles 100 sequential queries spaced 100-500ms apart",
134 Effect.gen(function* () {
135 const { url } = yield* stack;
136 const baseUrl = url.replace(/\/+$/, "");
137
138 yield* getWhenReady(baseUrl);
139
140 const queryOnce = Effect.gen(function* () {
141 const response = yield* HttpClient.get(baseUrl);
142 expect(response.status).toBe(200);
143 const body = (yield* response.json) as unknown as { users: User[] };
144 expect(Array.isArray(body.users)).toBe(true);
145 });
146
147 const jitter = Effect.sync(
148 () => Math.floor(Math.random() * 401) + 100,
149 ).pipe(Effect.flatMap((ms) => Effect.sleep(Duration.millis(ms))));
150
151 yield* queryOnce.pipe(
152 Effect.zip(jitter),
153 Effect.repeat(Schedule.recurs(99)),
154 );
155 }),
156 { timeout: 120_000 },
157);