@zerotal/testing 1.0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/package.json +56 -0
- package/src/TestApp.ts +573 -0
- package/src/TestExceptionHandler.ts +54 -0
- package/src/TestResponse.ts +953 -0
- package/src/assertions.ts +84 -0
- package/src/data.ts +4433 -0
- package/src/factory.ts +288 -0
- package/src/fake.ts +462 -0
- package/src/fakeFile.ts +229 -0
- package/src/global.d.ts +20 -0
- package/src/index.ts +30 -0
- package/src/migrateDatabase.ts +66 -0
- package/src/preload.ts +33 -0
- package/src/refreshDatabase.ts +115 -0
- package/src/resetTestState.ts +12 -0
- package/src/storageAssertions.ts +52 -0
- package/src/withDatabase.ts +52 -0
package/src/TestApp.ts
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { Application, type ExceptionHandler } from "@zerotal/core";
|
|
2
|
+
import { TestResponse, type SessionDecoder } from "./TestResponse.ts";
|
|
3
|
+
import { TestExceptionHandler } from "./TestExceptionHandler.ts";
|
|
4
|
+
import { resetTestState } from "./resetTestState.ts";
|
|
5
|
+
|
|
6
|
+
type BunServer = { port: number; stop(drain?: boolean): void };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wrapper around a running Application that provides an HTTP test client.
|
|
10
|
+
* Obtain an instance via createTestApp().
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const app = await createTestApp(bootstrap);
|
|
14
|
+
*
|
|
15
|
+
* // Authenticated request
|
|
16
|
+
* const res = await app.actingAs(user).get('/profile');
|
|
17
|
+
* res.assertStatus(200);
|
|
18
|
+
*
|
|
19
|
+
* // Reset auth between tests
|
|
20
|
+
* app.actingAsGuest();
|
|
21
|
+
*
|
|
22
|
+
* await app.close();
|
|
23
|
+
*/
|
|
24
|
+
/** Structural view of the container-bound session driver we need to encode a cookie. */
|
|
25
|
+
type SessionDriverLike = {
|
|
26
|
+
cookieName?: string;
|
|
27
|
+
saveSession(id: string, data: Record<string, unknown>, response: Response): Promise<void>;
|
|
28
|
+
loadFromRequest(request: Request): Promise<{ id: string; data: Record<string, unknown> }>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A file to attach to a multipart request. */
|
|
32
|
+
export interface TestFileInput {
|
|
33
|
+
/** File contents. A string is encoded as UTF-8. */
|
|
34
|
+
content: string | Uint8Array | Blob | File;
|
|
35
|
+
/** Filename sent in the part's `Content-Disposition`. Defaults to `file`. */
|
|
36
|
+
filename?: string;
|
|
37
|
+
/** MIME type sent for the part. Defaults to `application/octet-stream`. */
|
|
38
|
+
type?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Field values a form or multipart request accepts. */
|
|
42
|
+
export type TestFormValue =
|
|
43
|
+
string | number | boolean | null | undefined | TestFileInput | File | Blob;
|
|
44
|
+
|
|
45
|
+
export class TestApp {
|
|
46
|
+
private readonly _server: BunServer;
|
|
47
|
+
private _authCookie: string | undefined = undefined;
|
|
48
|
+
// Auth intent is captured synchronously (so `actingAs(u).get()` stays chainable)
|
|
49
|
+
// and encoded into a real session cookie lazily in request(), using the app's
|
|
50
|
+
// own `session.driver` — so it always matches the driver's format (currently
|
|
51
|
+
// AES-256-GCM encryption, not the legacy HMAC-signed format).
|
|
52
|
+
private _actingUser: { id: number | string } | undefined = undefined;
|
|
53
|
+
private _sessionData: Record<string, unknown> | undefined = undefined;
|
|
54
|
+
private _globalHeaders: Record<string, string> = {};
|
|
55
|
+
private _cookies: Record<string, string> = {};
|
|
56
|
+
private _followRedirects: boolean = false;
|
|
57
|
+
// `undefined` = not resolved yet; `null` = the app binds no session driver.
|
|
58
|
+
private _sessionDriver: SessionDriverLike | null | undefined = undefined;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly _app: Application,
|
|
62
|
+
private readonly _errors?: TestExceptionHandler,
|
|
63
|
+
) {
|
|
64
|
+
const server = (_app as unknown as { _static: BunServer })._static;
|
|
65
|
+
if (!server)
|
|
66
|
+
throw new Error("[Zerotal/testing] App has no running server. Did you call start()?");
|
|
67
|
+
this._server = server;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get port(): number {
|
|
71
|
+
return this._server.port;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Base URL for all requests, e.g. http://localhost:52340 */
|
|
75
|
+
get baseUrl(): string {
|
|
76
|
+
return `http://localhost:${this.port}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The underlying application, for resolving container bindings inside a test. */
|
|
80
|
+
get app(): Application {
|
|
81
|
+
return this._app;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Auth helpers ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Forge a signed session cookie containing user_id so subsequent requests
|
|
88
|
+
* behave as if the given user is authenticated via AuthMiddleware.
|
|
89
|
+
*
|
|
90
|
+
* Reads session.secret and session.cookie from the container config.
|
|
91
|
+
* Chainable — returns `this` so you can inline it:
|
|
92
|
+
* `await app.actingAs(user).get('/profile')`
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const res = await app.actingAs(user).get('/profile');
|
|
96
|
+
* res.assertStatus(200);
|
|
97
|
+
*/
|
|
98
|
+
actingAs(user: { id: number | string }): this {
|
|
99
|
+
this._actingUser = user;
|
|
100
|
+
this._authCookie = undefined; // rebuilt lazily against the app's session driver
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Clear any auth cookie set by actingAs()/withSession(). Subsequent requests are guest.
|
|
106
|
+
* Call in afterEach to reset state between tests.
|
|
107
|
+
*/
|
|
108
|
+
actingAsGuest(): this {
|
|
109
|
+
this._actingUser = undefined;
|
|
110
|
+
this._sessionData = undefined;
|
|
111
|
+
this._authCookie = undefined;
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pre-seed session data for the next request, merged with any `actingAs()` user.
|
|
117
|
+
* Encoded into a real cookie by the app's session driver on the next request.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* const res = await app.withSession({ locale: 'fr', flash_status: 'saved' }).get('/profile');
|
|
121
|
+
*/
|
|
122
|
+
withSession(data: Record<string, unknown>): this {
|
|
123
|
+
this._sessionData = { ...(this._sessionData ?? {}), ...data };
|
|
124
|
+
this._authCookie = undefined; // rebuilt lazily
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Enable automatic redirect following for all requests on this instance.
|
|
130
|
+
* The test client will follow up to 10 Location redirects transparently.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* const res = await app.followingRedirects().post('/login', { email, password });
|
|
134
|
+
* res.assertOk(); // landed on dashboard, not the 302
|
|
135
|
+
*/
|
|
136
|
+
followingRedirects(): this {
|
|
137
|
+
this._followRedirects = true;
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Disable redirect following (restores default behaviour). */
|
|
142
|
+
withoutFollowingRedirects(): this {
|
|
143
|
+
this._followRedirects = false;
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge extra headers into every request sent by this TestApp instance.
|
|
149
|
+
* Useful for setting Accept, Authorization, or custom app headers globally.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* app.withHeaders({ 'X-App-Version': '2' });
|
|
153
|
+
*/
|
|
154
|
+
withHeaders(headers: Record<string, string>): this {
|
|
155
|
+
this._globalHeaders = { ...this._globalHeaders, ...headers };
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Send the request as an API client: `Accept: application/json`. Errors then
|
|
161
|
+
* come back as JSON rather than as the rendered HTML error page, which is what
|
|
162
|
+
* the JSON assertions expect.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* const res = await app.asJson().post('/api/posts', { title: '' });
|
|
166
|
+
* res.assertUnprocessable().assertInvalid('title');
|
|
167
|
+
*/
|
|
168
|
+
asJson(): this {
|
|
169
|
+
return this.withHeaders({ Accept: "application/json" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attach a cookie to every subsequent request.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* app.withCookie('theme', 'dark');
|
|
177
|
+
*/
|
|
178
|
+
withCookie(name: string, value: string): this {
|
|
179
|
+
this._cookies[name] = value;
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Attach several cookies to every subsequent request. */
|
|
184
|
+
withCookies(cookies: Record<string, string>): this {
|
|
185
|
+
this._cookies = { ...this._cookies, ...cookies };
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Drop all cookies added with {@link withCookie}/{@link withCookies}. */
|
|
190
|
+
withoutCookies(): this {
|
|
191
|
+
this._cookies = {};
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Exception handling ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Stop converting exceptions into error pages: the error is captured and the
|
|
199
|
+
* response is a bare `500`, so `res.exception()` hands you the original and
|
|
200
|
+
* any failing assertion quotes its stack.
|
|
201
|
+
*
|
|
202
|
+
* Reach for it when a test is failing on a `500` and the rendered page is
|
|
203
|
+
* telling you nothing. Errors are captured either way — this additionally
|
|
204
|
+
* silences the handler's reporting, so an expected failure stops writing a
|
|
205
|
+
* stack trace into the test output.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* const res = await app.withoutExceptionHandling().get('/checkout');
|
|
209
|
+
* expect(res.exception()).toBeInstanceOf(PaymentDeclinedError);
|
|
210
|
+
*/
|
|
211
|
+
withoutExceptionHandling(): this {
|
|
212
|
+
if (!this._errors) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"[Zerotal/testing] withoutExceptionHandling() needs an app built by createTestApp().",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
this._errors.captureMode = true;
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Restore normal exception rendering (the default). */
|
|
222
|
+
withExceptionHandling(): this {
|
|
223
|
+
if (this._errors) this._errors.captureMode = false;
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── HTTP client ───────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Make an HTTP request to the test server.
|
|
231
|
+
* Merges any global headers and the auth cookie automatically.
|
|
232
|
+
*/
|
|
233
|
+
async request(url: string, init: RequestInit = {}): Promise<TestResponse> {
|
|
234
|
+
const full = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
235
|
+
|
|
236
|
+
await this._ensureAuthCookie();
|
|
237
|
+
|
|
238
|
+
// Merge global headers, then per-request headers, then auth cookie.
|
|
239
|
+
const headers = new Headers({
|
|
240
|
+
...this._globalHeaders,
|
|
241
|
+
...((init.headers as Record<string, string> | undefined) ?? {}),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const cookiePairs = Object.entries(this._cookies).map(([k, v]) => `${k}=${v}`);
|
|
245
|
+
if (this._authCookie) cookiePairs.unshift(this._authCookie);
|
|
246
|
+
if (cookiePairs.length > 0) {
|
|
247
|
+
const existing = headers.get("Cookie");
|
|
248
|
+
const merged = cookiePairs.join("; ");
|
|
249
|
+
headers.set("Cookie", existing ? `${merged}; ${existing}` : merged);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (this._errors) this._errors.lastError = undefined;
|
|
253
|
+
|
|
254
|
+
let res = await fetch(full, { ...init, headers, redirect: "manual" });
|
|
255
|
+
|
|
256
|
+
if (this._followRedirects) {
|
|
257
|
+
let hops = 0;
|
|
258
|
+
while (res.status >= 300 && res.status < 400 && hops < 10) {
|
|
259
|
+
const location = res.headers.get("Location");
|
|
260
|
+
if (!location) break;
|
|
261
|
+
const nextUrl = location.startsWith("http") ? location : `${this.baseUrl}${location}`;
|
|
262
|
+
// Propagate any Set-Cookie from the redirect response
|
|
263
|
+
const setCookie = res.headers.get("Set-Cookie");
|
|
264
|
+
const nextHeaders = new Headers(headers);
|
|
265
|
+
if (setCookie) {
|
|
266
|
+
const existing = nextHeaders.get("Cookie") ?? "";
|
|
267
|
+
const cookiePair = setCookie.split(";")[0] ?? "";
|
|
268
|
+
nextHeaders.set("Cookie", existing ? `${existing}; ${cookiePair}` : cookiePair);
|
|
269
|
+
}
|
|
270
|
+
res = await fetch(nextUrl, { method: "GET", headers: nextHeaders, redirect: "manual" });
|
|
271
|
+
hops++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return TestResponse.of(res, {
|
|
276
|
+
session: await this._sessionDecoder(),
|
|
277
|
+
exception: this._errors?.lastError,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Send a GET request. */
|
|
282
|
+
async get(url: string, headers: Record<string, string> = {}): Promise<TestResponse> {
|
|
283
|
+
return this.request(url, { method: "GET", headers });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Send a HEAD request. */
|
|
287
|
+
async head(url: string, headers: Record<string, string> = {}): Promise<TestResponse> {
|
|
288
|
+
return this.request(url, { method: "HEAD", headers });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Send an OPTIONS request. */
|
|
292
|
+
async options(url: string, headers: Record<string, string> = {}): Promise<TestResponse> {
|
|
293
|
+
return this.request(url, { method: "OPTIONS", headers });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Send a POST request with a JSON body. */
|
|
297
|
+
async post(
|
|
298
|
+
url: string,
|
|
299
|
+
body: unknown,
|
|
300
|
+
headers: Record<string, string> = {},
|
|
301
|
+
): Promise<TestResponse> {
|
|
302
|
+
return this.request(url, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
305
|
+
body: JSON.stringify(body),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Send a PUT request with a JSON body. */
|
|
310
|
+
async put(
|
|
311
|
+
url: string,
|
|
312
|
+
body: unknown,
|
|
313
|
+
headers: Record<string, string> = {},
|
|
314
|
+
): Promise<TestResponse> {
|
|
315
|
+
return this.request(url, {
|
|
316
|
+
method: "PUT",
|
|
317
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
318
|
+
body: JSON.stringify(body),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Send a PATCH request with a JSON body. */
|
|
323
|
+
async patch(
|
|
324
|
+
url: string,
|
|
325
|
+
body: unknown,
|
|
326
|
+
headers: Record<string, string> = {},
|
|
327
|
+
): Promise<TestResponse> {
|
|
328
|
+
return this.request(url, {
|
|
329
|
+
method: "PATCH",
|
|
330
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
331
|
+
body: JSON.stringify(body),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Send a DELETE request. */
|
|
336
|
+
async delete(url: string, headers: Record<string, string> = {}): Promise<TestResponse> {
|
|
337
|
+
return this.request(url, { method: "DELETE", headers });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Form and file-upload requests ─────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Submit a URL-encoded form, the way a browser posts one.
|
|
344
|
+
*
|
|
345
|
+
* A JSON `post()` does not exercise the same path: form submits are what
|
|
346
|
+
* trigger the redirect-back-with-errors branch of validation, the CSRF check,
|
|
347
|
+
* and any middleware that reads `application/x-www-form-urlencoded`. A route
|
|
348
|
+
* meant for a browser should be tested the way a browser reaches it.
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* const res = await app.postForm('/posts', { title: 'Hello', published: true });
|
|
352
|
+
* res.assertRedirect('/posts');
|
|
353
|
+
*/
|
|
354
|
+
async postForm(
|
|
355
|
+
url: string,
|
|
356
|
+
body: Record<string, TestFormValue> = {},
|
|
357
|
+
headers: Record<string, string> = {},
|
|
358
|
+
): Promise<TestResponse> {
|
|
359
|
+
return this._formRequest("POST", url, body, headers);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Submit a URL-encoded form with `PUT`. */
|
|
363
|
+
async putForm(
|
|
364
|
+
url: string,
|
|
365
|
+
body: Record<string, TestFormValue> = {},
|
|
366
|
+
headers: Record<string, string> = {},
|
|
367
|
+
): Promise<TestResponse> {
|
|
368
|
+
return this._formRequest("PUT", url, body, headers);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Submit a URL-encoded form with `PATCH`. */
|
|
372
|
+
async patchForm(
|
|
373
|
+
url: string,
|
|
374
|
+
body: Record<string, TestFormValue> = {},
|
|
375
|
+
headers: Record<string, string> = {},
|
|
376
|
+
): Promise<TestResponse> {
|
|
377
|
+
return this._formRequest("PATCH", url, body, headers);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Submit a `multipart/form-data` request — the only way to exercise a route
|
|
382
|
+
* that reads uploaded files.
|
|
383
|
+
*
|
|
384
|
+
* Pair it with {@link fakeFile} to build the attachments; plain values are
|
|
385
|
+
* sent as ordinary fields alongside them.
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* const res = await app.multipart('/avatar', {
|
|
389
|
+
* name: 'Alice',
|
|
390
|
+
* avatar: fakeFile.image('avatar.png', { width: 64, height: 64 }),
|
|
391
|
+
* });
|
|
392
|
+
* res.assertCreated();
|
|
393
|
+
*/
|
|
394
|
+
async multipart(
|
|
395
|
+
url: string,
|
|
396
|
+
body: Record<string, TestFormValue> = {},
|
|
397
|
+
headers: Record<string, string> = {},
|
|
398
|
+
method: "POST" | "PUT" | "PATCH" = "POST",
|
|
399
|
+
): Promise<TestResponse> {
|
|
400
|
+
const form = new FormData();
|
|
401
|
+
for (const [key, value] of Object.entries(body)) {
|
|
402
|
+
if (value === undefined || value === null) continue;
|
|
403
|
+
if (value instanceof File) {
|
|
404
|
+
form.append(key, value);
|
|
405
|
+
} else if (value instanceof Blob) {
|
|
406
|
+
form.append(key, value, key);
|
|
407
|
+
} else if (typeof value === "object") {
|
|
408
|
+
form.append(key, _toFile(value));
|
|
409
|
+
} else {
|
|
410
|
+
form.append(key, String(value));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Content-Type is deliberately not set: fetch derives it from the FormData,
|
|
414
|
+
// and it must carry the generated multipart boundary.
|
|
415
|
+
return this.request(url, { method, headers, body: form });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Stop the test server and shut the app's providers down. Call in afterAll(). */
|
|
419
|
+
async close(): Promise<void> {
|
|
420
|
+
// Run the full provider teardown (onStopping/onStopped), not just the HTTP
|
|
421
|
+
// server: otherwise queue polling intervals, monitor timers, worker threads,
|
|
422
|
+
// and DB connections from this test file keep running while the next file
|
|
423
|
+
// boots a new app in the same process — a real source of flaky suites and
|
|
424
|
+
// `bun test` runs that never exit. `exit: false` keeps the test process alive.
|
|
425
|
+
await this._app.stop({ exit: false });
|
|
426
|
+
resetTestState();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/** Send `body` as `application/x-www-form-urlencoded`. */
|
|
432
|
+
private async _formRequest(
|
|
433
|
+
method: string,
|
|
434
|
+
url: string,
|
|
435
|
+
body: Record<string, TestFormValue>,
|
|
436
|
+
headers: Record<string, string>,
|
|
437
|
+
): Promise<TestResponse> {
|
|
438
|
+
const params = new URLSearchParams();
|
|
439
|
+
for (const [key, value] of Object.entries(body)) {
|
|
440
|
+
if (value === undefined || value === null) continue;
|
|
441
|
+
if (typeof value === "object") {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`[Zerotal/testing] Field "${key}" is a file — use multipart() instead of ${method.toLowerCase()}Form().`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
params.append(key, String(value));
|
|
447
|
+
}
|
|
448
|
+
return this.request(url, {
|
|
449
|
+
method,
|
|
450
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", ...headers },
|
|
451
|
+
body: params.toString(),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Resolve (once) the app's session driver, or `null` when it binds none. */
|
|
456
|
+
private async _driver(): Promise<SessionDriverLike | null> {
|
|
457
|
+
if (this._sessionDriver !== undefined) return this._sessionDriver;
|
|
458
|
+
try {
|
|
459
|
+
const container = this._app.container as unknown as { make(token: string): Promise<unknown> };
|
|
460
|
+
this._sessionDriver = (await container.make("session.driver")) as SessionDriverLike;
|
|
461
|
+
} catch {
|
|
462
|
+
this._sessionDriver = null;
|
|
463
|
+
}
|
|
464
|
+
return this._sessionDriver;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Build the decoder {@link TestResponse} uses for session assertions: it feeds
|
|
469
|
+
* the response's cookies back through the app's own driver, so whatever format
|
|
470
|
+
* the driver writes is the format that gets read. Returns `undefined` when the
|
|
471
|
+
* app binds no driver, which the assertions report as "no session to read".
|
|
472
|
+
*/
|
|
473
|
+
private async _sessionDecoder(): Promise<SessionDecoder | undefined> {
|
|
474
|
+
const driver = await this._driver();
|
|
475
|
+
if (!driver || typeof driver.loadFromRequest !== "function") return undefined;
|
|
476
|
+
const cookieName = driver.cookieName;
|
|
477
|
+
|
|
478
|
+
return async (response: Response): Promise<Record<string, unknown> | null> => {
|
|
479
|
+
const pairs: string[] = [];
|
|
480
|
+
response.headers.forEach((value, name) => {
|
|
481
|
+
if (name.toLowerCase() !== "set-cookie") return;
|
|
482
|
+
const pair = value.split(";")[0]?.trim();
|
|
483
|
+
if (!pair) return;
|
|
484
|
+
// Only the session cookie matters; an unrelated `theme=dark` must not be
|
|
485
|
+
// mistaken for "a session was set" and reported as an empty session.
|
|
486
|
+
if (cookieName && !pair.startsWith(`${cookieName}=`)) return;
|
|
487
|
+
pairs.push(pair);
|
|
488
|
+
});
|
|
489
|
+
if (pairs.length === 0) return null;
|
|
490
|
+
|
|
491
|
+
const request = new Request(this.baseUrl, { headers: { Cookie: pairs.join("; ") } });
|
|
492
|
+
const payload = await driver.loadFromRequest(request);
|
|
493
|
+
return payload?.data ?? null;
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Encode the pending `actingAs()` user / `withSession()` data into a real
|
|
499
|
+
* session cookie using the app's own `session.driver`. Doing it through the
|
|
500
|
+
* driver (rather than hand-rolling crypto) guarantees the cookie matches the
|
|
501
|
+
* driver's exact format — encrypted (AES-256-GCM), signed, or otherwise.
|
|
502
|
+
* No-op when neither actingAs nor withSession has been called.
|
|
503
|
+
*/
|
|
504
|
+
private async _ensureAuthCookie(): Promise<void> {
|
|
505
|
+
if (this._authCookie !== undefined) return;
|
|
506
|
+
if (this._actingUser === undefined && this._sessionData === undefined) return;
|
|
507
|
+
|
|
508
|
+
const data: Record<string, unknown> = { ...(this._sessionData ?? {}) };
|
|
509
|
+
if (this._actingUser) data["user_id"] = this._actingUser.id;
|
|
510
|
+
|
|
511
|
+
const driver = await this._driver();
|
|
512
|
+
if (!driver) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
"[Zerotal/testing] actingAs()/withSession() need a `session.driver` binding — " +
|
|
515
|
+
"register SessionProvider in the app you pass to createTestApp().",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const res = new Response();
|
|
519
|
+
await driver.saveSession(crypto.randomUUID(), data, res);
|
|
520
|
+
const setCookie = res.headers.get("Set-Cookie");
|
|
521
|
+
// Keep just the `name=value` pair (drop attributes like Path/HttpOnly/Max-Age).
|
|
522
|
+
this._authCookie = setCookie ? (setCookie.split(";")[0] ?? undefined) : undefined;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Turn a {@link TestFileInput} into the `File` FormData wants. */
|
|
527
|
+
function _toFile(input: TestFileInput | File | Blob): File {
|
|
528
|
+
if (input instanceof File) return input;
|
|
529
|
+
if (input instanceof Blob) return new File([input], "file", { type: input.type });
|
|
530
|
+
const { content, filename = "file", type = "application/octet-stream" } = input;
|
|
531
|
+
if (content instanceof File) return content;
|
|
532
|
+
const parts = typeof content === "string" ? [content] : [content as BlobPart];
|
|
533
|
+
return new File(parts, filename, { type });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Boot the app and start it on an OS-assigned port (port 0).
|
|
538
|
+
*
|
|
539
|
+
* Routes must be registered in the optional `setup` callback (called after
|
|
540
|
+
* reset but before start) so they are compiled into the server correctly.
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* import { Application, Router } from '@zerotal/core';
|
|
544
|
+
* import { DatabaseProvider } from '@zerotal/orm';
|
|
545
|
+
*
|
|
546
|
+
* const app = await createTestApp(
|
|
547
|
+
* () => Application.create({ env: 'test' }).register([DatabaseProvider]).useConfig({ database: { url: ':memory:' } }),
|
|
548
|
+
* () => { Router.get('/ping', PingController, 'handle'); },
|
|
549
|
+
* );
|
|
550
|
+
*/
|
|
551
|
+
export async function createTestApp(
|
|
552
|
+
bootstrap: () => Application | Promise<Application>,
|
|
553
|
+
setup?: () => void,
|
|
554
|
+
): Promise<TestApp> {
|
|
555
|
+
resetTestState();
|
|
556
|
+
const app = await bootstrap();
|
|
557
|
+
// A module-cached `bootstrap/app.ts` returns its top-level app on re-import
|
|
558
|
+
// (e.g. a second test file in the same process): `Application.create()`
|
|
559
|
+
// short-circuits, so the app scope `resetTestState()` just tore down is never
|
|
560
|
+
// reinstalled. Re-adopt it so facades resolve. No-op for a fresh app.
|
|
561
|
+
app.adoptAsCurrent();
|
|
562
|
+
setup?.();
|
|
563
|
+
|
|
564
|
+
// Wrap whatever handler the app already has, so every request's exception is
|
|
565
|
+
// recoverable by the test that made it — a suite testing a custom handler still
|
|
566
|
+
// exercises that handler. Routes capture the handler by value when they compile,
|
|
567
|
+
// which is why this has to happen before start().
|
|
568
|
+
const errors = new TestExceptionHandler();
|
|
569
|
+
errors.inner = app._swapExceptionHandler(errors);
|
|
570
|
+
|
|
571
|
+
await app.start(0);
|
|
572
|
+
return new TestApp(app, errors);
|
|
573
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ExceptionHandler, type HttpContext } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The exception handler {@link TestApp} installs so a request's failure is
|
|
5
|
+
* visible to the test that made it.
|
|
6
|
+
*
|
|
7
|
+
* Without it, an exception inside a route is converted to a response and the
|
|
8
|
+
* original is gone: a test sees `500` and a rendered error page, and finding the
|
|
9
|
+
* actual bug means re-running the route by hand. This records every error it
|
|
10
|
+
* renders, so the failing assertion can quote the stack that caused it.
|
|
11
|
+
*
|
|
12
|
+
* It delegates to whatever handler the application already had, so a suite
|
|
13
|
+
* testing a custom handler still exercises that handler. In capture mode
|
|
14
|
+
* ({@link TestApp.withoutExceptionHandling}) it skips both reporting and
|
|
15
|
+
* rendering and returns a bare `500` instead — the response no longer matters
|
|
16
|
+
* once the test is reading the exception directly, and skipping `report()`
|
|
17
|
+
* keeps expected failures from writing stack traces into the test output.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export class TestExceptionHandler extends ExceptionHandler {
|
|
22
|
+
/** When true, do not report or delegate rendering — capture and return a bare 500. */
|
|
23
|
+
captureMode = false;
|
|
24
|
+
|
|
25
|
+
/** The most recent error rendered, cleared by {@link TestApp} before each request. */
|
|
26
|
+
lastError: unknown = undefined;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The handler this one displaced, if the application had registered its own.
|
|
30
|
+
* Assigned after construction because installing this handler is what reveals
|
|
31
|
+
* the previous one.
|
|
32
|
+
*/
|
|
33
|
+
inner: ExceptionHandler | undefined = undefined;
|
|
34
|
+
|
|
35
|
+
override async report(error: unknown, ctx?: HttpContext): Promise<void> {
|
|
36
|
+
this.lastError = error;
|
|
37
|
+
if (this.captureMode) return;
|
|
38
|
+
if (this.inner) return this.inner.report(error, ctx);
|
|
39
|
+
return super.report(error, ctx);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override async render(error: unknown, ctx: HttpContext): Promise<Response> {
|
|
43
|
+
this.lastError = error;
|
|
44
|
+
if (this.captureMode) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return new Response(`[Zerotal/testing] Uncaught: ${message}`, {
|
|
47
|
+
status: 500,
|
|
48
|
+
headers: { "X-Zerotal-Test-Exception": "1" },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (this.inner) return this.inner.render(error, ctx);
|
|
52
|
+
return ExceptionHandler.defaultRender(error, ctx);
|
|
53
|
+
}
|
|
54
|
+
}
|