@onebun/core 0.2.6 → 0.2.8

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/application/application.test.ts +350 -7
  3. package/src/application/application.ts +537 -254
  4. package/src/application/multi-service-application.test.ts +15 -0
  5. package/src/application/multi-service-application.ts +2 -0
  6. package/src/application/multi-service.types.ts +7 -1
  7. package/src/decorators/decorators.ts +213 -0
  8. package/src/docs-examples.test.ts +386 -3
  9. package/src/exception-filters/exception-filters.test.ts +172 -0
  10. package/src/exception-filters/exception-filters.ts +129 -0
  11. package/src/exception-filters/http-exception.ts +22 -0
  12. package/src/exception-filters/index.ts +2 -0
  13. package/src/file/onebun-file.ts +8 -2
  14. package/src/http-guards/http-guards.test.ts +230 -0
  15. package/src/http-guards/http-guards.ts +173 -0
  16. package/src/http-guards/index.ts +1 -0
  17. package/src/index.ts +10 -0
  18. package/src/module/module.test.ts +78 -0
  19. package/src/module/module.ts +55 -7
  20. package/src/queue/docs-examples.test.ts +72 -12
  21. package/src/queue/index.ts +4 -0
  22. package/src/queue/queue-service-proxy.test.ts +82 -0
  23. package/src/queue/queue-service-proxy.ts +114 -0
  24. package/src/queue/types.ts +2 -2
  25. package/src/security/cors-middleware.ts +212 -0
  26. package/src/security/index.ts +19 -0
  27. package/src/security/rate-limit-middleware.ts +276 -0
  28. package/src/security/security-headers-middleware.ts +188 -0
  29. package/src/security/security.test.ts +285 -0
  30. package/src/testing/index.ts +1 -0
  31. package/src/testing/testing-module.test.ts +199 -0
  32. package/src/testing/testing-module.ts +252 -0
  33. package/src/types.ts +153 -3
@@ -9,6 +9,9 @@
9
9
  * - docs/api/services.md
10
10
  * - docs/api/validation.md
11
11
  * - docs/api/websocket.md
12
+ * - docs/api/guards.md
13
+ * - docs/api/exception-filters.md
14
+ * - docs/api/security.md
12
15
  * - docs/examples/basic-app.md
13
16
  * - docs/examples/crud-api.md
14
17
  * - docs/examples/websocket-chat.md
@@ -33,6 +36,7 @@ import type {
33
36
  BeforeApplicationDestroy,
34
37
  OnApplicationDestroy,
35
38
  } from './';
39
+ import type { ExceptionFilter } from './exception-filters/exception-filters';
36
40
  import type {
37
41
  SseEvent,
38
42
  SseGenerator,
@@ -41,6 +45,7 @@ import type {
41
45
  MiddlewareClass,
42
46
  OnModuleConfigure,
43
47
  } from './types';
48
+ import type { HttpExecutionContext } from './types';
44
49
  import type { ServerWebSocket } from 'bun';
45
50
 
46
51
  import {
@@ -125,6 +130,19 @@ import {
125
130
  DEFAULT_IDLE_TIMEOUT,
126
131
  DEFAULT_SSE_HEARTBEAT_MS,
127
132
  DEFAULT_SSE_TIMEOUT,
133
+ UseGuards,
134
+ AuthGuard,
135
+ RolesGuard,
136
+ createHttpGuard,
137
+ HttpExecutionContextImpl,
138
+ UseFilters,
139
+ createExceptionFilter,
140
+ defaultExceptionFilter,
141
+ HttpException,
142
+ CorsMiddleware,
143
+ RateLimitMiddleware,
144
+ MemoryRateLimitStore,
145
+ SecurityHeadersMiddleware,
128
146
  } from './';
129
147
 
130
148
 
@@ -1096,7 +1114,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
1096
1114
  }
1097
1115
  }
1098
1116
 
