@mandujs/core 0.22.1 → 0.24.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,203 @@
1
+ /**
2
+ * @mandujs/core/testing/mocks
3
+ *
4
+ * Swap-in mocks for the I/O primitives tests typically stub:
5
+ *
6
+ * - `mockMail()` → the production `MemoryEmailSender` (Phase 5 email
7
+ * primitive), wrapped so tests get a uniform async-dispose cleanup helper.
8
+ * - `mockStorage()` → an in-memory `S3Client`-shaped handle satisfying the
9
+ * public `@mandujs/core/storage/s3` interface without booting `Bun.S3Client`.
10
+ *
11
+ * Both return objects that are **drop-in replacements** for their production
12
+ * counterparts. Pass them to the handler/service under test via whatever
13
+ * dependency-injection path your code already uses — there is no magic here.
14
+ *
15
+ * ```ts
16
+ * import { mockMail, mockStorage } from "@mandujs/core/testing";
17
+ *
18
+ * const mail = mockMail();
19
+ * const storage = mockStorage();
20
+ *
21
+ * await sendWelcomeEmail({ mail }, "u@x.com");
22
+ * expect(mail.sent[0].subject).toBe("Welcome");
23
+ *
24
+ * await uploadAvatar({ storage }, buffer);
25
+ * expect(await storage.exists("u/avatar.png")).toBe(true);
26
+ * ```
27
+ *
28
+ * @module testing/mocks
29
+ */
30
+
31
+ import {
32
+ createMemoryEmailSender,
33
+ type EmailMessage,
34
+ type MemoryEmailSender,
35
+ } from "../email/index";
36
+ import { getContentType, type S3Client, type S3UploadOptions, type S3PresignOptions } from "../storage/s3/index";
37
+
38
+ // ═══════════════════════════════════════════════════════════════════════════
39
+ // Email mock
40
+ // ═══════════════════════════════════════════════════════════════════════════
41
+
42
+ /** Handle returned by {@link mockMail}. Extends the production sender 1:1. */
43
+ export interface MockMail extends MemoryEmailSender {
44
+ /**
45
+ * Find the most recently sent message whose recipient matches `to`
46
+ * (single-address equality). Returns `undefined` if none matched.
47
+ *
48
+ * Convenience shortcut around a reverse scan of `sent` — the common
49
+ * assertion in verification / password-reset tests.
50
+ */
51
+ lastTo(to: string): (EmailMessage & { id: string; sentAt: number }) | undefined;
52
+ /** `using mail = mockMail()` — clears the outbox on exit. */
53
+ [Symbol.dispose](): void;
54
+ }
55
+
56
+ /**
57
+ * Create an in-process email sender. Backing store is a plain array —
58
+ * read via `mail.sent`, clear between cases with `mail.clear()`.
59
+ *
60
+ * This is a thin wrapper around the production `createMemoryEmailSender`
61
+ * so tests do not depend on an implementation detail of the email package.
62
+ */
63
+ export function mockMail(): MockMail {
64
+ const inner = createMemoryEmailSender();
65
+
66
+ // MemoryEmailSender's `sent` is declared readonly at the type level; the
67
+ // runtime object exposes .push under the covers. We add the convenience
68
+ // helper without widening the surface.
69
+ //
70
+ // Reverse-iterating by index (vs. `.findLast`) avoids dependency on the
71
+ // ES2023 `Array.prototype.findLast` lib type — older tsconfig targets
72
+ // (`"lib": ["ES2022"]`) do not declare it. The runtime has it regardless.
73
+ return Object.assign(inner, {
74
+ lastTo(
75
+ to: string,
76
+ ): (EmailMessage & { id: string; sentAt: number }) | undefined {
77
+ for (let i = inner.sent.length - 1; i >= 0; i--) {
78
+ const m = inner.sent[i];
79
+ if (Array.isArray(m.to)) {
80
+ if (m.to.includes(to)) return m;
81
+ } else if (m.to === to) {
82
+ return m;
83
+ }
84
+ }
85
+ return undefined;
86
+ },
87
+ [Symbol.dispose]() {
88
+ inner.clear();
89
+ },
90
+ }) as MockMail;
91
+ }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════
94
+ // Storage (S3-compatible) mock
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+
97
+ /** Stored blob + metadata for assertions. */
98
+ export interface MockStoredObject {
99
+ readonly body: Uint8Array;
100
+ readonly contentType: string;
101
+ readonly acl?: "private" | "public-read";
102
+ }
103
+
104
+ /** Handle returned by {@link mockStorage}. Extends `S3Client` with test-only affordances. */
105
+ export interface MockStorage extends S3Client {
106
+ /** Every key currently present. */
107
+ keys(): string[];
108
+ /** Raw access to a stored object. Returns `undefined` for missing keys. */
109
+ peek(key: string): MockStoredObject | undefined;
110
+ /** Wipe all stored objects. */
111
+ clear(): void;
112
+ /** `using s = mockStorage()` — clears the store on exit. */
113
+ [Symbol.dispose](): void;
114
+ }
115
+
116
+ /**
117
+ * Convert the production `S3Client` body types into a normalized Uint8Array
118
+ * so `peek()` returns a stable type regardless of what callers uploaded.
119
+ */
120
+ function normalizeBody(body: Blob | ArrayBuffer | Uint8Array): Promise<Uint8Array> {
121
+ if (body instanceof Uint8Array) return Promise.resolve(new Uint8Array(body));
122
+ if (body instanceof ArrayBuffer) return Promise.resolve(new Uint8Array(body));
123
+ // Blob → ArrayBuffer → Uint8Array.
124
+ return body.arrayBuffer().then((ab) => new Uint8Array(ab));
125
+ }
126
+
127
+ /**
128
+ * Create an in-memory S3-compatible storage client. Satisfies the full
129
+ * `S3Client` interface — handlers written against the production API can
130
+ * be called with this mock unchanged.
131
+ *
132
+ * Presigned URLs are synthesized as opaque `mandu-mock://bucket/<key>`
133
+ * strings. They are not de-serializable back into real uploads — only used
134
+ * for identity assertions (presign returned? key matches?).
135
+ */
136
+ export function mockStorage(options?: { bucket?: string }): MockStorage {
137
+ const bucket = options?.bucket ?? "mandu-test-bucket";
138
+ const store = new Map<string, MockStoredObject>();
139
+
140
+ async function upload(
141
+ body: Blob | ArrayBuffer | Uint8Array,
142
+ opts: S3UploadOptions,
143
+ ): Promise<string> {
144
+ if (!opts.key) {
145
+ throw new TypeError(
146
+ "[testing/mocks] mockStorage.upload: 'key' is required.",
147
+ );
148
+ }
149
+ const contentType = opts.contentType ?? getContentType(opts.key);
150
+ const bytes = await normalizeBody(body);
151
+ store.set(opts.key, { body: bytes, contentType, acl: opts.acl });
152
+ return `mandu-mock://${bucket}/${opts.key}`;
153
+ }
154
+
155
+ async function presign(opts: S3PresignOptions): Promise<string> {
156
+ if (!opts.key) {
157
+ throw new TypeError(
158
+ "[testing/mocks] mockStorage.presign: 'key' is required.",
159
+ );
160
+ }
161
+ const method = opts.method ?? "PUT";
162
+ const expiresIn = opts.expiresIn ?? 900;
163
+ return `mandu-mock://${bucket}/${opts.key}?method=${method}&expires=${expiresIn}`;
164
+ }
165
+
166
+ async function deleteObject(key: string): Promise<void> {
167
+ store.delete(key);
168
+ }
169
+
170
+ async function getReadable(key: string): Promise<ReadableStream> {
171
+ const obj = store.get(key);
172
+ if (!obj) {
173
+ throw new Error(
174
+ `[testing/mocks] mockStorage.getReadable: key not found: ${JSON.stringify(key)}`,
175
+ );
176
+ }
177
+ return new ReadableStream({
178
+ start(controller) {
179
+ controller.enqueue(obj.body);
180
+ controller.close();
181
+ },
182
+ });
183
+ }
184
+
185
+ async function exists(key: string): Promise<boolean> {
186
+ return store.has(key);
187
+ }
188
+
189
+ const handle: MockStorage = {
190
+ upload,
191
+ presign,
192
+ delete: deleteObject,
193
+ getReadable,
194
+ exists,
195
+ keys: () => [...store.keys()],
196
+ peek: (key) => store.get(key),
197
+ clear: () => store.clear(),
198
+ [Symbol.dispose]() {
199
+ store.clear();
200
+ },
201
+ };
202
+ return handle;
203
+ }
@@ -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
+ }