@onebun/core 0.2.9 → 0.2.11

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,280 @@
1
+ /**
2
+ * Documentation Examples Tests for @onebun/core testing utilities
3
+ *
4
+ * This file tests code examples from:
5
+ * - docs/testing.md
6
+ */
7
+
8
+ import {
9
+ describe,
10
+ expect,
11
+ it,
12
+ } from 'bun:test';
13
+
14
+ import type { CompiledTestingModule } from './testing-module';
15
+
16
+ import {
17
+ Controller,
18
+ Get,
19
+ Param,
20
+ } from '../decorators/decorators';
21
+ import { Controller as BaseController } from '../module/controller';
22
+ import { BaseService, Service } from '../module/service';
23
+
24
+ import { createTestController, createTestService } from './service-helpers';
25
+ import {
26
+ createMockConfig,
27
+ createMockLogger,
28
+ useFakeTimers,
29
+ } from './test-utils';
30
+ import { TestingModule } from './testing-module';
31
+
32
+ // ============================================================================
33
+ // Test fixtures
34
+ // ============================================================================
35
+
36
+ @Service()
37
+ class UserService extends BaseService {
38
+ findById(id: string): { id: string; name: string } {
39
+ return { id, name: `User ${id}` };
40
+ }
41
+ }
42
+
43
+ @Service()
44
+ class ServiceWithConfig extends BaseService {
45
+ getDbUrl(): unknown {
46
+ return this.config.get('database.url');
47
+ }
48
+ }
49
+
50
+ @Service()
51
+ class ServiceWithDeps extends BaseService {
52
+ constructor(private readonly repo: { find: () => string }) {
53
+ super();
54
+ }
55
+
56
+ getData(): string {
57
+ return this.repo.find();
58
+ }
59
+ }
60
+
61
+ @Controller('/users')
62
+ class UserController extends BaseController {
63
+ constructor(private readonly userService: UserService) {
64
+ super();
65
+ }
66
+
67
+ @Get('/:id')
68
+ getUser(@Param('id') id: string) {
69
+ return this.userService.findById(id);
70
+ }
71
+ }
72
+
73
+ // ============================================================================
74
+ // createTestService — docs/testing.md
75
+ // ============================================================================
76
+
77
+ describe('docs/testing.md — createTestService', () => {
78
+ /**
79
+ * @source docs/testing.md#createtestservice
80
+ */
81
+ it('basic usage — creates service with mock logger and config', () => {
82
+ const { instance, logger, config } = createTestService(UserService);
83
+
84
+ const result = instance.findById('123');
85
+
86
+ expect(result).toEqual({ id: '123', name: 'User 123' });
87
+ expect(logger).toBeDefined();
88
+ expect(config).toBeDefined();
89
+ expect(config.isInitialized).toBe(true);
90
+ });
91
+
92
+ /**
93
+ * @source docs/testing.md#with-config-and-dependencies
94
+ */
95
+ it('with config — config.get returns provided values', () => {
96
+ const { instance } = createTestService(ServiceWithConfig, {
97
+ /* eslint-disable @typescript-eslint/naming-convention */
98
+ config: { 'database.url': 'postgres://localhost/test' },
99
+ /* eslint-enable @typescript-eslint/naming-convention */
100
+ });
101
+
102
+ expect(instance.getDbUrl()).toBe('postgres://localhost/test');
103
+ });
104
+
105
+ /**
106
+ * @source docs/testing.md#with-config-and-dependencies
107
+ */
108
+ it('with deps — passes constructor dependencies', () => {
109
+ const mockRepo = { find: () => 'mock-data' };
110
+ const { instance } = createTestService(ServiceWithDeps, {
111
+ deps: [mockRepo],
112
+ });
113
+
114
+ expect(instance.getData()).toBe('mock-data');
115
+ });
116
+ });
117
+
118
+ // ============================================================================
119
+ // createTestController — docs/testing.md
120
+ // ============================================================================
121
+
122
+ describe('docs/testing.md — createTestController', () => {
123
+ /**
124
+ * @source docs/testing.md#createtestcontroller
125
+ */
126
+ it('basic usage — creates controller with mock logger and config', () => {
127
+ const mockUserService = { findById: (id: string) => ({ id, name: 'Mock' }) };
128
+ const { instance, logger, config } = createTestController(UserController, {
129
+ deps: [mockUserService],
130
+ });
131
+
132
+ expect(instance).toBeInstanceOf(UserController);
133
+ expect(logger).toBeDefined();
134
+ expect(config).toBeDefined();
135
+ });
136
+ });
137
+
138
+ // ============================================================================
139
+ // TestingModule — docs/testing.md
140
+ // ============================================================================
141
+
142
+ describe('docs/testing.md — TestingModule', () => {
143
+ /**
144
+ * @source docs/testing.md#basic-usage-1
145
+ */
146
+ it('basic compile / inject / close flow', async () => {
147
+ let module: CompiledTestingModule | undefined;
148
+
149
+ try {
150
+ module = await TestingModule
151
+ .create({
152
+ controllers: [UserController],
153
+ providers: [UserService],
154
+ })
155
+ .compile();
156
+
157
+ const response = await module.inject('GET', '/users/42');
158
+
159
+ expect(response.status).toBe(200);
160
+
161
+ const body = await response.json() as { result: { id: string; name: string } };
162
+ expect(body.result.id).toBe('42');
163
+ expect(body.result.name).toBe('User 42');
164
+ } finally {
165
+ await module?.close();
166
+ }
167
+ });
168
+
169
+ /**
170
+ * @source docs/testing.md#overrideproviderserviceclass
171
+ */
172
+ it('overrideProvider — replaces service with mock value', async () => {
173
+ const mockUser = { id: '1', name: 'MockUser' };
174
+ let module: CompiledTestingModule | undefined;
175
+
176
+ try {
177
+ module = await TestingModule
178
+ .create({ controllers: [UserController], providers: [UserService] })
179
+ .overrideProvider(UserService).useValue({ findById: () => mockUser })
180
+ .compile();
181
+
182
+ const response = await module.inject('GET', '/users/1');
183
+
184
+ expect(response.status).toBe(200);
185
+
186
+ const body = await response.json() as { result: { id: string; name: string } };
187
+ expect(body.result.name).toBe('MockUser');
188
+ } finally {
189
+ await module?.close();
190
+ }
191
+ });
192
+
193
+ /**
194
+ * @source docs/testing.md#setoptionsoptions
195
+ */
196
+ it('setOptions — applies basePath to routes', async () => {
197
+ let module: CompiledTestingModule | undefined;
198
+
199
+ try {
200
+ module = await TestingModule
201
+ .create({ controllers: [UserController], providers: [UserService] })
202
+ .setOptions({ basePath: '/api' })
203
+ .compile();
204
+
205
+ const response = await module.inject('GET', '/api/users/1');
206
+
207
+ expect(response.status).toBe(200);
208
+ } finally {
209
+ await module?.close();
210
+ }
211
+ });
212
+ });
213
+
214
+ // ============================================================================
215
+ // useFakeTimers — docs/testing.md
216
+ // ============================================================================
217
+
218
+ describe('docs/testing.md — useFakeTimers', () => {
219
+ /**
220
+ * @source docs/testing.md#usefaketimers
221
+ */
222
+ it('basic usage — advance time and trigger setTimeout', () => {
223
+ const timers = useFakeTimers();
224
+
225
+ try {
226
+ let called = false;
227
+ setTimeout(() => {
228
+ called = true;
229
+ }, 1000);
230
+
231
+ timers.advanceTime(999);
232
+ expect(called).toBe(false);
233
+
234
+ timers.advanceTime(1);
235
+ expect(called).toBe(true);
236
+ } finally {
237
+ timers.restore();
238
+ }
239
+ });
240
+ });
241
+
242
+ // ============================================================================
243
+ // createMockLogger — docs/testing.md
244
+ // ============================================================================
245
+
246
+ describe('docs/testing.md — createMockLogger', () => {
247
+ /**
248
+ * @source docs/testing.md#createmocklogger
249
+ */
250
+ it('basic usage — creates silent async logger', () => {
251
+ const logger = createMockLogger();
252
+
253
+ expect(logger).toBeDefined();
254
+ expect(typeof logger.info).toBe('function');
255
+ expect(typeof logger.child).toBe('function');
256
+ expect(logger.child({ context: 'test' })).toBe(logger);
257
+ });
258
+ });
259
+
260
+ // ============================================================================
261
+ // createMockConfig — docs/testing.md
262
+ // ============================================================================
263
+
264
+ describe('docs/testing.md — createMockConfig', () => {
265
+ /**
266
+ * @source docs/testing.md#createmockconfig
267
+ */
268
+ it('basic usage — returns values from provided map', () => {
269
+ const config = createMockConfig({
270
+ /* eslint-disable @typescript-eslint/naming-convention */
271
+ 'server.port': 3000,
272
+ 'server.host': '0.0.0.0',
273
+ /* eslint-enable @typescript-eslint/naming-convention */
274
+ });
275
+
276
+ expect(config.get('server.port')).toBe(3000);
277
+ expect(config.get('server.host')).toBe('0.0.0.0');
278
+ expect(config.isInitialized).toBe(true);
279
+ });
280
+ });
@@ -4,5 +4,7 @@
4
4
  * Helpers for testing OneBun applications.