1099
- class CorsMiddleware extends BaseMiddleware {
1117
+ class ExampleCorsMiddleware extends BaseMiddleware {
1100
1118
  async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1101
1119
  const response = await next();
1102
1120
  response.headers.set('Access-Control-Allow-Origin', '*');
@@ -1115,7 +1133,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
1115
1133
 
1116
1134
  const app = new OneBunApplication(AppModule, {
1117
1135
  port: 0,
1118
- middleware: [RequestIdMiddleware, CorsMiddleware],
1136
+ middleware: [RequestIdMiddleware, ExampleCorsMiddleware],
1119
1137
  });
1120
1138
 
1121
1139
  expect(app).toBeDefined();
@@ -1648,7 +1666,7 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
1648
1666
 
1649
1667
  describe('Controller Lifecycle Hooks', () => {
1650
1668
  /**
1651
- * @source docs/api/controllers.md#lifecycle-hooks
1669
+ * @source docs/api/services.md#lifecycle-hooks (controllers support the same hooks)
1652
1670
  */
1653
1671
  it('should implement lifecycle hooks in controllers', () => {
1654
1672
  // From docs: Controller lifecycle hooks example
@@ -1855,6 +1873,67 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
1855
1873
  // Database initialized first, then cache saw database was ready
1856
1874
  expect(initOrder).toEqual(['database', 'cache:db-ready=true']);
1857
1875
  });
1876
+
1877
+ /**
1878
+ * @source docs/api/services.md#lifecycle-hooks
1879
+ * onModuleInit is called for services in ALL modules across the entire
1880
+ * import tree, not just the root module. Deeply nested modules are
1881
+ * initialized in depth-first order.
1882
+ */
1883
+ it('should call onModuleInit for services across the entire module import tree', async () => {
1884
+ const moduleMod = await import('./module/module');
1885
+ const testUtils = await import('./testing/test-utils');
1886
+ const effectLib = await import('effect');
1887
+
1888
+ const initLog: string[] = [];
1889
+
1890
+ @Service()
1891
+ class AuthService extends BaseService implements OnModuleInit {
1892
+ async onModuleInit(): Promise<void> {
1893
+ initLog.push('auth');
1894
+ }
1895
+ }
1896
+
1897
+ @Module({
1898
+ providers: [AuthService],
1899
+ })
1900
+ class AuthModule {}
1901
+
1902
+ @Service()
1903
+ class UserService extends BaseService implements OnModuleInit {
1904
+ async onModuleInit(): Promise<void> {
1905
+ initLog.push('user');
1906
+ }
1907
+ }
1908
+
1909
+ @Module({
1910
+ imports: [AuthModule],
1911
+ providers: [UserService],
1912
+ })
1913
+ class UserModule {}
1914
+
1915
+ @Service()
1916
+ class AppService extends BaseService implements OnModuleInit {
1917
+ async onModuleInit(): Promise<void> {
1918
+ initLog.push('app');
1919
+ }
1920
+ }
1921
+
1922
+ @Module({
1923
+ imports: [UserModule],
1924
+ providers: [AppService],
1925
+ })
1926
+ class AppModule {}
1927
+
1928
+ const mod = new moduleMod.OneBunModule(AppModule, testUtils.makeMockLoggerLayer());
1929
+ await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
1930
+
1931
+ // All three modules' services should have onModuleInit called
1932
+ expect(initLog).toContain('auth');
1933
+ expect(initLog).toContain('user');
1934
+ expect(initLog).toContain('app');
1935
+ expect(initLog.length).toBe(3);
1936
+ });
1858
1937
  });
1859
1938
  });
1860
1939
 
@@ -2406,6 +2485,34 @@ describe('OneBunApplication (docs/api/core.md)', () => {
2406
2485
  const app = new OneBunApplication(AppModule, { tracing: tracingOptions, loggerLayer: makeMockLoggerLayer() });
2407
2486
  expect(app).toBeDefined();
2408
2487
  });
2488
+
2489
+ /**
2490
+ * @source docs/api/core.md#staticapplicationoptions
2491
+ */
2492
+ it('should accept static file serving configuration (SPA on same host)', async () => {
2493
+ const fs = await import('node:fs');
2494
+ const path = await import('node:path');
2495
+ const os = await import('node:os');
2496
+
2497
+ const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'onebun-docs-static-'));
2498
+ try {
2499
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<!DOCTYPE html><html><body>SPA</body></html>', 'utf8');
2500
+
2501
+ @Module({ controllers: [] })
2502
+ class AppModule {}
2503
+
2504
+ // From docs: Static files (SPA on same host)
2505
+ const app = new OneBunApplication(AppModule, {
2506
+ loggerLayer: makeMockLoggerLayer(),
2507
+ static: { root: tmpDir, fallbackFile: 'index.html' },
2508
+ });
2509
+ expect(app).toBeDefined();
2510
+ await app.start();
2511
+ await app.stop();
2512
+ } finally {
2513
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2514
+ }
2515
+ });
2409
2516
  });
