@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/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
+ }