@mandujs/core 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @mandujs/core/testing/server
3
+ *
4
+ * In-process server fixture for integration tests.
5
+ *
6
+ * Wraps `startServer()` with an ephemeral port (`0`), a per-test
7
+ * `ServerRegistry` (so parallel tests cannot stomp each other's handlers),
8
+ * and a scoped `fetch` helper so callers can write:
9
+ *
10
+ * ```ts
11
+ * import { createTestServer } from "@mandujs/core/testing";
12
+ *
13
+ * const server = await createTestServer(manifest, {
14
+ * registerHandlers(reg) {
15
+ * reg.registerApiHandler("api/health", async () => Response.json({ ok: true }));
16
+ * },
17
+ * });
18
+ * afterAll(() => server.close());
19
+ *
20
+ * const res = await server.fetch("/api/health");
21
+ * expect(res.status).toBe(200);
22
+ * ```
23
+ *
24
+ * ## Design
25
+ *
26
+ * - **Registry isolation** — the fixture always creates its own
27
+ * `ServerRegistry` (via `createServerRegistry()`), never touches the default
28
+ * global. That lets several fixtures run concurrently in the same test
29
+ * process without handler collisions.
30
+ * - **Port zero** — Bun picks an OS-assigned ephemeral port; the fixture
31
+ * exposes the resolved port and a pre-computed `baseUrl` so tests never
32
+ * hard-code `:3000`.
33
+ * - **Scoped `fetch`** — accepts either an absolute URL or a root-relative
34
+ * path; the latter is joined to `baseUrl`. This matches the ergonomics
35
+ * of `supertest` / Remix `createRoutesStub` without taking a dep on them.
36
+ * - **Cleanup contract** — `close()` is idempotent; the returned handle is
37
+ * also `asyncDispose`-compatible so `using server = await createTestServer(...)`
38
+ * works under Bun's Explicit Resource Management.
39
+ *
40
+ * @module testing/server
41
+ */
42
+
43
+ import type { RoutesManifest } from "../spec/schema";
44
+ import {
45
+ startServer,
46
+ createServerRegistry,
47
+ type ManduServer,
48
+ type ServerOptions,
49
+ type ServerRegistry,
50
+ } from "../runtime/server";
51
+
52
+ /** Options accepted by {@link createTestServer}. */
53
+ export interface CreateTestServerOptions {
54
+ /**
55
+ * Optional registration callback. The fixture creates a fresh
56
+ * `ServerRegistry` and passes it here so tests can attach API handlers,
57
+ * page loaders, layouts, and so on synchronously before the server starts
58
+ * listening. Skip when the manifest's handlers are resolved elsewhere.
59
+ */
60
+ registerHandlers?: (registry: ServerRegistry) => void | Promise<void>;
61
+ /**
62
+ * Host to bind. Default: `"127.0.0.1"` (explicit IPv4 so Windows CI boxes
63
+ * do not fall through to `::1` and break `fetch` default resolution).
64
+ */
65
+ hostname?: string;
66
+ /**
67
+ * Extra options forwarded to `startServer()`. The fixture overrides
68
+ * `port`, `registry`, and `isDev` — those keys are ignored if present.
69
+ */
70
+ serverOptions?: Omit<ServerOptions, "port" | "registry" | "isDev" | "hostname">;
71
+ /** Set to `true` to boot the server in dev mode (HMR, kitchen, etc.). Default: `false`. */
72
+ isDev?: boolean;
73
+ }
74
+
75
+ /** The fixture handle returned by {@link createTestServer}. */
76
+ export interface TestServer {
77
+ /** The underlying `ManduServer` (access `server.server.port` if you need Bun handles). */
78
+ readonly server: ManduServer;
79
+ /** The registry the fixture created. Use this to register handlers *after* startup. */
80
+ readonly registry: ServerRegistry;
81
+ /** Resolved port (non-zero once `await`ed). */
82
+ readonly port: number;
83
+ /** `http://<host>:<port>` with no trailing slash. */
84
+ readonly baseUrl: string;
85
+
86
+ /**
87
+ * Scoped fetch — accepts a path (`"/api/foo"`) or absolute URL
88
+ * (`"http://other/..."`). Path-style inputs are joined to `baseUrl`.
89
+ *
90
+ * ```ts
91
+ * await server.fetch("/api/health")
92
+ * await server.fetch("/api/login", { method: "POST", body: JSON.stringify({...}) })
93
+ * await server.fetch(new Request("http://localhost/anything"))
94
+ * ```
95
+ */
96
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
97
+
98
+ /**
99
+ * Idempotent teardown. Stops the underlying `Bun.serve` instance and
100
+ * clears the registry so no handler leaks into the next fixture.
101
+ */
102
+ close(): void;
103
+
104
+ /** Explicit Resource Management support: `using server = await createTestServer(...)`. */
105
+ [Symbol.asyncDispose](): Promise<void>;
106
+ }
107
+
108
+ /**
109
+ * Boot an in-process test server on an ephemeral port.
110
+ *
111
+ * Returns once `Bun.serve` is listening — no polling required; Bun binds
112
+ * synchronously before `fetch` becomes ready.
113
+ *
114
+ * @throws if `manifest` is missing required fields (handler resolution errors
115
+ * surface on the first `fetch`, not here).
116
+ */
117
+ export async function createTestServer(
118
+ manifest: RoutesManifest,
119
+ options: CreateTestServerOptions = {},
120
+ ): Promise<TestServer> {
121
+ const registry = createServerRegistry();
122
+
123
+ if (options.registerHandlers) {
124
+ await options.registerHandlers(registry);
125
+ }
126
+
127
+ const hostname = options.hostname ?? "127.0.0.1";
128
+ const server = startServer(manifest, {
129
+ ...(options.serverOptions ?? {}),
130
+ port: 0,
131
+ hostname,
132
+ isDev: options.isDev ?? false,
133
+ registry,
134
+ });
135
+
136
+ // Bun.serve always resolves the port synchronously before returning the
137
+ // handle — `undefined` would only happen on a fully-closed server, which
138
+ // is impossible here because we just created it. Assert + narrow so
139
+ // consumers see a plain `number`.
140
+ if (typeof server.server.port !== "number") {
141
+ throw new Error(
142
+ "[testing/server] startServer() returned without a bound port. This is a framework-level bug — please report.",
143
+ );
144
+ }
145
+ const port: number = server.server.port;
146
+ const baseUrl = `http://${hostname}:${port}`;
147
+
148
+ let closed = false;
149
+ const close = (): void => {
150
+ if (closed) return;
151
+ closed = true;
152
+ try {
153
+ server.stop();
154
+ } catch {
155
+ // stop() is best-effort — swallow so afterEach doesn't mask real test failures.
156
+ }
157
+ };
158
+
159
+ const fetchImpl = async (
160
+ input: string | URL | Request,
161
+ init?: RequestInit,
162
+ ): Promise<Response> => {
163
+ if (closed) {
164
+ throw new Error(
165
+ "[testing/server] fetch() called after close() — did an afterAll run before this test?",
166
+ );
167
+ }
168
+
169
+ // Resolve path-style inputs against baseUrl. Absolute URLs and Request
170
+ // objects pass through untouched so callers keep full control.
171
+ if (typeof input === "string") {
172
+ const resolved = input.startsWith("http://") || input.startsWith("https://")
173
+ ? input
174
+ : `${baseUrl}${input.startsWith("/") ? "" : "/"}${input}`;
175
+ return fetch(resolved, init);
176
+ }
177
+
178
+ if (input instanceof URL) {
179
+ return fetch(input, init);
180
+ }
181
+
182
+ return fetch(input, init);
183
+ };
184
+
185
+ return {
186
+ server,
187
+ registry,
188
+ port,
189
+ baseUrl,
190
+ fetch: fetchImpl,
191
+ close,
192
+ async [Symbol.asyncDispose]() {
193
+ close();
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @mandujs/core/testing/session
3
+ *
4
+ * Pre-baked login state for integration tests.
5
+ *
6
+ * Most test scenarios start with "given a logged-in user, when...". This
7
+ * module returns a ready-to-use set of request headers (specifically,
8
+ * a `Cookie` header bearing a signed session payload) without going
9
+ * through the live `/login` flow — which would couple the test to the
10
+ * app's password, CSRF, and rate-limit policies.
11
+ *
12
+ * ```ts
13
+ * import { createTestSession } from "@mandujs/core/testing";
14
+ *
15
+ * const authed = await createTestSession({ userId: "u_42", roles: ["admin"] });
16
+ *
17
+ * const res = await server.fetch("/dashboard", {
18
+ * headers: authed.headers,
19
+ * });
20
+ * expect(res.status).toBe(200);
21
+ * ```
22
+ *
23
+ * ## Contract
24
+ *
25
+ * - **Storage compatibility**: the cookie produced here is consumable by the
26
+ * same `createCookieSessionStorage()` the app uses in production, as long
27
+ * as the shared `secret` matches. Tests typically pass the app's real
28
+ * storage instance via `{ storage }`; otherwise the fixture spins up a
29
+ * fresh one with a deterministic test secret.
30
+ * - **No network**: everything happens in-process — no HTTP roundtrip, no
31
+ * CSRF token generation, no rate-limit counters incremented.
32
+ * - **Fully typed**: `userId` is required, `extras` carries arbitrary
33
+ * JSON-serializable payload. Cookie attributes (name, path, secure) come
34
+ * from the provided storage's options.
35
+ *
36
+ * @module testing/session
37
+ */
38
+
39
+ import {
40
+ Session,
41
+ createCookieSessionStorage,
42
+ type SessionStorage,
43
+ type CookieSessionOptions,
44
+ } from "../filling/session";
45
+ import { CookieManager } from "../filling/context";
46
+
47
+ /** Key under which login helpers persist the user id. Mirrors `auth/login.ts`. */
48
+ const USER_ID_KEY = "userId";
49
+ /** Key under which login helpers persist the login timestamp. Mirrors `auth/login.ts`. */
50
+ const LOGGED_AT_KEY = "loginAt";
51
+
52
+ /** Default session secret used when the caller does not pass a storage instance. */
53
+ const DEFAULT_TEST_SECRET = "mandu-test-secret-do-not-use-in-production";
54
+
55
+ /** Options for {@link createTestSession}. */
56
+ export interface CreateTestSessionOptions {
57
+ /** User id persisted in the session. Required. */
58
+ userId: string;
59
+ /** Arbitrary additional session data (JSON-serializable). */
60
+ extras?: Record<string, unknown>;
61
+ /**
62
+ * Login timestamp to persist. Default: `Date.now()` at call time.
63
+ * Useful for age-sensitive flows (e.g., re-auth prompts).
64
+ */
65
+ loggedAt?: number;
66
+ /**
67
+ * Storage instance to commit against. When omitted, the fixture spins up
68
+ * a fresh in-memory cookie-backed storage with {@link DEFAULT_TEST_SECRET}.
69
+ * Pass your app's real storage (same `secret`) to make the cookie
70
+ * round-trippable by the server under test.
71
+ */
72
+ storage?: SessionStorage;
73
+ /**
74
+ * Overrides for the fixture-created storage. Ignored when `storage` is
75
+ * passed directly.
76
+ */
77
+ cookieOptions?: Partial<CookieSessionOptions["cookie"]>;
78
+ }
79
+
80
+ /** The return shape. */
81
+ export interface TestSession {
82
+ /** Raw `Set-Cookie` header string produced by committing the session. */
83
+ readonly setCookie: string;
84
+ /** `Cookie: ...` value suitable for outbound requests. */
85
+ readonly cookieHeader: string;
86
+ /** Headers object with `Cookie` pre-set — spread into `fetch()` directly. */
87
+ readonly headers: Record<string, string>;
88
+ /** The storage instance used to commit — re-use for subsequent setup steps. */
89
+ readonly storage: SessionStorage;
90
+ /** The `Session` instance that was committed (read-only snapshot view). */
91
+ readonly session: Session;
92
+ /** Convenience: the `userId` the fixture was created for. */
93
+ readonly userId: string;
94
+ }
95
+
96
+ /**
97
+ * Build a ready-to-use authenticated session.
98
+ *
99
+ * **Why not just POST `/login`?** Because the login endpoint can be behind
100
+ * CSRF, rate limit, captcha, 2FA, etc. The testing need is to assert
101
+ * behaviour *given* a logged-in user — not to re-validate the login path.
102
+ * For login-path tests, call the route directly with `server.fetch("/login", …)`.
103
+ */
104
+ export async function createTestSession(
105
+ options: CreateTestSessionOptions,
106
+ ): Promise<TestSession> {
107
+ const { userId, extras, loggedAt, cookieOptions } = options;
108
+
109
+ if (typeof userId !== "string" || userId.length === 0) {
110
+ throw new TypeError(
111
+ "[testing/session] createTestSession: 'userId' must be a non-empty string.",
112
+ );
113
+ }
114
+
115
+ const storage =
116
+ options.storage ??
117
+ createCookieSessionStorage({
118
+ cookie: {
119
+ name: cookieOptions?.name ?? "__session",
120
+ secrets: cookieOptions?.secrets ?? [DEFAULT_TEST_SECRET],
121
+ httpOnly: cookieOptions?.httpOnly ?? true,
122
+ // Tests run over plain HTTP — forcing Secure would make the cookie
123
+ // invisible to the server under test. Explicit `false` overrides the
124
+ // production-env default in `createCookieSessionStorage`.
125
+ secure: cookieOptions?.secure ?? false,
126
+ sameSite: cookieOptions?.sameSite ?? "lax",
127
+ maxAge: cookieOptions?.maxAge ?? 86_400,
128
+ path: cookieOptions?.path ?? "/",
129
+ domain: cookieOptions?.domain,
130
+ },
131
+ });
132
+
133
+ const session = new Session();
134
+ session.set(USER_ID_KEY, userId);
135
+ session.set(LOGGED_AT_KEY, typeof loggedAt === "number" ? loggedAt : Date.now());
136
+
137
+ if (extras) {
138
+ for (const [key, value] of Object.entries(extras)) {
139
+ session.set(key, value);
140
+ }
141
+ }
142
+
143
+ const setCookie = await storage.commitSession(session);
144
+ const cookieHeader = extractCookieValuePair(setCookie);
145
+
146
+ return {
147
+ setCookie,
148
+ cookieHeader,
149
+ headers: { Cookie: cookieHeader },
150
+ storage,
151
+ session,
152
+ userId,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Given a Set-Cookie string, return the `name=value` segment suitable for
158
+ * an outgoing `Cookie` request header. Attribute fields (Path, Max-Age,
159
+ * HttpOnly, SameSite, ...) are stripped.
160
+ *
161
+ * The value MUST NOT be URL-decoded — the server-side cookie parser expects
162
+ * the same encoding that was produced by `commitSession`.
163
+ *
164
+ * Exported so tests that build their own Set-Cookie (custom storage) can
165
+ * share the parsing logic.
166
+ */
167
+ export function extractCookieValuePair(setCookie: string): string {
168
+ const end = setCookie.indexOf(";");
169
+ return end === -1 ? setCookie : setCookie.slice(0, end);
170
+ }
171
+
172
+ /**
173
+ * Read the session that `storage.getSession()` would yield for the given
174
+ * `Cookie:` header. Useful in tests that install their own middleware and
175
+ * want to assert what the handler would see.
176
+ *
177
+ * Thin wrapper that constructs a throwaway `Request` (the form the
178
+ * production `CookieManager` expects) and delegates to `getSession` —
179
+ * so tests never have to thread the plumbing themselves.
180
+ */
181
+ export async function readSession(
182
+ storage: SessionStorage,
183
+ cookieHeader: string,
184
+ ): Promise<Session> {
185
+ const request = new Request("http://localhost/__testing/readSession", {
186
+ headers: { cookie: cookieHeader },
187
+ });
188
+ const cookies = new CookieManager(request);
189
+ return storage.getSession(cookies);
190
+ }