1import * as Alchemy from "alchemy";
2import * as Fly from "alchemy/Fly";
3import * as Test from "alchemy/Test/Bun";
4import { expect } from "bun:test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import Stack from "../alchemy.run.ts";
11
12const { getWhenReady } = Test;
13
14class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
15 body: string;
16}> {}
17
18const getBodyWhenReady = (url: string, expected: string) =>
19 Effect.gen(function* () {
20 const res = yield* getWhenReady(url);
21 expect(res.status).toBe(200);
22 const body = yield* res.text;
23 if (!body.includes(expected)) {
24 return yield* Effect.fail(new AssetNotReady({ body }));
25 }
26 return body;
27 }).pipe(
28 Effect.retry({
29 while: (error) => error instanceof AssetNotReady,
30 schedule: Schedule.max([
31 Schedule.min([
32 Schedule.exponential("500 millis"),
33 Schedule.spaced("3 seconds"),
34 ]),
35 Schedule.recurs(20),
36 ]),
37 }),
38 );
39
40const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
41 providers: Fly.providers(),
42 state: Alchemy.localState(),
43 profile: process.env.ALCHEMY_PROFILE,
44});
45
46const stack = beforeAll(deploy(Stack), { timeout: 240_000 });
47
48afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
49 timeout: 180_000,
50});
51
52const executeOk = (request: HttpClientRequest.HttpClientRequest) =>
53 Effect.gen(function* () {
54 const client = yield* HttpClient.HttpClient;
55 return yield* client.execute(request).pipe(
56 Effect.flatMap((res) =>
57 res.status === 200 || res.status === 201
58 ? Effect.succeed(res)
59 : Effect.fail(new Error(`HTTP ${res.status}`)),
60 ),
61 Effect.retry({
62 schedule: Schedule.spaced("2 seconds"),
63 times: 45,
64 }),
65 );
66 });
67
68test(
69 "deploys the SPA and the API",
70 Effect.gen(function* () {
71 const { url, apiUrl } = yield* stack;
72 expect(url).toBeString();
73 expect(url).toMatch(/^https:\/\/.+\.fly\.dev$/);
74 expect(apiUrl).toBeString();
75 expect(apiUrl).toMatch(/^https:\/\/.+\.fly\.dev$/);
76 }),
77 { timeout: 120_000 },
78);
79
80test(
81 "SPA renders the Notes heading",
82 Effect.gen(function* () {
83 const { url } = yield* stack;
84 if (!url) throw new Error("expected the site to expose a fly.dev url");
85 const html = yield* getBodyWhenReady(
86 String(url).replace(/\/+$/, ""),
87 "Notes — Fly",
88 );
89 expect(html).toContain("Notes — Fly");
90 }),
91 { timeout: 120_000 },
92);
93
94test(
95 "API persists a note in Postgres",
96 Effect.gen(function* () {
97 const { apiUrl } = yield* stack;
98 if (!apiUrl) throw new Error("expected the API url");
99 const base = String(apiUrl).replace(/\/+$/, "");
100 const marker = `fly-note-${crypto.randomUUID()}`;
101 const created = yield* executeOk(
102 HttpClientRequest.post(`${base}/notes`).pipe(
103 HttpClientRequest.bodyJsonUnsafe({ body: marker }),
104 ),
105 );
106 expect(created.status).toBe(201);
107 const listed = yield* executeOk(HttpClientRequest.get(`${base}/notes`));
108 const payload = (yield* listed.json) as { notes: Array<{ body: string }> };
109 expect(payload.notes.some((note) => note.body === marker)).toBe(true);
110 }),
111 { timeout: 180_000 },
112);