@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
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Exception Filters
3
+ *
4
+ * Intercept and transform errors thrown by route handlers.
5
+ * Apply with `@UseFilters()` on controllers or individual routes,
6
+ * or globally via `ApplicationOptions.filters`.
7
+ */
8
+
9
+ import type { HttpExecutionContext, OneBunResponse } from '../types';
10
+
11
+ import {
12
+ createErrorResponse,
13
+ HttpStatusCode,
14
+ OneBunBaseError,
15
+ } from '@onebun/requests';
16
+
17
+ import { HttpException } from './http-exception';
18
+
19
+ // ============================================================================
20
+ // Interfaces
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Exception Filter interface — implement to handle errors thrown by route handlers.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * class HttpExceptionFilter implements ExceptionFilter {
29
+ * catch(error: unknown, ctx: HttpExecutionContext): Response {
30
+ * const status = error instanceof OneBunBaseError ? error.code : 500;
31
+ * return new Response(JSON.stringify({ message: String(error) }), {
32
+ * status,
33
+ * headers: { 'Content-Type': 'application/json' },
34
+ * });
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ export interface ExceptionFilter {
40
+ catch(error: unknown, context: HttpExecutionContext): OneBunResponse | Promise<OneBunResponse>;
41
+ }
42
+
43
+ // ============================================================================
44
+ // Factory
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Create a custom exception filter from a plain function.
49
+ *
50
+ * @param fn - Filter function receiving the error and execution context
51
+ * @returns An ExceptionFilter instance
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const logAndForwardFilter = createExceptionFilter((error, ctx) => {
56
+ * console.error(`[${ctx.getController()}#${ctx.getHandler()}]`, error);
57
+ * return new Response('Internal Error', { status: 500 });
58
+ * });
59
+ *
60
+ * @UseFilters(logAndForwardFilter)
61
+ * @Get('/risky')
62
+ * riskyRoute() { ... }
63
+ * ```
64
+ */
65
+ export function createExceptionFilter(
66
+ fn: (error: unknown, context: HttpExecutionContext) => OneBunResponse | Promise<OneBunResponse>,
67
+ ): ExceptionFilter {
68
+ return { catch: fn };
69
+ }
70
+
71
+ // ============================================================================
72
+ // Default filter (wraps existing error-handling logic)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Default exception filter — mirrors the built-in error handling behaviour.
77
+ * - `OneBunBaseError` instances are serialised with their own `toErrorResponse()`.
78
+ * - Every other error is converted to a generic 500 response.
79
+ * All responses use HTTP 200 with the standardised `ApiResponse` envelope,
80
+ * consistent with the rest of the framework.
81
+ */
82
+ export const defaultExceptionFilter: ExceptionFilter = {
83
+ catch(error: unknown): OneBunResponse {
84
+ if (error instanceof HttpException) {
85
+ const errorResponse = createErrorResponse(
86
+ error.message,
87
+ error.statusCode,
88
+ error.message,
89
+ );
90
+
91
+ return new Response(JSON.stringify(errorResponse), {
92
+ status: error.statusCode,
93
+ headers: {
94
+ // eslint-disable-next-line @typescript-eslint/naming-convention
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ });
98
+ }
99
+
100
+ if (error instanceof OneBunBaseError) {
101
+ return new Response(JSON.stringify(error.toErrorResponse()), {
102
+ status: HttpStatusCode.OK,
103
+ headers: {
104
+ // eslint-disable-next-line @typescript-eslint/naming-convention
105
+ 'Content-Type': 'application/json',
106
+ },
107
+ });
108
+ }
109
+
110
+ const message = error instanceof Error ? error.message : String(error);
111
+ const code =
112
+ error instanceof Error && 'code' in error
113
+ ? Number((error as { code: unknown }).code)
114
+ : HttpStatusCode.INTERNAL_SERVER_ERROR;
115
+
116
+ const errorResponse = createErrorResponse(message, code, message, undefined, {
117
+ originalErrorName: error instanceof Error ? error.name : 'UnknownError',
118
+ stack: error instanceof Error ? error.stack : undefined,
119
+ });
120
+
121
+ return new Response(JSON.stringify(errorResponse), {
122
+ status: HttpStatusCode.OK,
123
+ headers: {
124
+ // eslint-disable-next-line @typescript-eslint/naming-convention
125
+ 'Content-Type': 'application/json',
126
+ },
127
+ });
128
+ },
129
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * HTTP exception that carries a status code.
3
+ *
4
+ * Throw from route handlers, guards, or middleware to return
5
+ * a specific HTTP status. The default exception filter converts
6
+ * these into JSON responses with the matching status code.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * throw new HttpException(404, 'User not found');
11
+ * // → HTTP 404 { success: false, error: "User not found", ... }
12
+ * ```
13
+ */
14
+ export class HttpException extends Error {
15
+ constructor(
16
+ public readonly statusCode: number,
17
+ message: string,
18
+ ) {
19
+ super(message);
20
+ this.name = 'HttpException';
21
+ }
22
+ }
@@ -0,0 +1,2 @@
1
+ export * from './exception-filters';
2
+ export * from './http-exception';
@@ -1,3 +1,7 @@
1
+ import { HttpStatusCode } from '@onebun/requests';
2
+
3
+ import { HttpException } from '../exception-filters/http-exception';
4
+
1
5
  /**
2
6
  * OneBunFile - Unified file wrapper for file uploads
3
7
  *
@@ -287,7 +291,8 @@ export function validateFile(
287
291
 
288
292
  // Validate file size
289
293
  if (options.maxSize !== undefined && file.size > options.maxSize) {
290
- throw new Error(
294
+ throw new HttpException(
295
+ HttpStatusCode.BAD_REQUEST,
291
296
  `${prefix} exceeds maximum size. Got ${file.size} bytes, max is ${options.maxSize} bytes`,
292
297
  );
293
298
  }
@@ -296,7 +301,8 @@ export function validateFile(
296
301
  if (options.mimeTypes && options.mimeTypes.length > 0) {
297
302
  const matches = options.mimeTypes.some((pattern) => matchMimeType(file.type, pattern));
298
303
  if (!matches) {
299
- throw new Error(
304
+ throw new HttpException(
305
+ HttpStatusCode.BAD_REQUEST,
300
306
  `${prefix} has invalid MIME type "${file.type}". Allowed: ${options.mimeTypes.join(', ')}`,
301
307
  );
302
308
  }
@@ -0,0 +1,230 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from 'bun:test';
6
+
7
+ import type {
8
+ HttpExecutionContext,
9
+ HttpGuard,
10
+ OneBunRequest,
11
+ } from '../types';
12
+
13
+ import {
14
+ AuthGuard,
15
+ createHttpGuard,
16
+ executeHttpGuards,
17
+ HttpExecutionContextImpl,
18
+ RolesGuard,
19
+ } from './http-guards';
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function makeRequest(headers?: Headers): OneBunRequest {
26
+ return new Request('http://localhost/test', { headers }) as unknown as OneBunRequest;
27
+ }
28
+
29
+ function makeHeaders(entries: [string, string][]): Headers {
30
+ const h = new Headers();
31
+ for (const [key, value] of entries) {
32
+ h.set(key, value);
33
+ }
34
+
35
+ return h;
36
+ }
37
+
38
+ function makeContext(
39
+ headers: [string, string][] = [],
40
+ handler = 'testHandler',
41
+ controller = 'TestController',
42
+ ): HttpExecutionContext {
43
+ return new HttpExecutionContextImpl(makeRequest(makeHeaders(headers)), handler, controller);
44
+ }
45
+
46
+ // ============================================================================
47
+ // HttpExecutionContextImpl
48
+ // ============================================================================
49
+
50
+ describe('HttpExecutionContextImpl', () => {
51
+ it('returns request from getRequest()', () => {
52
+ const req = makeRequest(makeHeaders([['authorization', 'Bearer token']]));
53
+ const ctx = new HttpExecutionContextImpl(req, 'myHandler', 'MyController');
54
+
55
+ expect(ctx.getRequest()).toBe(req);
56
+ });
57
+
58
+ it('returns handler name from getHandler()', () => {
59
+ const ctx = makeContext([], 'getUser', 'UserController');
60
+
61
+ expect(ctx.getHandler()).toBe('getUser');
62
+ });
63
+
64
+ it('returns controller name from getController()', () => {
65
+ const ctx = makeContext([], 'getUser', 'UserController');
66
+
67
+ expect(ctx.getController()).toBe('UserController');
68
+ });
69
+ });
70
+
71
+ // ============================================================================
72
+ // executeHttpGuards
73
+ // ============================================================================
74
+
75
+ describe('executeHttpGuards', () => {
76
+ it('returns true when there are no guards', async () => {
77
+ const ctx = makeContext();
78
+
79
+ expect(await executeHttpGuards([], ctx)).toBe(true);
80
+ });
81
+
82
+ it('returns true when all guards pass', async () => {
83
+ const passGuard = createHttpGuard(() => true);
84
+ const ctx = makeContext();
85
+
86
+ expect(await executeHttpGuards([passGuard, passGuard], ctx)).toBe(true);
87
+ });
88
+
89
+ it('returns false when any guard fails', async () => {
90
+ const passGuard = createHttpGuard(() => true);
91
+ const failGuard = createHttpGuard(() => false);
92
+ const ctx = makeContext();
93
+
94
+ expect(await executeHttpGuards([passGuard, failGuard, passGuard], ctx)).toBe(false);
95
+ });
96
+
97
+ it('short-circuits on first failing guard', async () => {
98
+ let secondCalled = false;
99
+
100
+ const failGuard = createHttpGuard(() => false);
101
+ const trackGuard = createHttpGuard(() => {
102
+ secondCalled = true;
103
+
104
+ return true;
105
+ });
106
+ const ctx = makeContext();
107
+
108
+ await executeHttpGuards([failGuard, trackGuard], ctx);
109
+
110
+ expect(secondCalled).toBe(false);
111
+ });
112
+
113
+ it('accepts guard instances (not just class constructors)', async () => {
114
+ const instance: HttpGuard = { canActivate: () => true };
115
+ const ctx = makeContext();
116
+
117
+ expect(await executeHttpGuards([instance], ctx)).toBe(true);
118
+ });
119
+
120
+ it('accepts async guards', async () => {
121
+ const asyncPassGuard = createHttpGuard(async () => {
122
+ await Promise.resolve();
123
+
124
+ return true;
125
+ });
126
+ const ctx = makeContext();
127
+
128
+ expect(await executeHttpGuards([asyncPassGuard], ctx)).toBe(true);
129
+ });
130
+ });
131
+
132
+ // ============================================================================
133
+ // createHttpGuard
134
+ // ============================================================================
135
+
136
+ describe('createHttpGuard', () => {
137
+ it('returns a class constructor', () => {
138
+ const guardClass = createHttpGuard(() => true);
139
+
140
+ expect(typeof guardClass).toBe('function');
141
+ });
142
+
143
+ it('instantiated class calls the provided function', async () => {
144
+ let called = false;
145
+ const guardClass = createHttpGuard((ctx) => {
146
+ called = true;
147
+
148
+ return ctx.getHandler() === 'target';
149
+ });
150
+ const ctx = makeContext([], 'target');
151
+ const instance = new guardClass();
152
+
153
+ expect(await instance.canActivate(ctx)).toBe(true);
154
+ expect(called).toBe(true);
155
+ });
156
+ });
157
+
158
+ // ============================================================================
159
+ // AuthGuard
160
+ // ============================================================================
161
+
162
+ describe('AuthGuard', () => {
163
+ it('allows request with Bearer token', () => {
164
+ const guard = new AuthGuard();
165
+ const ctx = makeContext([['authorization', 'Bearer my-token']]);
166
+
167
+ expect(guard.canActivate(ctx)).toBe(true);
168
+ });
169
+
170
+ it('blocks request without Authorization header', () => {
171
+ const guard = new AuthGuard();
172
+ const ctx = makeContext();
173
+
174
+ expect(guard.canActivate(ctx)).toBe(false);
175
+ });
176
+
177
+ it('blocks request with non-Bearer Authorization header', () => {
178
+ const guard = new AuthGuard();
179
+ const ctx = makeContext([['authorization', 'Basic dXNlcjpwYXNz']]);
180
+
181
+ expect(guard.canActivate(ctx)).toBe(false);
182
+ });
183
+ });
184
+
185
+ // ============================================================================
186
+ // RolesGuard
187
+ // ============================================================================
188
+
189
+ describe('RolesGuard', () => {
190
+ it('allows when user has all required roles (default extractor)', () => {
191
+ const guard = new RolesGuard(['admin', 'editor']);
192
+ const headers = makeHeaders([['x-user-roles', 'admin, editor, viewer']]);
193
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
194
+
195
+ expect(guard.canActivate(ctx)).toBe(true);
196
+ });
197
+
198
+ it('blocks when user is missing a required role', () => {
199
+ const guard = new RolesGuard(['admin']);
200
+ const headers = makeHeaders([['x-user-roles', 'viewer']]);
201
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
202
+
203
+ expect(guard.canActivate(ctx)).toBe(false);
204
+ });
205
+
206
+ it('blocks when x-user-roles header is absent', () => {
207
+ const guard = new RolesGuard(['admin']);
208
+ const ctx = makeContext();
209
+
210
+ expect(guard.canActivate(ctx)).toBe(false);
211
+ });
212
+
213
+ it('uses custom roles extractor when provided', () => {
214
+ const headers = makeHeaders([['x-roles', 'admin|user']]);
215
+ const guard = new RolesGuard(['admin'], (ctx) =>
216
+ ctx.getRequest().headers.get('x-roles')?.split('|') ?? [],
217
+ );
218
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
219
+
220
+ expect(guard.canActivate(ctx)).toBe(true);
221
+ });
222
+
223
+ it('requires ALL roles to be present (not just one)', () => {
224
+ const guard = new RolesGuard(['admin', 'superuser']);
225
+ const headers = makeHeaders([['x-user-roles', 'admin']]);
226
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
227
+
228
+ expect(guard.canActivate(ctx)).toBe(false);
229
+ });
230
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * HTTP Guards
3
+ *
4
+ * Guards for authorizing HTTP requests before they reach the route handler.
5
+ * Apply with `@UseGuards()` on controllers or individual routes.
6
+ */
7
+
8
+ import type {
9
+ HttpExecutionContext,
10
+ HttpGuard,
11
+ OneBunRequest,
12
+ } from '../types';
13
+
14
+ // ============================================================================
15
+ // Execution Context Implementation
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Implementation of HttpExecutionContext
20
+ */
21
+ export class HttpExecutionContextImpl implements HttpExecutionContext {
22
+ constructor(
23
+ private readonly request: OneBunRequest,
24
+ private readonly handlerName: string,
25
+ private readonly controllerName: string,
26
+ ) {}
27
+
28
+ getRequest(): OneBunRequest {
29
+ return this.request;
30
+ }
31
+
32
+ getHandler(): string {
33
+ return this.handlerName;
34
+ }
35
+
36
+ getController(): string {
37
+ return this.controllerName;
38
+ }
39
+ }
40
+
41
+ // ============================================================================
42
+ // Guard Execution Helper
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Execute a list of HTTP guards sequentially.
47
+ * Returns false as soon as any guard denies access (short-circuit).
48
+ *
49
+ * @param guards - Array of guard class constructors or instances
50
+ * @param context - Execution context for this request
51
+ * @returns Whether all guards passed
52
+ */
53
+ export async function executeHttpGuards(
54
+ guards: (Function | HttpGuard)[],
55
+ context: HttpExecutionContext,
56
+ ): Promise<boolean> {
57
+ for (const guard of guards) {
58
+ let guardInstance: HttpGuard;
59
+
60
+ if (typeof guard === 'function') {
61
+ guardInstance = new (guard as new () => HttpGuard)();
62
+ } else {
63
+ guardInstance = guard;
64
+ }
65
+
66
+ const result = await guardInstance.canActivate(context);
67
+ if (!result) {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Create a custom HTTP guard from a plain function.
77
+ * Returns a class constructor compatible with `@UseGuards()`.
78
+ *
79
+ * @param fn - Guard function receiving the execution context
80
+ * @returns Guard class constructor
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * const ApiKeyGuard = createHttpGuard((ctx) => {
85
+ * return ctx.getRequest().headers.get('x-api-key') === process.env.API_KEY;
86
+ * });
87
+ *
88
+ * @UseGuards(ApiKeyGuard)
89
+ * @Get('/protected')
90
+ * getData() { ... }
91
+ * ```
92
+ */
93
+ export function createHttpGuard(
94
+ fn: (context: HttpExecutionContext) => boolean | Promise<boolean>,
95
+ ): new () => HttpGuard {
96
+ return class implements HttpGuard {
97
+ canActivate(context: HttpExecutionContext): boolean | Promise<boolean> {
98
+ return fn(context);
99
+ }
100
+ };
101
+ }
102
+
103
+ // ============================================================================
104
+ // Built-in Guards
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Guard that requires a valid Bearer token in the Authorization header.
109
+ * Does NOT validate the token — only checks that the header is present.
110
+ * Combine with a custom middleware or guard to validate the token itself.
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * @UseGuards(AuthGuard)
115
+ * @Get('/profile')
116
+ * getProfile() { ... }
117
+ * ```
118
+ */
119
+ export class AuthGuard implements HttpGuard {
120
+ canActivate(context: HttpExecutionContext): boolean {
121
+ const auth = context.getRequest().headers.get('authorization');
122
+
123
+ return auth !== null && auth.startsWith('Bearer ');
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Default roles extractor — reads comma-separated roles from the `x-user-roles` header.
129
+ * Set this header from your auth middleware after validating the token.
130
+ */
131
+ function defaultRolesExtractor(ctx: HttpExecutionContext): string[] {
132
+ const rolesHeader = ctx.getRequest().headers.get('x-user-roles');
133
+
134
+ return rolesHeader ? rolesHeader.split(',').map((r) => r.trim()) : [];
135
+ }
136
+
137
+ /**
138
+ * Guard that requires all specified roles to be present on the request.
139
+ * By default reads roles from the `x-user-roles` header (comma-separated).
140
+ * Provide a custom `rolesExtractor` to read roles from a different source
141
+ * (e.g. a JWT claim decoded by a preceding auth middleware).
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * // All specified roles must be present
146
+ * @UseGuards(new RolesGuard(['admin', 'moderator']))
147
+ * @Delete('/users/:id')
148
+ * deleteUser() { ... }
149
+ *
150
+ * // Custom roles extractor
151
+ * @UseGuards(new RolesGuard(['admin'], (ctx) => ctx.getRequest().headers.get('x-roles')?.split('|') ?? []))
152
+ * @Get('/admin')
153
+ * adminPanel() { ... }
154
+ * ```
155
+ */
156
+ export class RolesGuard implements HttpGuard {
157
+ private readonly roles: string[];
158
+ private readonly rolesExtractor: (ctx: HttpExecutionContext) => string[];
159
+
160
+ constructor(
161
+ roles: string[],
162
+ rolesExtractor: (ctx: HttpExecutionContext) => string[] = defaultRolesExtractor,
163
+ ) {
164
+ this.roles = roles;
165
+ this.rolesExtractor = rolesExtractor;
166
+ }
167
+
168
+ canActivate(context: HttpExecutionContext): boolean {
169
+ const userRoles = this.rolesExtractor(context);
170
+
171
+ return this.roles.every((role) => userRoles.includes(role));
172
+ }
173
+ }
@@ -0,0 +1 @@
1
+ export * from './http-guards';
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  type WsStorageType,
49
49
  type WsStorageOptions,
50
50
  type WebSocketApplicationOptions,
51
+ type StaticApplicationOptions,
51
52
  // Docs types
52
53
  type DocsApplicationOptions,
53
54
  // SSE types
@@ -131,3 +132,12 @@ export * from './validation';
131
132
 
132
133
  // Testing Utilities
133
134
  export * from './testing';
135
+
136
+ // HTTP Guards
137
+ export * from './http-guards';
138
+
139
+ // Exception Filters
140
+ export * from './exception-filters';
141
+
142
+ // Security Middleware
143
+ export * from './security';
@@ -1422,6 +1422,84 @@ describe('OneBunModule', () => {
1422
1422
  expect(depValueInInit).not.toBeNull();
1423
1423
  expect(depValueInInit as unknown as number).toBe(8080);
1424
1424
  });
1425
+
1426
+ test('should call onModuleInit for services and controllers in deeply nested module tree', async () => {
1427
+ const initLog: string[] = [];
1428
+
1429
+ @Service()
1430
+ class GrandchildService {
1431
+ async onModuleInit(): Promise<void> {
1432
+ initLog.push('grandchild-service');
1433
+ }
1434
+ }
1435
+
1436
+ @CtrlDeco('/grandchild')
1437
+ class GrandchildController extends CtrlBase {
1438
+ async onModuleInit(): Promise<void> {
1439
+ initLog.push('grandchild-controller');
1440
+ }
1441
+ }
1442
+
1443
+ @Module({
1444
+ providers: [GrandchildService],
1445
+ controllers: [GrandchildController],
1446
+ })
1447
+ class GrandchildModule {}
1448
+
1449
+ @Service()
1450
+ class ChildService {
1451
+ async onModuleInit(): Promise<void> {
1452
+ initLog.push('child-service');
1453
+ }
1454
+ }
1455
+
1456
+ @CtrlDeco('/child')
1457
+ class ChildController extends CtrlBase {
1458
+ async onModuleInit(): Promise<void> {
1459
+ initLog.push('child-controller');
1460
+ }
1461
+ }
1462
+
1463
+ @Module({
1464
+ imports: [GrandchildModule],
1465
+ providers: [ChildService],
1466
+ controllers: [ChildController],
1467
+ })
1468
+ class ChildModule {}
1469
+
1470
+ @Service()
1471
+ class RootService {
1472
+ async onModuleInit(): Promise<void> {
1473
+ initLog.push('root-service');
1474
+ }
1475
+ }
1476
+
1477
+ @CtrlDeco('/root')
1478
+ class RootController extends CtrlBase {
1479
+ async onModuleInit(): Promise<void> {
1480
+ initLog.push('root-controller');
1481
+ }
1482
+ }
1483
+
1484
+ @Module({
1485
+ imports: [ChildModule],
1486
+ providers: [RootService],
1487
+ controllers: [RootController],
1488
+ })
1489
+ class RootModule {}
1490
+
1491
+ const module = new ModuleClass(RootModule, mockLoggerLayer);
1492
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1493
+
1494
+ // All services and controllers across all levels must have onModuleInit called
1495
+ expect(initLog).toContain('grandchild-service');
1496
+ expect(initLog).toContain('grandchild-controller');
1497
+ expect(initLog).toContain('child-service');
1498
+ expect(initLog).toContain('child-controller');
1499
+ expect(initLog).toContain('root-service');
1500
+ expect(initLog).toContain('root-controller');
1501
+ expect(initLog.length).toBe(6);
1502
+ });
1425
1503
  });
1426
1504
 
1427
1505
  describe('Module DI scoping (exports only for cross-module)', () => {