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