@onebun/core 0.2.7 → 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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * TestingModule — lightweight harness for testing OneBun controllers and services
3
+ * without the full application startup overhead.
4
+ *
5
+ * Usage:
6
+ * ```typescript
7
+ * const module = await TestingModule
8
+ * .create({ controllers: [UserController], providers: [UserService] })
9
+ * .overrideProvider(UserService).useValue(mockUserService)
10
+ * .compile();
11
+ *
12
+ * const response = await module.inject('GET', '/users/1');
13
+ * expect(response.ok).toBe(true);
14
+ *
15
+ * await module.close();
16
+ * ```
17
+ */
18
+
19
+ import type { OneBunApplication } from '../application/application';
20
+ import type { HttpMethod, OneBunResponse } from '../types';
21
+ import type { Context } from 'effect';
22
+
23
+ import { Module } from '../decorators/decorators';
24
+ import { getServiceTag } from '../module/service';
25
+
26
+ import { makeMockLoggerLayer } from './test-utils';
27
+
28
+ // Re-export for convenience
29
+ export { makeMockLoggerLayer };
30
+
31
+ // ============================================================================
32
+ // Types
33
+ // ============================================================================
34
+
35
+ interface TestingModuleCreateOptions {
36
+ /** Module classes to import (already decorated with @Module) */
37
+ imports?: Function[];
38
+ /** Controller classes to include */
39
+ controllers?: Function[];
40
+ /** Service/provider classes to include */
41
+ providers?: Function[];
42
+ }
43
+
44
+ interface InjectOptions {
45
+ /** Request body (will be JSON-serialised) */
46
+ body?: unknown;
47
+ /** Extra headers */
48
+ headers?: Record<string, string>;
49
+ /** Query parameters */
50
+ query?: Record<string, string>;
51
+ }
52
+
53
+ type OverrideBuilder = {
54
+ /** Replace the service with a plain value / mock instance */
55
+ useValue(val: unknown): TestingModule;
56
+ /** Replace the service with an instance of the given class */
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ useClass(cls: new (...args: any[]) => unknown): TestingModule;
59
+ };
60
+
61
+ // ============================================================================
62
+ // CompiledTestingModule
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Result of `TestingModule.compile()`.
67
+ * Provides `inject()` for HTTP calls and `get()` for service access.
68
+ * Call `close()` when done to release the underlying server.
69
+ */
70
+ export class CompiledTestingModule {
71
+ constructor(
72
+ private readonly app: OneBunApplication,
73
+ private readonly port: number,
74
+ ) {}
75
+
76
+ /**
77
+ * Get a service instance by its class constructor.
78
+ * Useful for asserting service state after a request.
79
+ *
80
+ * @param serviceClass - The `@Service()`-decorated class
81
+ * @returns The service instance registered in the module
82
+ * @throws If the service is not found
83
+ */
84
+ get<T>(serviceClass: new (...args: unknown[]) => T): T {
85
+ return this.app.getService(serviceClass) as T;
86
+ }
87
+
88
+ /**
89
+ * Send a fake HTTP request to the testing application.
90
+ * No real network call is made — the request goes through the full
91
+ * middleware → guards → filters → handler pipeline.
92
+ *
93
+ * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
94
+ * @param path - URL path (e.g. '/users/1')
95
+ * @param options - Optional body, headers, query params
96
+ */
97
+ async inject(
98
+ method: HttpMethod | string,
99
+ path: string,
100
+ options?: InjectOptions,
101
+ ): Promise<OneBunResponse> {
102
+ let url = `http://localhost:${this.port}${path}`;
103
+
104
+ if (options?.query && Object.keys(options.query).length > 0) {
105
+ const qs = new URLSearchParams(options.query).toString();
106
+ url = `${url}?${qs}`;
107
+ }
108
+
109
+ const headers = new Headers(options?.headers);
110
+ if (!headers.has('content-type')) {
111
+ headers.set('content-type', 'application/json');
112
+ }
113
+
114
+ // Use the native undici fetch to bypass any global `fetch` mock that may be set by
115
+ // other concurrent test files. This ensures real network calls reach the test server.
116
+
117
+ const nativeFetch = (require('undici') as { fetch: typeof fetch }).fetch ?? globalThis.fetch;
118
+
119
+ return await nativeFetch(url, {
120
+ method,
121
+ headers,
122
+ body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
123
+ }) as OneBunResponse;
124
+ }
125
+
126
+ /**
127
+ * Stop the test server and release resources.
128
+ * Call this in `afterEach` / `afterAll` to prevent port leaks.
129
+ */
130
+ async close(): Promise<void> {
131
+ await this.app.stop?.({ closeSharedRedis: false });
132
+ }
133
+ }
134
+
135
+ // ============================================================================
136
+ // TestingModule
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Fluent builder for creating isolated test environments for OneBun modules.
141
+ *
142
+ * @example Basic usage
143
+ * ```typescript
144
+ * describe('UserController', () => {
145
+ * let module: CompiledTestingModule;
146
+ *
147
+ * beforeEach(async () => {
148
+ * module = await TestingModule
149
+ * .create({ controllers: [UserController], providers: [UserService] })
150
+ * .compile();
151
+ * });
152
+ *
153
+ * afterEach(() => module.close());
154
+ *
155
+ * it('returns 200 for GET /users', async () => {
156
+ * const res = await module.inject('GET', '/users');
157
+ * expect(res.ok).toBe(true);
158
+ * });
159
+ * });
160
+ * ```
161
+ *
162
+ * @example With provider overrides
163
+ * ```typescript
164
+ * const mockService = { getUser: () => ({ id: 1, name: 'Test' }) };
165
+ *
166
+ * module = await TestingModule
167
+ * .create({ controllers: [UserController], providers: [UserService] })
168
+ * .overrideProvider(UserService).useValue(mockService)
169
+ * .compile();
170
+ * ```
171
+ */
172
+ export class TestingModule {
173
+ private readonly options: TestingModuleCreateOptions;
174
+ private readonly overrides: Array<{
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ tag: Context.Tag<any, any>;
177
+ value: unknown;
178
+ }> = [];
179
+
180
+ private constructor(options: TestingModuleCreateOptions) {
181
+ this.options = options;
182
+ }
183
+
184
+ /**
185
+ * Create a new TestingModule builder.
186
+ *
187
+ * @param options - Module options (controllers, providers, imports)
188
+ */
189
+ static create(options: TestingModuleCreateOptions): TestingModule {
190
+ return new TestingModule(options);
191
+ }
192
+
193
+ /**
194
+ * Override a provider with a mock value or class.
195
+ * Overrides are applied before `setup()` so controllers receive mocks at construction time.
196
+ *
197
+ * @param serviceClass - The `@Service()`-decorated class to override
198
+ */
199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
+ overrideProvider<T>(serviceClass: new (...args: any[]) => T): OverrideBuilder {
201
+ return {
202
+ useValue: (val: unknown): TestingModule => {
203
+ this.overrides.push({ tag: getServiceTag(serviceClass), value: val });
204
+
205
+ return this;
206
+ },
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ useClass: (cls: new (...args: any[]) => unknown): TestingModule => {
209
+ this.overrides.push({ tag: getServiceTag(serviceClass), value: new cls() });
210
+
211
+ return this;
212
+ },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Compile the testing module and start the test server.
218
+ * Returns a `CompiledTestingModule` with `inject()` and `get()` methods.
219
+ *
220
+ * @returns Compiled module ready for testing
221
+ */
222
+ async compile(): Promise<CompiledTestingModule> {
223
+ // Lazily import to avoid circular dependencies at module parse time
224
+ // eslint-disable-next-line @typescript-eslint/naming-convention
225
+ const { OneBunApplication } = await import('../application/application');
226
+
227
+ // Build a synthetic module class from the provided options
228
+ const { controllers = [], providers = [], imports = [] } = this.options;
229
+
230
+ class _TestingAppModule {}
231
+ Module({
232
+ controllers,
233
+ providers: providers as unknown[],
234
+ imports,
235
+ })(_TestingAppModule);
236
+
237
+ // Create the application with:
238
+ // - port 0 → OS picks a free port
239
+ // - silent logger
240
+ // - test provider overrides injected before setup()
241
+ const app = new OneBunApplication(_TestingAppModule, {
242
+ port: 0,
243
+ loggerLayer: makeMockLoggerLayer() as import('effect').Layer.Layer<import('@onebun/logger').Logger>,
244
+ gracefulShutdown: false,
245
+ _testProviders: this.overrides,
246
+ });
247
+
248
+ await app.start();
249
+
250
+ return new CompiledTestingModule(app, app.getPort());
251
+ }
252
+ }
package/src/types.ts CHANGED
@@ -471,6 +471,71 @@ export interface ApplicationOptions {
471
471
  * @defaultValue true
472
472
  */
473
473
  gracefulShutdown?: boolean;
474
+
475
+ /**
476
+ * Global exception filters applied to all routes.
477
+ * Route-level and controller-level filters take priority over global ones.
478
+ * If no filters match, the built-in default filter is used.
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * const app = new OneBunApplication(AppModule, {
483
+ * filters: [new GlobalExceptionFilter()],
484
+ * });
485
+ * ```
486
+ */
487
+ filters?: import('./exception-filters/exception-filters').ExceptionFilter[];
488
+
489
+ /**
490
+ * CORS configuration. When provided, `CorsMiddleware` is automatically prepended
491
+ * to the global middleware chain.
492
+ *
493
+ * @example
494
+ * ```typescript
495
+ * const app = new OneBunApplication(AppModule, {
496
+ * cors: { origin: 'https://my-frontend.example.com', credentials: true },
497
+ * });
498
+ * ```
499
+ */
500
+ cors?: import('./security/cors-middleware').CorsOptions | true;
501
+
502
+ /**
503
+ * Rate limiting configuration. When provided, `RateLimitMiddleware` is automatically
504
+ * prepended to the global middleware chain (after CORS, before other middleware).
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const app = new OneBunApplication(AppModule, {
509
+ * rateLimit: { windowMs: 60_000, max: 100 },
510
+ * });
511
+ * ```
512
+ */
513
+ rateLimit?: import('./security/rate-limit-middleware').RateLimitOptions | true;
514
+
515
+ /**
516
+ * Security headers configuration. When provided, `SecurityHeadersMiddleware` is
517
+ * automatically appended to the global middleware chain.
518
+ * Pass `true` to use all defaults (equivalent to no options).
519
+ *
520
+ * @example
521
+ * ```typescript
522
+ * const app = new OneBunApplication(AppModule, {
523
+ * security: { strictTransportSecurity: false }, // disable HSTS for local dev
524
+ * });
525
+ * ```
526
+ */
527
+ security?: import('./security/security-headers-middleware').SecurityHeadersOptions | true;
528
+
529
+ /**
530
+ * Pre-register service instances for testing.
531
+ * These are injected before `setup()` so controllers receive mocks at construction time.
532
+ * @internal Use `TestingModule` instead of setting this directly.
533
+ */
534
+ _testProviders?: Array<{
535
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
536
+ tag: Context.Tag<any, any>;
537
+ value: unknown;
538
+ }>;
474
539
  }
475
540
 
476
541
  /**
@@ -764,6 +829,35 @@ export interface RouteOptions {
764
829
  timeout?: number;
765
830
  }
766
831
 
832
+ /**
833
+ * HTTP Execution Context provided to HTTP guards.
834
+ * Gives read-only access to the incoming request and routing information.
835
+ */
836
+ export interface HttpExecutionContext {
837
+ /** Returns the incoming request object */
838
+ getRequest(): OneBunRequest;
839
+ /** Returns the handler method name (e.g. 'getUser') */
840
+ getHandler(): string;
841
+ /** Returns the controller class name (e.g. 'UserController') */
842
+ getController(): string;
843
+ }
844
+
845
+ /**
846
+ * HTTP Guard interface — implement to protect routes via `@UseGuards()`.
847
+ *
848
+ * @example
849
+ * ```typescript
850
+ * class MyGuard implements HttpGuard {
851
+ * canActivate(ctx: HttpExecutionContext): boolean {
852
+ * return ctx.getRequest().headers.get('x-api-key') === 'secret';
853
+ * }
854
+ * }
855
+ * ```
856
+ */
857
+ export interface HttpGuard {
858
+ canActivate(context: HttpExecutionContext): boolean | Promise<boolean>;
859
+ }
860
+
767
861
  /**
768
862
  * Route metadata
769
863
  */
@@ -773,6 +867,10 @@ export interface RouteMetadata {
773
867
  handler: string;
774
868
  params?: ParamMetadata[];
775
869
  middleware?: Function[];
870
+ /** Guards to execute before the route handler. Supports class constructors and instances. */
871
+ guards?: (Function | HttpGuard)[];
872
+ /** Exception filters to apply when the route handler throws. */
873
+ filters?: import('./exception-filters/exception-filters').ExceptionFilter[];
776
874
  /**
777
875
  * Response schemas for validation
778
876
  * Key is HTTP status code (e.g., 200, 201, 404)