2410
2517
 
2411
2518
  describe('MultiServiceApplication (docs/api/core.md)', () => {
@@ -5322,3 +5429,279 @@ describe('File Upload API Documentation (docs/api/controllers.md)', () => {
5322
5429
  expect(FileController).toBeDefined();
5323
5430
  });
5324
5431
  });
5432
+
5433
+ // ============================================================================
5434
+ // docs/api/guards.md examples
5435
+ // ============================================================================
5436
+
5437
+ describe('docs/api/guards.md', () => {
5438
+ it('createHttpGuard — function-based guard returns a class constructor', () => {
5439
+ const apiKeyGuardClass = createHttpGuard((ctx) => {
5440
+ return ctx.getRequest().headers.get('x-api-key') !== null;
5441
+ });
5442
+
5443
+ expect(apiKeyGuardClass).toBeDefined();
5444
+ // createHttpGuard returns a constructor — instantiate to get the guard instance
5445
+ const instance = new apiKeyGuardClass();
5446
+ expect(typeof instance.canActivate).toBe('function');
5447
+ });
5448
+
5449
+ it('AuthGuard blocks request without Bearer token', async () => {
5450
+ const guard = new AuthGuard();
5451
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5452
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5453
+
5454
+ expect(await guard.canActivate(ctx)).toBe(false);
5455
+ });
5456
+
5457
+ it('AuthGuard allows request with Bearer token', async () => {
5458
+ const guard = new AuthGuard();
5459
+ const req = new Request('http://localhost/', {
5460
+ headers: { authorization: 'Bearer my-token' },
5461
+ }) as unknown as OneBunRequest;
5462
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5463
+
5464
+ expect(await guard.canActivate(ctx)).toBe(true);
5465
+ });
5466
+
5467
+ it('RolesGuard checks x-user-roles header', async () => {
5468
+ const guard = new RolesGuard(['admin']);
5469
+ const h = new Headers();
5470
+ h.set('x-user-roles', 'admin,user');
5471
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5472
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5473
+
5474
+ expect(await guard.canActivate(ctx)).toBe(true);
5475
+ });
5476
+
5477
+ it('RolesGuard rejects when role not present', async () => {
5478
+ const guard = new RolesGuard(['admin']);
5479
+ const h = new Headers();
5480
+ h.set('x-user-roles', 'user');
5481
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5482
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5483
+
5484
+ expect(await guard.canActivate(ctx)).toBe(false);
5485
+ });
5486
+
5487
+ it('RolesGuard with custom role extractor', async () => {
5488
+ const guard = new RolesGuard(
5489
+ ['admin'],
5490
+ (ctx) => {
5491
+ const raw = ctx.getRequest().headers.get('x-custom-roles');
5492
+
5493
+ return raw ? raw.split(':') : [];
5494
+ },
5495
+ );
5496
+ const h = new Headers();
5497
+ h.set('x-custom-roles', 'admin:editor');
5498
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5499
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5500
+
5501
+ expect(await guard.canActivate(ctx)).toBe(true);
5502
+ });
5503
+
5504
+ it('@UseGuards applies metadata to controller', () => {
5505
+ @UseGuards(AuthGuard)
5506
+ @Controller('/protected')
5507
+ class ProtectedController extends BaseController {
5508
+ @Get('/')
5509
+ index() {
5510
+ return { message: 'authenticated' };
5511
+ }
5512
+ }
5513
+
5514
+ expect(ProtectedController).toBeDefined();
5515
+ });
5516
+ });
5517
+
5518
+ // ============================================================================
5519
+ // docs/api/exception-filters.md examples
5520
+ // ============================================================================
5521
+
5522
+ describe('docs/api/exception-filters.md', () => {
5523
+ it('createExceptionFilter — function-based filter', async () => {
5524
+ const filter = createExceptionFilter((error, _ctx) => {
5525
+ if (error instanceof Error) {
5526
+ return new Response(JSON.stringify({ caught: error.message }), { status: 200 });
5527
+ }
5528
+ throw error;
5529
+ });
5530
+
5531
+ expect(filter).toBeDefined();
5532
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5533
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5534
+ const res = await filter.catch(new Error('boom'), ctx);
5535
+ const body = await res.json() as { caught: string };
5536
+
5537
+ expect(body.caught).toBe('boom');
5538
+ });
5539
+
5540
+ it('class-based filter — re-throws unknown errors', async () => {
5541
+ class TypedFilter implements ExceptionFilter {
5542
+ catch(error: unknown, _ctx: HttpExecutionContext): Response {
5543
+ if (error instanceof RangeError) {
5544
+ return Response.json({ success: false, error: 'range' });
5545
+ }
5546
+ throw error;
5547
+ }
5548
+ }
5549
+
5550
+ const filter = new TypedFilter();
5551
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5552
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5553
+
5554
+ const res = await filter.catch(new RangeError('out of range'), ctx);
5555
+ const body = await res.json() as { success: boolean; error: string };
5556
+
5557
+ expect(body.success).toBe(false);
5558
+ expect(body.error).toBe('range');
5559
+ });
5560
+
5561
+ it('defaultExceptionFilter handles OneBunBaseError subclass', async () => {
5562
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5563
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5564
+ const res = await defaultExceptionFilter.catch(new NotFoundError('not found'), ctx);
5565
+ const body = await res.json() as { success: boolean };
5566
+
5567
+ expect(body.success).toBe(false);
5568
+ });
5569
+
5570
+ it('async filter resolves correctly', async () => {
5571
+ const asyncFilter = createExceptionFilter(async (error, _ctx) => {
5572
+ await Promise.resolve(); // simulate async work
5573
+
5574
+ return new Response(JSON.stringify({ async: true, msg: String(error) }));
5575
+ });
5576
+
5577
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5578
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5579
+ const res = await asyncFilter.catch(new Error('err'), ctx);
5580
+ const body = await res.json() as { async: boolean };
5581
+
5582
+ expect(body.async).toBe(true);
5583
+ });
5584
+
5585
+ it('HttpException — carries statusCode', () => {
5586
+ const ex = new HttpException(400, 'Bad request');
5587
+
5588
+ expect(ex).toBeInstanceOf(Error);
5589
+ expect(ex.statusCode).toBe(400);
5590
+ expect(ex.message).toBe('Bad request');
5591
+ });
5592
+
5593
+ it('defaultExceptionFilter returns real HTTP status for HttpException', async () => {
5594
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5595
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5596
+ const res = await defaultExceptionFilter.catch(
5597
+ new HttpException(404, 'Not found'),
5598
+ ctx,
5599
+ );
5600
+
5601
+ expect(res.status).toBe(404);
5602
+ const body = await res.json() as { success: boolean; error: string };
5603
+ expect(body.success).toBe(false);
5604
+ expect(body.error).toBe('Not found');
5605
+ });
5606
+
5607
+ it('@UseFilters applies metadata to controller', () => {
5608
+ const filter = createExceptionFilter((err, _ctx) => {
5609
+ throw err;
5610
+ });
5611
+
5612
+ @UseFilters(filter)
5613
+ @Controller('/filtered')
5614
+ class FilteredController extends BaseController {
5615
+ @Get('/')
5616
+ index() {
5617
+ return {};
5618
+ }
5619
+ }
5620
+
5621
+ expect(FilteredController).toBeDefined();
5622
+ });
5623
+ });
5624
+
5625
+ // ============================================================================
5626
+ // docs/api/security.md examples
5627
+ // ============================================================================
5628
+
5629
+ describe('docs/api/security.md', () => {
5630
+ it('CorsMiddleware — default wildcard origin', async () => {
5631
+ const mw = new CorsMiddleware();
5632
+ const req = new Request('http://localhost/', {
5633
+ headers: { origin: 'https://example.com' },
5634
+ }) as unknown as OneBunRequest;
5635
+
5636
+ const res = await mw.use(req, async () => new Response('ok'));
5637
+
5638
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
5639
+ });
5640
+
5641
+ it('CorsMiddleware — preflight returns 204', async () => {
5642
+ const mw = new CorsMiddleware();
5643
+ const req = new Request('http://localhost/', {
5644
+ method: 'OPTIONS',
5645
+ headers: { origin: 'https://example.com' },
5646
+ }) as unknown as OneBunRequest;
5647
+
5648
+ const res = await mw.use(req, async () => new Response('ok'));
5649
+
5650
+ expect(res.status).toBe(204);
5651
+ });
5652
+
5653
+ it('CorsMiddleware.configure() factory', async () => {
5654
+ const configuredClass = CorsMiddleware.configure({ origin: 'https://trusted.com' });
5655
+ const mw = new configuredClass();
5656
+ const req = new Request('http://localhost/', {
5657
+ headers: { origin: 'https://trusted.com' },
5658
+ }) as unknown as OneBunRequest;
5659
+
5660
+ const res = await mw.use(req, async () => new Response('ok'));
5661
+
5662
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://trusted.com');
5663
+ });
5664
+
5665
+ it('RateLimitMiddleware — allows below max', async () => {
5666
+ const store = new MemoryRateLimitStore();
5667
+ const mw = new RateLimitMiddleware({ max: 5, windowMs: 60_000, store });
5668
+ const h1 = new Headers();
5669
+ h1.set('x-forwarded-for', '1.2.3.4');
5670
+ const req = new Request('http://localhost/', { headers: h1 }) as unknown as OneBunRequest;
5671
+
5672
+ const res = await mw.use(req, async () => new Response('ok'));
5673
+
5674
+ expect(res.status).toBe(200);
5675
+ });
5676
+
5677
+ it('RateLimitMiddleware — returns 429 when over limit', async () => {
5678
+ const store = new MemoryRateLimitStore();
5679
+ const mw = new RateLimitMiddleware({ max: 1, windowMs: 60_000, store });
5680
+ const h2 = new Headers();
5681
+ h2.set('x-forwarded-for', '9.9.9.9');
5682
+ const req = new Request('http://localhost/', { headers: h2 }) as unknown as OneBunRequest;
5683
+ await mw.use(req, async () => new Response('ok'));
5684
+ const res = await mw.use(req, async () => new Response('ok'));
5685
+
5686
+ expect(res.status).toBe(429);
5687
+ });
5688
+
5689
+ it('SecurityHeadersMiddleware — sets X-Frame-Options', async () => {
5690
+ const mw = new SecurityHeadersMiddleware();
5691
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5692
+
5693
+ const res = await mw.use(req, async () => new Response('ok'));
5694
+
5695
+ expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
5696
+ expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
5697
+ });
5698
+
5699
+ it('SecurityHeadersMiddleware — disabled header is absent', async () => {
5700
+ const mw = new SecurityHeadersMiddleware({ strictTransportSecurity: false });
5701
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5702
+
5703
+ const res = await mw.use(req, async () => new Response('ok'));
5704
+
5705
+ expect(res.headers.get('Strict-Transport-Security')).toBeNull();
5706
+ });
5707
+ });
@@ -0,0 +1,172 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from 'bun:test';
6
+
7
+ import type { OneBunRequest } from '../types';
8
+
9
+ import { NotFoundError, HttpStatusCode } from '@onebun/requests';
10
+
11
+ import { HttpExecutionContextImpl } from '../http-guards/http-guards';
12
+
13
+ import { createExceptionFilter, defaultExceptionFilter } from './exception-filters';
14
+ import { HttpException } from './http-exception';
15
+
16
+ // ============================================================================
17
+ // Helpers
18
+ // ============================================================================
19
+
20
+ function makeContext(): HttpExecutionContextImpl {
21
+ const req = new Request('http://localhost/test') as unknown as OneBunRequest;
22
+
23
+ return new HttpExecutionContextImpl(req, 'testHandler', 'TestController');
24
+ }
25
+
26
+ // ============================================================================
27
+ // HttpException
28
+ // ============================================================================
29
+
30
+ describe('HttpException', () => {
31
+ it('stores statusCode and message', () => {
32
+ const ex = new HttpException(400, 'Bad request');
33
+ expect(ex).toBeInstanceOf(Error);
34
+ expect(ex.statusCode).toBe(400);
35
+ expect(ex.message).toBe('Bad request');
36
+ expect(ex.name).toBe('HttpException');
37
+ });
38
+
39
+ it('works with instanceof check', () => {
40
+ const ex = new HttpException(404, 'Not found');
41
+ expect(ex instanceof HttpException).toBe(true);
42
+ expect(ex instanceof Error).toBe(true);
43
+ });
44
+ });
45
+
46
+ // ============================================================================
47
+ // createExceptionFilter
48
+ // ============================================================================
49
+
50
+ describe('createExceptionFilter', () => {
51
+ it('creates a filter that calls the provided function', async () => {
52
+ let caught: unknown;
53
+ const filter = createExceptionFilter((error, _ctx) => {
54
+ caught = error;
55
+
56
+ return new Response('handled', { status: 200 });
57
+ });
58
+
59
+ const err = new Error('boom');
60
+ const ctx = makeContext();
61
+ const response = await filter.catch(err, ctx);
62
+
63
+ expect(caught).toBe(err);
64
+ expect(response.status).toBe(200);
65
+ expect(await response.text()).toBe('handled');
66
+ });
67
+
68
+ it('receives the execution context', async () => {
69
+ let capturedHandler = '';
70
+ let capturedController = '';
71
+
72
+ const filter = createExceptionFilter((_error, ctx) => {
73
+ capturedHandler = ctx.getHandler();
74
+ capturedController = ctx.getController();
75
+
76
+ return new Response('ok');
77
+ });
78
+
79
+ const ctx = makeContext();
80
+ await filter.catch(new Error('test'), ctx);
81
+
82
+ expect(capturedHandler).toBe('testHandler');
83
+ expect(capturedController).toBe('TestController');
84
+ });
85
+
86
+ it('supports async filter functions', async () => {
87
+ const filter = createExceptionFilter(async () => {
88
+ await Promise.resolve();
89
+
90
+ return new Response('async', { status: 418 });
91
+ });
92
+
93
+ const response = await filter.catch(new Error('test'), makeContext());
94
+
95
+ expect(response.status).toBe(418);
96
+ });
97
+ });
98
+
99
+ // ============================================================================
100
+ // defaultExceptionFilter
101
+ // ============================================================================
102
+
103
+ describe('defaultExceptionFilter', () => {
104
+ it('returns HTTP 200 with serialised OneBunBaseError', async () => {
105
+ const error = new NotFoundError('Not found');
106
+ const response = await defaultExceptionFilter.catch(error, makeContext());
107
+
108
+ expect(response.status).toBe(HttpStatusCode.OK);
109
+ const body = await response.json() as { success: boolean };
110
+ expect(body.success).toBe(false);
111
+ });
112
+
113
+ it('returns HTTP 200 with generic error details for plain Error', async () => {
114
+ const error = new Error('Something went wrong');
115
+ const response = await defaultExceptionFilter.catch(error, makeContext());
116
+
117
+ expect(response.status).toBe(HttpStatusCode.OK);
118
+ const body = await response.json() as { success: boolean; error: string };
119
+ expect(body.success).toBe(false);
120
+ expect(body.error).toBe('Something went wrong');
121
+ });
122
+
123
+ it('returns HTTP 200 for non-Error values', async () => {
124
+ const response = await defaultExceptionFilter.catch('string error', makeContext());
125
+
126
+ expect(response.status).toBe(HttpStatusCode.OK);
127
+ const body = await response.json() as { success: boolean; error: string };
128
+ expect(body.success).toBe(false);
129
+ expect(body.error).toBe('string error');
130
+ });
131
+
132
+ it('sets Content-Type to application/json', async () => {
133
+ const response = await defaultExceptionFilter.catch(new Error('test'), makeContext());
134
+
135
+ expect(response.headers.get('content-type')).toContain('application/json');
136
+ });
137
+
138
+ it('returns actual HTTP status for HttpException', async () => {
139
+ const error = new HttpException(400, 'Validation failed');
140
+ const response = await defaultExceptionFilter.catch(error, makeContext());
141
+ expect(response.status).toBe(400);
142
+ const body = await response.json() as { success: boolean; error: string };
143
+ expect(body.success).toBe(false);
144
+ expect(body.error).toBe('Validation failed');
145
+ });
146
+
147
+ it('returns 404 for HttpException with 404 status', async () => {
148
+ const error = new HttpException(404, 'Not found');
149
+ const response = await defaultExceptionFilter.catch(error, makeContext());
150
+ expect(response.status).toBe(404);
151
+ const body = await response.json() as { success: boolean; error: string };
152
+ expect(body.success).toBe(false);
153
+ expect(body.error).toBe('Not found');
154
+ });
155
+ });
156
+
157
+ // ============================================================================
158
+ // Validation error via HttpException (bug reproduction)
159
+ // ============================================================================
160
+
161
+ describe('Validation error via HttpException (bug reproduction)', () => {
162
+ it('returns 400 with JSON body for validation HttpException', async () => {
163
+ const error = new HttpException(400, 'Parameter body validation failed: name must be a string (was missing)');
164
+ const response = await defaultExceptionFilter.catch(error, makeContext());
165
+
166
+ expect(response.status).toBe(400);
167
+ const body = await response.json() as { success: boolean; error: string; statusCode: number };
168
+ expect(body.success).toBe(false);
169
+ expect(body.error).toContain('validation failed');
170
+ expect(response.headers.get('content-type')).toContain('application/json');
171
+ });
172
+ });