5
5
  */
6
6
 
7
+ export * from './containers';
8
+ export * from './service-helpers';
7
9
  export * from './test-utils';
8
10
  export * from './testing-module';
@@ -0,0 +1,166 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ mock,
5
+ test,
6
+ } from 'bun:test';
7
+
8
+ import { Controller } from '../decorators/decorators';
9
+ import { Controller as BaseController } from '../module/controller';
10
+ import { BaseService, Service } from '../module/service';
11
+
12
+ import { createTestController, createTestService } from './service-helpers';
13
+
14
+ // ============================================================================
15
+ // Test fixtures
16
+ // ============================================================================
17
+
18
+ @Service()
19
+ class TestService extends BaseService {
20
+ getValue(): string {
21
+ return 'service-value';
22
+ }
23
+ }
24
+
25
+ @Service()
26
+ class ServiceWithDeps extends BaseService {
27
+ constructor(private readonly dep: { name: string }) {
28
+ super();
29
+ }
30
+
31
+ getDep(): string {
32
+ return this.dep.name;
33
+ }
34
+ }
35
+
36
+ @Controller('/test')
37
+ class TestController extends BaseController {
38
+ handle(): string {
39
+ return 'controller-value';
40
+ }
41
+ }
42
+
43
+ @Controller('/test-deps')
44
+ class ControllerWithDeps extends BaseController {
45
+ constructor(private readonly dep: { name: string }) {
46
+ super();
47
+ }
48
+
49
+ getDep(): string {
50
+ return this.dep.name;
51
+ }
52
+ }
53
+
54
+ // ============================================================================
55
+ // createTestService
56
+ // ============================================================================
57
+
58
+ describe('createTestService', () => {
59
+ test('creates service instance with mock logger and config', () => {
60
+ const { instance, logger, config } = createTestService(TestService);
61
+
62
+ expect(instance).toBeInstanceOf(TestService);
63
+ expect(instance.getValue()).toBe('service-value');
64
+ expect(logger).toBeDefined();
65
+ expect(config).toBeDefined();
66
+ expect(config.isInitialized).toBe(true);
67
+ });
68
+
69
+ test('config.get returns provided config values', () => {
70
+ const { config } = createTestService(TestService, {
71
+ /* eslint-disable @typescript-eslint/naming-convention */
72
+ config: { 'app.name': 'my-app', 'server.port': 3000 },
73
+ /* eslint-enable @typescript-eslint/naming-convention */
74
+ });
75
+
76
+ expect(config.get('app.name')).toBe('my-app');
77
+ expect(config.get('server.port')).toBe(3000);
78
+ });
79
+
80
+ test('passes constructor dependencies', () => {
81
+ const { instance } = createTestService(ServiceWithDeps, {
82
+ deps: [{ name: 'test-dep' }],
83
+ });
84
+
85
+ expect(instance.getDep()).toBe('test-dep');
86
+ });
87
+
88
+ test('logger methods are mock functions with .mock.calls', () => {
89
+ const { logger } = createTestService(TestService);
90
+
91
+ // initializeService calls logger.child() and then debug() internally,
92
+ // so we track calls made after initialization
93
+ const debugCallsBefore = (logger.debug as ReturnType<typeof mock>).mock.calls.length;
94
+
95
+ logger.info('test message');
96
+ logger.warn('warning');
97
+ logger.debug('debug');
98
+
99
+ expect((logger.info as ReturnType<typeof mock>).mock.calls).toHaveLength(1);
100
+ expect((logger.warn as ReturnType<typeof mock>).mock.calls).toHaveLength(1);
101
+ expect((logger.debug as ReturnType<typeof mock>).mock.calls.length - debugCallsBefore).toBe(1);
102
+ expect((logger.error as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
103
+ expect((logger.trace as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
104
+ expect((logger.fatal as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
105
+ });
106
+
107
+ test('initializes service via initializeService', () => {
108
+ const { instance } = createTestService(TestService);
109
+
110
+ // After initializeService, the service should have logger and config set
111
+ // We verify this indirectly — the service was created and initialized without errors
112
+ expect(instance).toBeInstanceOf(TestService);
113
+ });
114
+ });
115
+
116
+ // ============================================================================
117
+ // createTestController
118
+ // ============================================================================
119
+
120
+ describe('createTestController', () => {
121
+ test('creates controller instance with mock logger and config', () => {
122
+ const { instance, logger, config } = createTestController(TestController);
123
+
124
+ expect(instance).toBeInstanceOf(TestController);
125
+ expect(instance.handle()).toBe('controller-value');
126
+ expect(logger).toBeDefined();
127
+ expect(config).toBeDefined();
128
+ expect(config.isInitialized).toBe(true);
129
+ });
130
+
131
+ test('config.get returns provided config values', () => {
132
+ const { config } = createTestController(TestController, {
133
+ /* eslint-disable @typescript-eslint/naming-convention */
134
+ config: { 'app.name': 'my-app', 'server.port': 3000 },
135
+ /* eslint-enable @typescript-eslint/naming-convention */
136
+ });
137
+
138
+ expect(config.get('app.name')).toBe('my-app');
139
+ expect(config.get('server.port')).toBe(3000);
140
+ });
141
+
142
+ test('passes constructor dependencies', () => {
143
+ const { instance } = createTestController(ControllerWithDeps, {
144
+ deps: [{ name: 'test-dep' }],
145
+ });
146
+
147
+ expect(instance.getDep()).toBe('test-dep');
148
+ });
149
+
150
+ test('logger methods are mock functions with .mock.calls', () => {
151
+ const { logger } = createTestController(TestController);
152
+
153
+ logger.info('test message');
154
+ logger.error('error');
155
+
156
+ expect((logger.info as ReturnType<typeof mock>).mock.calls).toHaveLength(1);
157
+ expect((logger.error as ReturnType<typeof mock>).mock.calls).toHaveLength(1);
158
+ expect((logger.warn as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
159
+ });
160
+
161
+ test('initializes controller via initializeController', () => {
162
+ const { instance } = createTestController(TestController);
163
+
164
+ expect(instance).toBeInstanceOf(TestController);
165
+ });
166
+ });
@@ -0,0 +1,69 @@
1
+ import { mock } from 'bun:test';
2
+
3
+ import type { IConfig, OneBunAppConfig } from '../module/config.interface';
4
+
5
+ import type { SyncLogger } from '@onebun/logger';
6
+
7
+
8
+ import { createMockConfig } from './test-utils';
9
+
10
+ export interface TestInstanceResult<T> {
11
+ instance: T;
12
+ logger: SyncLogger;
13
+ config: IConfig<OneBunAppConfig>;
14
+ }
15
+
16
+ interface CreateTestOptions {
17
+ config?: Record<string, unknown>;
18
+ deps?: unknown[];
19
+ }
20
+
21
+ function createMockableSyncLogger(): SyncLogger {
22
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
23
+ const noOp = () => {};
24
+ const logger: SyncLogger = {
25
+ trace: mock(noOp),
26
+ debug: mock(noOp),
27
+ info: mock(noOp),
28
+ warn: mock(noOp),
29
+ error: mock(noOp),
30
+ fatal: mock(noOp),
31
+ child: mock(() => logger),
32
+ };
33
+
34
+ return logger;
35
+ }
36
+
37
+ export function createTestService<T>(
38
+ serviceClass: new (...args: never[]) => T,
39
+ options?: CreateTestOptions,
40
+ ): TestInstanceResult<T> {
41
+ const logger = createMockableSyncLogger();
42
+ const config = createMockConfig(options?.config ?? {});
43
+ const deps = options?.deps ?? [];
44
+
45
+ const instance = new serviceClass(...deps as never[]);
46
+
47
+ if (typeof (instance as Record<string, unknown>).initializeService === 'function') {
48
+ (instance as Record<string, (...args: unknown[]) => void>).initializeService(logger, config);
49
+ }
50
+
51
+ return { instance, logger, config };
52
+ }
53
+
54
+ export function createTestController<T>(
55
+ controllerClass: new (...args: never[]) => T,
56
+ options?: CreateTestOptions,
57
+ ): TestInstanceResult<T> {
58
+ const logger = createMockableSyncLogger();
59
+ const config = createMockConfig(options?.config ?? {});
60
+ const deps = options?.deps ?? [];
61
+
62
+ const instance = new controllerClass(...deps as never[]);
63
+
64
+ if (typeof (instance as Record<string, unknown>).initializeController === 'function') {
65
+ (instance as Record<string, (...args: unknown[]) => void>).initializeController(logger, config);
66
+ }
67
+
68
+ return { instance, logger, config };
69
+ }
@@ -6,6 +6,7 @@ import {
6
6
  it,
7
7
  } from 'bun:test';
8
8
 
9
+ import { OneBunApplication } from '../application/application';
9
10
  import {
10
11
  Controller,
11
12
  Get,
@@ -17,6 +18,7 @@ import {
17
18
  import { Controller as BaseController } from '../module/controller';
18
19
  import { BaseService, Service } from '../module/service';
19
20
 
21
+
20
22
  import { TestingModule, type CompiledTestingModule } from './testing-module';
21
23
 
22
24
  // ============================================================================
@@ -146,6 +148,84 @@ describe('TestingModule', () => {
146
148
  });
147
149
  });
148
150
 
151
+ describe('setOptions()', () => {
152
+ it('passes options to the application', async () => {
153
+ const module = await TestingModule
154
+ .create({
155
+ controllers: [GreetController],
156
+ providers: [GreetingService],
157
+ })
158
+ .setOptions({ basePath: '/api' })
159
+ .compile();
160
+
161
+ try {
162
+ // Without basePath prefix the route should not match
163
+ const notFound = await module.inject('GET', '/greet/world');
164
+ expect(notFound.status).toBe(404);
165
+
166
+ // With basePath prefix the route should match
167
+ const found = await module.inject('GET', '/api/greet/world');
168
+ expect(found.status).toBe(200);
169
+ } finally {
170
+ await module.close();
171
+ }
172
+ });
173
+ });
174
+
175
+ describe('getApp()', () => {
176
+ it('returns an OneBunApplication instance', async () => {
177
+ const module = await TestingModule
178
+ .create({
179
+ controllers: [GreetController],
180
+ providers: [GreetingService],
181
+ })
182
+ .compile();
183
+
184
+ try {
185
+ expect(module.getApp()).toBeInstanceOf(OneBunApplication);
186
+ } finally {
187
+ await module.close();
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('getPort()', () => {
193
+ it('returns a port greater than 0', async () => {
194
+ const module = await TestingModule
195
+ .create({
196
+ controllers: [GreetController],
197
+ providers: [GreetingService],
198
+ })
199
+ .compile();
200
+
201
+ try {
202
+ expect(module.getPort()).toBeGreaterThan(0);
203
+ } finally {
204
+ await module.close();
205
+ }
206
+ });
207
+ });
208
+
209
+ describe('getConfig()', () => {
210
+ it('returns config object when envSchema is provided', async () => {
211
+ const module = await TestingModule
212
+ .create({
213
+ controllers: [GreetController],
214
+ providers: [GreetingService],
215
+ })
216
+ .setOptions({ envSchema: {} })
217
+ .compile();
218
+
219
+ try {
220
+ const config = module.getConfig();
221
+ expect(config).toBeDefined();
222
+ expect(typeof config.get).toBe('function');
223
+ } finally {
224
+ await module.close();
225
+ }
226
+ });
227
+ });
228
+
149
229
  describe('overrideProvider()', () => {
150
230
  it('useValue() replaces service so controller uses mock', async () => {
151
231
  const mockService = {
@@ -17,7 +17,12 @@
17
17
  */
18
18
 
19
19
  import type { OneBunApplication } from '../application/application';
20
- import type { HttpMethod, OneBunResponse } from '../types';
20
+ import type { IConfig, OneBunAppConfig } from '../module/config.interface';
21
+ import type {
22
+ ApplicationOptions,
23
+ HttpMethod,
24
+ OneBunResponse,
25
+ } from '../types';
21
26
  import type { Context } from 'effect';
22
27
 
23
28
  import { Module } from '../decorators/decorators';
@@ -123,6 +128,31 @@ export class CompiledTestingModule {
123
128
  }) as OneBunResponse;
124
129
  }
125
130
 
131
+ /**
132
+ * Get the underlying OneBunApplication instance.
133
+ * Useful for accessing application-level APIs not exposed by the testing module.
134
+ */
135
+ getApp(): OneBunApplication {
136
+ return this.app;
137
+ }
138
+
139
+ /**
140
+ * Get the port the test server is listening on.
141
+ */
142
+ getPort(): number {
143
+ return this.port;
144
+ }
145
+
146
+ /**
147
+ * Get the application configuration.
148
+ * Requires `envSchema` to be set via `setOptions()`.
149
+ *
150
+ * @throws If configuration was not initialized (no envSchema provided)
151
+ */
152
+ getConfig(): IConfig<OneBunAppConfig> {
153
+ return this.app.getConfig();
154
+ }
155
+
126
156
  /**
127
157
  * Stop the test server and release resources.
128
158
  * Call this in `afterEach` / `afterAll` to prevent port leaks.
@@ -176,6 +206,7 @@ export class TestingModule {
176
206
  tag: Context.Tag<any, any>;
177
207
  value: unknown;
178
208
  }> = [];
209
+ private appOptions: Partial<ApplicationOptions> = {};
179
210
 
180
211
  private constructor(options: TestingModuleCreateOptions) {
181
212
  this.options = options;
@@ -190,6 +221,19 @@ export class TestingModule {
190
221
  return new TestingModule(options);
191
222
  }
192
223
 
224
+ /**
225
+ * Set additional application options (e.g. envSchema, cors, basePath).
226
+ * Options are merged into the application config. `gracefulShutdown` and
227
+ * `_testProviders` are always forced by the testing module.
228
+ *
229
+ * @param options - Partial application options to merge
230
+ */
231
+ setOptions(options: Partial<ApplicationOptions>): TestingModule {
232
+ this.appOptions = options;
233
+
234
+ return this;
235
+ }
236
+
193
237
  /**
194
238
  * Override a provider with a mock value or class.
195
239
  * Overrides are applied before `setup()` so controllers receive mocks at construction time.
@@ -239,8 +283,9 @@ export class TestingModule {
239
283
  // - silent logger
240
284
  // - test provider overrides injected before setup()
241
285
  const app = new OneBunApplication(_TestingAppModule, {
242
- port: 0,
243
286
  loggerLayer: makeMockLoggerLayer() as import('effect').Layer.Layer<import('@onebun/logger').Logger>,
287
+ port: 0,
288
+ ...this.appOptions,
244
289
  gracefulShutdown: false,
245
290
  _testProviders: this.overrides,
246
291
  });