@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.
- package/package.json +1 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/dev.ts +288 -0
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/prefetch-helper.ts +55 -0
- package/src/config/mandu.ts +106 -0
- package/src/config/validate.ts +87 -1
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -0
- package/src/desktop/__tests__/window.test.ts +79 -3
- package/src/desktop/webview-fallback.ts +583 -0
- package/src/desktop/window.ts +527 -492
- package/src/perf/hmr-markers.ts +12 -0
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/server.ts +243 -12
- package/src/runtime/ssr.ts +146 -7
- package/src/runtime/streaming-ssr.ts +89 -3
- package/src/testing/db.ts +157 -0
- package/src/testing/index.ts +59 -1
- package/src/testing/mocks.ts +203 -0
- package/src/testing/server.ts +196 -0
- package/src/testing/session.ts +190 -0
- package/src/testing/snapshot.ts +444 -0
|
@@ -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
|
+
}
|