@onebun/core 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/application.test.ts +52 -6
- package/src/application/application.ts +308 -220
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +357 -2
- package/src/exception-filters/exception-filters.test.ts +172 -0
- package/src/exception-filters/exception-filters.ts +129 -0
- package/src/exception-filters/http-exception.ts +22 -0
- package/src/exception-filters/index.ts +2 -0
- package/src/file/onebun-file.ts +8 -2
- package/src/http-guards/http-guards.test.ts +230 -0
- package/src/http-guards/http-guards.ts +173 -0
- package/src/http-guards/index.ts +1 -0
- package/src/index.ts +9 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +47 -7
- package/src/queue/docs-examples.test.ts +2 -2
- package/src/security/cors-middleware.ts +212 -0
- package/src/security/index.ts +19 -0
- package/src/security/rate-limit-middleware.ts +276 -0
- package/src/security/security-headers-middleware.ts +188 -0
- package/src/security/security.test.ts +285 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/testing-module.test.ts +199 -0
- package/src/testing/testing-module.ts +252 -0
- package/src/types.ts +98 -0
|
@@ -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)
|