Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 85 additions & 55 deletions examples/start-api-todo/src/app.integration.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,91 @@
// Integration: boot the REAL server — `runHost(AppLayer)` exactly as `server.ts` does — on an
// ephemeral port (PORT=0), drive it over an actual socket with fetch, then let the scope close
// and prove teardown (the port stops accepting). No containers needed: this example's only
// infrastructure is the HTTP listener itself.
import { HttpServer } from "@btravstack/start-api";
import { runHost } from "@btravstack/start-kernel";
import { fromSafePromise } from "unthrown";
import { describe, expect, it } from "vitest";

describe("start-api-todo (full boot)", () => {
it("serves CRUD over a real socket and tears the listener down on scope close", async () => {
process.env["PORT"] = "0";
process.env["LOG_LEVEL"] = "warn";
const { AppLayer } = await import("./app.js");

let baseUrl = "";
const outcome = await runHost(AppLayer, {
use: (ctx) =>
fromSafePromise(
(async () => {
baseUrl = `http://localhost:${ctx.get(HttpServer).port}`;

const created = await fetch(`${baseUrl}/todos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "buy milk" }),
});
expect(created.status).toBe(200);
const todo = (await created.json()) as { id: string; title: string };
expect(todo).toMatchObject({ title: "buy milk" });

const listed = await fetch(`${baseUrl}/todos`);
expect(listed.status).toBe(200);
expect(await listed.json()).toEqual([expect.objectContaining({ title: "buy milk" })]);

const got = await fetch(`${baseUrl}/todos/${todo.id}`);
expect(got.status).toBe(200);

const missing = await fetch(`${baseUrl}/todos/does-not-exist`);
expect(missing.status).toBe(404);

const invalid = await fetch(`${baseUrl}/todos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "" }),
});
expect(invalid.status).toBe(400);
})(),
),
// Integration — drive the running todos API over a real socket with plain `fetch`: one request per
// test, each asserting the whole response (status and body together). Nothing here mentions
// `runHost`, demesne, or a context — all of that is quarantined in `./testkit/boot`. This file is
// transport-only, so it would stay identical if start + demesne were swapped out.
//
// Each test gets its own freshly-booted app through the `app` fixture, so the in-memory repo starts
// empty every time: tests are autonomous and order-independent (no shared seed row).
import { it as base, describe, expect } from "vitest";

import { boot, type BootedApp } from "./testkit/boot.js";

const it = base.extend<{ app: BootedApp }>({
// vitest requires the fixture's first arg to be an object-destructuring pattern; this fixture
// pulls no other fixtures, so it is empty.
// oxlint-disable-next-line no-empty-pattern
app: async ({}, use) => {
const app = await boot();
// `finally` so the listener is always torn down — even if the test (i.e. `use`) throws on an
// assertion failure or timeout, which would otherwise leak a running server into later tests.
try {
await use(app);
} finally {
await app.close();
}
},
});

// A Response can't be compared with `toEqual` directly (its body is a one-shot stream and it
// carries internal fields), so collapse it to an assertable snapshot and match the whole thing.
const responseOf = async (res: Response): Promise<{ status: number; body: unknown }> => ({
status: res.status,
body: await res.json(),
});

const postTodo = (baseUrl: string, title: string): Promise<Response> =>
fetch(`${baseUrl}/todos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title }),
});

describe("start-api-todo over HTTP", () => {
it("creates a todo", async ({ app }) => {
expect(await responseOf(await postTodo(app.baseUrl, "buy milk"))).toEqual({
status: 200,
body: { id: expect.any(String), title: "buy milk", completed: false },
});
});

it("lists the todos", async ({ app }) => {
const created = (await (await postTodo(app.baseUrl, "buy milk")).json()) as { id: string };

expect(await responseOf(await fetch(`${app.baseUrl}/todos`))).toEqual({
status: 200,
body: [{ id: created.id, title: "buy milk", completed: false }],
});
});

it("gets a todo by id", async ({ app }) => {
const created = (await (await postTodo(app.baseUrl, "buy milk")).json()) as { id: string };

expect(await responseOf(await fetch(`${app.baseUrl}/todos/${created.id}`))).toEqual({
status: 200,
body: { id: created.id, title: "buy milk", completed: false },
});
});

outcome.match({
ok: () => undefined,
err: (error) => expect.unreachable(`startup failed: ${error._tag}`),
defect: (cause) => expect.unreachable(`panic: ${String(cause)}`),
it("maps a missing todo to 404", async ({ app }) => {
expect(await responseOf(await fetch(`${app.baseUrl}/todos/does-not-exist`))).toEqual({
status: 404,
body: { error: "todo not found" },
});
});

it("rejects invalid input with 400", async ({ app }) => {
expect(await responseOf(await postTodo(app.baseUrl, ""))).toEqual({
status: 400,
body: expect.objectContaining({ error: "invalid input" }),
});
});
});

// The scope has closed — the listener must be gone (factor IX: disposability).
await expect(fetch(`${baseUrl}/todos`)).rejects.toThrow();
describe("start-api-todo teardown", () => {
// Boots its own app (the `app` fixture is lazy and never triggered here) so it can assert on the
// world *after* the scope has closed — something the auto-closing fixture can't express.
it("stops accepting once the scope closes (factor IX: disposability)", async () => {
const app = await boot();
await app.close();
await expect(fetch(`${app.baseUrl}/todos`)).rejects.toThrow();
});
});
83 changes: 83 additions & 0 deletions examples/start-api-todo/src/testkit/boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// The integration suite's ONE seam onto btravstack start + demesne. It boots the real app —
// `runHost(AppLayer)` exactly as `server.ts` does — on an ephemeral port (PORT=0), holds the scope
// open for the caller, and hands back a transport-neutral handle: a `baseUrl` to drive with plain
// `fetch`, and `close()` to tear the listener down. The spec that consumes this knows only HTTP, so
// swapping start/demesne for anything else would touch this file and nothing else.
//
// Why a bridge is needed: `runHost`'s scope lifetime IS its `use` callback (kernel invariant K2 —
// there is no open/hold/close handle). To span a test's lifetime we pin the scope open with two
// promises: `use` resolves `ready` outward once the app is up, then awaits `release`; `close()`
// fires `release`, letting `use` return so every finalizer runs in reverse (the listener stops
// accepting). All of that lives here — never in a test body.
import { HttpServer } from "@btravstack/start-api";
import { runHost } from "@btravstack/start-kernel";
import { fromSafePromise } from "unthrown";
import { vi } from "vitest";

export type BootedApp = {
/** Base URL of the live listener — drive it with plain `fetch`. */
readonly baseUrl: string;
/** Close the scope: run every finalizer in reverse, after which the port stops accepting. */
readonly close: () => Promise<void>;
};

export const boot = async (): Promise<BootedApp> => {
// `vi.stubEnv` (paired with `unstubEnvs: true` in the config) sets the config seam without
// mutating `process.env` permanently — the worker's env is restored before every test, so a boot
// here can never leak PORT/LOG_LEVEL into a later file or make failures order-dependent.
vi.stubEnv("PORT", "0");
vi.stubEnv("LOG_LEVEL", "warn");
const { AppLayer } = await import("../app.js");

let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});

let ready!: (baseUrl: string) => void;
const readyUrl = new Promise<string>((resolve) => {
ready = resolve;
});

// `use` never asserts — it only publishes the base URL outward and then parks on `release`,
// keeping the scope (and the listener) alive until `close()` is called.
const outcome = runHost(AppLayer, {
use: (ctx) => {
ready(`http://localhost:${ctx.get(HttpServer).port}`);
return fromSafePromise(released);
},
});

// A boot failure (config or listen error) settles `outcome` before `use` ever runs, so `readyUrl`
// would hang. Race the two: a non-ok boot throws here, failing setup with the underlying error
// preserved as `cause`; a successful boot resolves `readyUrl` while this branch stays pending
// until `close()`.
const bootFailure = outcome.then((result) =>
result.match({
ok: () => new Promise<never>(() => undefined), // reached only at close — not a boot failure
err: (error) => {
throw new Error(`startup failed: ${error._tag}`, { cause: error });
},
defect: (cause) => {
throw new Error("startup panicked", { cause });
},
}),
);

const baseUrl = await Promise.race([readyUrl, bootFailure]);

const close = async (): Promise<void> => {
release();
(await outcome).match({
ok: () => undefined,
err: (error) => {
throw new Error(`shutdown failed: ${error._tag}`, { cause: error });
},
defect: (cause) => {
throw new Error("shutdown panicked", { cause });
},
});
};

return { baseUrl, close };
};
3 changes: 3 additions & 0 deletions examples/start-api-todo/vitest.integration.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.integration.ts"],
// Each boot stubs PORT/LOG_LEVEL via `vi.stubEnv`; restore them before every test so config
// never leaks across tests or files and failures stay order-independent.
unstubEnvs: true,
pool: "forks",
testTimeout: 240_000,
hookTimeout: 300_000,
Expand Down
Loading