diff --git a/examples/start-api-todo/src/app.integration.ts b/examples/start-api-todo/src/app.integration.ts index 8752a47..f135bc7 100644 --- a/examples/start-api-todo/src/app.integration.ts +++ b/examples/start-api-todo/src/app.integration.ts @@ -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 => + 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(); }); }); diff --git a/examples/start-api-todo/src/testkit/boot.ts b/examples/start-api-todo/src/testkit/boot.ts new file mode 100644 index 0000000..1df2c08 --- /dev/null +++ b/examples/start-api-todo/src/testkit/boot.ts @@ -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; +}; + +export const boot = async (): Promise => { + // `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((resolve) => { + release = resolve; + }); + + let ready!: (baseUrl: string) => void; + const readyUrl = new Promise((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(() => 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 => { + 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 }; +}; diff --git a/examples/start-api-todo/vitest.integration.config.ts b/examples/start-api-todo/vitest.integration.config.ts index 28e4acb..9d7ca30 100644 --- a/examples/start-api-todo/vitest.integration.config.ts +++ b/examples/start-api-todo/vitest.integration.config.ts @@ -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,