@onebun/core 0.2.11 → 0.2.13
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 +6 -2
- package/src/application/application.test.ts +325 -8
- package/src/application/application.ts +23 -6
- package/src/decorators/decorators.test.ts +1 -1
- package/src/decorators/decorators.ts +1 -1
- package/src/decorators/metadata.test.ts +86 -0
- package/src/decorators/metadata.ts +199 -0
- package/src/docs-examples.test.ts +2 -1
- package/src/index.ts +2 -2
- package/src/module/module.ts +36 -0
- package/src/queue/adapters/memory.adapter.test.ts +0 -4
- package/src/queue/adapters/memory.adapter.ts +0 -46
- package/src/queue/adapters/redis.adapter.test.ts +0 -64
- package/src/queue/adapters/redis.adapter.ts +0 -41
- package/src/queue/docs-examples.test.ts +220 -9
- package/src/queue/index.ts +8 -1
- package/src/queue/queue-service-proxy.test.ts +12 -3
- package/src/queue/queue-service-proxy.ts +37 -7
- package/src/queue/queue.service.test.ts +138 -16
- package/src/queue/queue.service.ts +48 -11
- package/src/queue/scheduler.test.ts +280 -0
- package/src/queue/scheduler.ts +156 -3
- package/src/queue/types.ts +75 -27
- package/src/testing/test-utils.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onebun/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Core package for OneBun framework - decorators, DI, modules, controllers",
|
|
5
5
|
"license": "LGPL-3.0",
|
|
6
6
|
"author": "RemRyahirev",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"src",
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/index.ts",
|
|
37
|
+
"./testing": "./src/testing/index.ts"
|
|
38
|
+
},
|
|
35
39
|
"main": "src/index.ts",
|
|
36
40
|
"module": "src/index.ts",
|
|
37
41
|
"types": "src/index.ts",
|
|
@@ -43,7 +47,7 @@
|
|
|
43
47
|
"arktype": "^2.0.0",
|
|
44
48
|
"@onebun/logger": "^0.2.1",
|
|
45
49
|
"@onebun/envs": "^0.2.1",
|
|
46
|
-
"@onebun/metrics": "^0.2.
|
|
50
|
+
"@onebun/metrics": "^0.2.2",
|
|
47
51
|
"@onebun/requests": "^0.2.1",
|
|
48
52
|
"@onebun/trace": "^0.2.1"
|
|
49
53
|
},
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { Controller as BaseController } from '../module/controller';
|
|
40
40
|
import { BaseMiddleware } from '../module/middleware';
|
|
41
41
|
import { clearGlobalServicesRegistry } from '../module/module';
|
|
42
|
-
import { Service } from '../module/service';
|
|
42
|
+
import { BaseService, Service } from '../module/service';
|
|
43
43
|
import { QueueService, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from '../queue';
|
|
44
44
|
import { CronExpression } from '../queue/cron-expression';
|
|
45
45
|
import {
|
|
@@ -2706,6 +2706,43 @@ describe('OneBunApplication', () => {
|
|
|
2706
2706
|
expect(bodyListWithSlash.result).toEqual({ tasks: ['t1', 't2'], status: 'all' });
|
|
2707
2707
|
});
|
|
2708
2708
|
|
|
2709
|
+
test('should not produce double slash when @Controller() has no path prefix', async () => {
|
|
2710
|
+
@Controller()
|
|
2711
|
+
class NoPrefixController extends BaseController {
|
|
2712
|
+
@Get('/users')
|
|
2713
|
+
async listUsers(): Promise<OneBunResponse> {
|
|
2714
|
+
return this.success({ users: ['u1'] });
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
@Get('/')
|
|
2718
|
+
async root(): Promise<OneBunResponse> {
|
|
2719
|
+
return this.success({ root: true });
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
@Module({ controllers: [NoPrefixController] })
|
|
2724
|
+
class TestModule {}
|
|
2725
|
+
|
|
2726
|
+
const app = createTestApp(TestModule);
|
|
2727
|
+
await app.start();
|
|
2728
|
+
|
|
2729
|
+
// Route should be /users, not //users
|
|
2730
|
+
const responseUsers = await (mockServer as /* eslint-disable-line @typescript-eslint/no-explicit-any */ any).fetchHandler(
|
|
2731
|
+
new Request('http://localhost:3000/users', { method: 'GET' }),
|
|
2732
|
+
);
|
|
2733
|
+
expect(responseUsers.status).toBe(200);
|
|
2734
|
+
const bodyUsers = await responseUsers.json();
|
|
2735
|
+
expect(bodyUsers.result).toEqual({ users: ['u1'] });
|
|
2736
|
+
|
|
2737
|
+
// Route should be /, not empty
|
|
2738
|
+
const responseRoot = await (mockServer as /* eslint-disable-line @typescript-eslint/no-explicit-any */ any).fetchHandler(
|
|
2739
|
+
new Request('http://localhost:3000/', { method: 'GET' }),
|
|
2740
|
+
);
|
|
2741
|
+
expect(responseRoot.status).toBe(200);
|
|
2742
|
+
const bodyRoot = await responseRoot.json();
|
|
2743
|
+
expect(bodyRoot.result).toEqual({ root: true });
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2709
2746
|
test('should normalize metrics route labels - trailing slash requests use same label as non-trailing', async () => {
|
|
2710
2747
|
@Controller('/api')
|
|
2711
2748
|
class ApiController extends BaseController {
|
|
@@ -3927,13 +3964,6 @@ describe('OneBunApplication', () => {
|
|
|
3927
3964
|
};
|
|
3928
3965
|
}
|
|
3929
3966
|
|
|
3930
|
-
async addScheduledJob(): Promise<void> {}
|
|
3931
|
-
async removeScheduledJob(): Promise<boolean> {
|
|
3932
|
-
return false;
|
|
3933
|
-
}
|
|
3934
|
-
async getScheduledJobs(): Promise<import('../queue/types').ScheduledJobInfo[]> {
|
|
3935
|
-
return [];
|
|
3936
|
-
}
|
|
3937
3967
|
supports(): boolean {
|
|
3938
3968
|
return false;
|
|
3939
3969
|
}
|
|
@@ -4311,4 +4341,291 @@ describe('OneBunApplication', () => {
|
|
|
4311
4341
|
await app.stop();
|
|
4312
4342
|
});
|
|
4313
4343
|
});
|
|
4344
|
+
|
|
4345
|
+
describe('queue handler execution', () => {
|
|
4346
|
+
afterEach(async () => {
|
|
4347
|
+
clearGlobalServicesRegistry();
|
|
4348
|
+
register.clear();
|
|
4349
|
+
});
|
|
4350
|
+
|
|
4351
|
+
test('@Interval handler fires immediately on start (auto-detected, no explicit queue config)', async () => {
|
|
4352
|
+
let callCount = 0;
|
|
4353
|
+
const intervalMs = 60000;
|
|
4354
|
+
|
|
4355
|
+
@Controller('/interval-fire')
|
|
4356
|
+
class IntervalFireController extends BaseController {
|
|
4357
|
+
@Interval(intervalMs, { pattern: 'test.interval' })
|
|
4358
|
+
getData() {
|
|
4359
|
+
callCount++;
|
|
4360
|
+
|
|
4361
|
+
return { ts: Date.now() };
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
@Module({ controllers: [IntervalFireController] })
|
|
4366
|
+
class IntervalFireModule {}
|
|
4367
|
+
|
|
4368
|
+
const app = createTestApp(IntervalFireModule, { port: 0 });
|
|
4369
|
+
await app.start();
|
|
4370
|
+
|
|
4371
|
+
// scheduler.startIntervalJob calls executeJob immediately (fire-and-forget async)
|
|
4372
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4373
|
+
|
|
4374
|
+
expect(app.getQueueService()).not.toBeNull();
|
|
4375
|
+
expect(callCount).toBe(1);
|
|
4376
|
+
|
|
4377
|
+
await app.stop();
|
|
4378
|
+
});
|
|
4379
|
+
|
|
4380
|
+
test('@Interval handler fires with explicit queue: { enabled: true }', async () => {
|
|
4381
|
+
let callCount = 0;
|
|
4382
|
+
const intervalMs = 60000;
|
|
4383
|
+
|
|
4384
|
+
@Controller('/interval-explicit')
|
|
4385
|
+
class IntervalExplicitController extends BaseController {
|
|
4386
|
+
@Interval(intervalMs, { pattern: 'test.explicit' })
|
|
4387
|
+
getData() {
|
|
4388
|
+
callCount++;
|
|
4389
|
+
|
|
4390
|
+
return { ts: Date.now() };
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
@Module({ controllers: [IntervalExplicitController] })
|
|
4395
|
+
class IntervalExplicitModule {}
|
|
4396
|
+
|
|
4397
|
+
const app = createTestApp(IntervalExplicitModule, {
|
|
4398
|
+
port: 0,
|
|
4399
|
+
queue: { enabled: true },
|
|
4400
|
+
});
|
|
4401
|
+
await app.start();
|
|
4402
|
+
|
|
4403
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4404
|
+
|
|
4405
|
+
expect(callCount).toBe(1);
|
|
4406
|
+
|
|
4407
|
+
await app.stop();
|
|
4408
|
+
});
|
|
4409
|
+
|
|
4410
|
+
test('@Interval handler fires with injected service dependency', async () => {
|
|
4411
|
+
let callCount = 0;
|
|
4412
|
+
const intervalMs = 60000;
|
|
4413
|
+
|
|
4414
|
+
@Service()
|
|
4415
|
+
class WorkerService extends BaseService {
|
|
4416
|
+
async doWork(): Promise<string> {
|
|
4417
|
+
return 'done';
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4421
|
+
@Controller('/interval-di')
|
|
4422
|
+
class IntervalDIController extends BaseController {
|
|
4423
|
+
constructor(private readonly worker: WorkerService) {
|
|
4424
|
+
super();
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
@Interval(intervalMs, { pattern: 'test.di' })
|
|
4428
|
+
async getData(): Promise<Record<string, unknown>> {
|
|
4429
|
+
callCount++;
|
|
4430
|
+
|
|
4431
|
+
return { result: await this.worker.doWork() };
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
@Module({ controllers: [IntervalDIController], providers: [WorkerService] })
|
|
4436
|
+
class IntervalDIModule {}
|
|
4437
|
+
|
|
4438
|
+
const app = createTestApp(IntervalDIModule, { port: 0 });
|
|
4439
|
+
await app.start();
|
|
4440
|
+
|
|
4441
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4442
|
+
|
|
4443
|
+
expect(callCount).toBe(1);
|
|
4444
|
+
|
|
4445
|
+
await app.stop();
|
|
4446
|
+
});
|
|
4447
|
+
|
|
4448
|
+
test('@Cron handler registers job in scheduler (auto-detected)', async () => {
|
|
4449
|
+
@Controller('/cron-register')
|
|
4450
|
+
class CronRegisterController extends BaseController {
|
|
4451
|
+
@Cron(CronExpression.EVERY_HOUR, { pattern: 'cron.hourly' })
|
|
4452
|
+
getData() {
|
|
4453
|
+
return { ts: Date.now() };
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
@Module({ controllers: [CronRegisterController] })
|
|
4458
|
+
class CronRegisterModule {}
|
|
4459
|
+
|
|
4460
|
+
const app = createTestApp(CronRegisterModule, { port: 0 });
|
|
4461
|
+
await app.start();
|
|
4462
|
+
|
|
4463
|
+
const scheduler = app.getQueueService()!.getScheduler();
|
|
4464
|
+
const jobs = scheduler.getJobs();
|
|
4465
|
+
expect(jobs.length).toBe(1);
|
|
4466
|
+
expect(jobs[0].pattern).toBe('cron.hourly');
|
|
4467
|
+
|
|
4468
|
+
await app.stop();
|
|
4469
|
+
});
|
|
4470
|
+
|
|
4471
|
+
test('@Timeout handler registers and fires once (auto-detected)', async () => {
|
|
4472
|
+
let callCount = 0;
|
|
4473
|
+
const timeoutMs = 10;
|
|
4474
|
+
|
|
4475
|
+
@Controller('/timeout-fire')
|
|
4476
|
+
class TimeoutFireController extends BaseController {
|
|
4477
|
+
@Timeout(timeoutMs, { pattern: 'test.timeout' })
|
|
4478
|
+
getData() {
|
|
4479
|
+
callCount++;
|
|
4480
|
+
|
|
4481
|
+
return { ts: Date.now() };
|
|
4482
|
+
}
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
@Module({ controllers: [TimeoutFireController] })
|
|
4486
|
+
class TimeoutFireModule {}
|
|
4487
|
+
|
|
4488
|
+
const app = createTestApp(TimeoutFireModule, { port: 0 });
|
|
4489
|
+
await app.start();
|
|
4490
|
+
|
|
4491
|
+
// Wait for timeout to fire
|
|
4492
|
+
await new Promise(r => setTimeout(r, 100));
|
|
4493
|
+
|
|
4494
|
+
expect(callCount).toBe(1);
|
|
4495
|
+
|
|
4496
|
+
await app.stop();
|
|
4497
|
+
});
|
|
4498
|
+
|
|
4499
|
+
test('scheduled decorators in nested child module auto-init queue and fire', async () => {
|
|
4500
|
+
let callCount = 0;
|
|
4501
|
+
const intervalMs = 60000;
|
|
4502
|
+
|
|
4503
|
+
@Controller('/nested-interval')
|
|
4504
|
+
class NestedIntervalController extends BaseController {
|
|
4505
|
+
@Interval(intervalMs, { pattern: 'nested.interval' })
|
|
4506
|
+
getData() {
|
|
4507
|
+
callCount++;
|
|
4508
|
+
|
|
4509
|
+
return { ts: Date.now() };
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
@Module({ controllers: [NestedIntervalController] })
|
|
4514
|
+
class ChildModule {}
|
|
4515
|
+
|
|
4516
|
+
@Module({ imports: [ChildModule] })
|
|
4517
|
+
class ParentModule {}
|
|
4518
|
+
|
|
4519
|
+
const app = createTestApp(ParentModule, { port: 0 });
|
|
4520
|
+
await app.start();
|
|
4521
|
+
|
|
4522
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4523
|
+
|
|
4524
|
+
expect(app.getQueueService()).not.toBeNull();
|
|
4525
|
+
expect(callCount).toBe(1);
|
|
4526
|
+
|
|
4527
|
+
await app.stop();
|
|
4528
|
+
});
|
|
4529
|
+
|
|
4530
|
+
test('scheduled decorators in deeply nested module auto-init queue and fire', async () => {
|
|
4531
|
+
let callCount = 0;
|
|
4532
|
+
const intervalMs = 60000;
|
|
4533
|
+
|
|
4534
|
+
@Controller('/deep-interval')
|
|
4535
|
+
class DeepIntervalController extends BaseController {
|
|
4536
|
+
@Interval(intervalMs, { pattern: 'deep.interval' })
|
|
4537
|
+
getData() {
|
|
4538
|
+
callCount++;
|
|
4539
|
+
|
|
4540
|
+
return { ts: Date.now() };
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
@Module({ controllers: [DeepIntervalController] })
|
|
4545
|
+
class GrandchildModule {}
|
|
4546
|
+
|
|
4547
|
+
@Module({ imports: [GrandchildModule] })
|
|
4548
|
+
class MiddleModule {}
|
|
4549
|
+
|
|
4550
|
+
@Module({ imports: [MiddleModule] })
|
|
4551
|
+
class TopModule {}
|
|
4552
|
+
|
|
4553
|
+
const app = createTestApp(TopModule, { port: 0 });
|
|
4554
|
+
await app.start();
|
|
4555
|
+
|
|
4556
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4557
|
+
|
|
4558
|
+
expect(app.getQueueService()).not.toBeNull();
|
|
4559
|
+
expect(callCount).toBe(1);
|
|
4560
|
+
|
|
4561
|
+
await app.stop();
|
|
4562
|
+
});
|
|
4563
|
+
|
|
4564
|
+
test('handler error does not break scheduler', async () => {
|
|
4565
|
+
let errorCallCount = 0;
|
|
4566
|
+
let successCallCount = 0;
|
|
4567
|
+
const intervalMs = 60000;
|
|
4568
|
+
|
|
4569
|
+
@Controller('/error-handler')
|
|
4570
|
+
class ErrorHandlerController extends BaseController {
|
|
4571
|
+
@Interval(intervalMs, { pattern: 'error.handler' })
|
|
4572
|
+
getErrorData() {
|
|
4573
|
+
errorCallCount++;
|
|
4574
|
+
throw new Error('Handler failed');
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
@Interval(intervalMs, { pattern: 'success.handler' })
|
|
4578
|
+
getSuccessData() {
|
|
4579
|
+
successCallCount++;
|
|
4580
|
+
|
|
4581
|
+
return { ok: true };
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
@Module({ controllers: [ErrorHandlerController] })
|
|
4586
|
+
class ErrorHandlerModule {}
|
|
4587
|
+
|
|
4588
|
+
const app = createTestApp(ErrorHandlerModule, { port: 0 });
|
|
4589
|
+
await app.start();
|
|
4590
|
+
|
|
4591
|
+
await new Promise(r => setTimeout(r, 50));
|
|
4592
|
+
|
|
4593
|
+
// Both handlers should have been called despite one throwing
|
|
4594
|
+
expect(errorCallCount).toBe(1);
|
|
4595
|
+
expect(successCallCount).toBe(1);
|
|
4596
|
+
|
|
4597
|
+
await app.stop();
|
|
4598
|
+
});
|
|
4599
|
+
|
|
4600
|
+
test('only scheduled decorators (no @Subscribe) implicitly use in-memory adapter', async () => {
|
|
4601
|
+
@Controller('/scheduled-only')
|
|
4602
|
+
class ScheduledOnlyController extends BaseController {
|
|
4603
|
+
@Interval(60000, { pattern: 'scheduled.only' })
|
|
4604
|
+
getData() {
|
|
4605
|
+
return { ts: Date.now() };
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
@Cron(CronExpression.EVERY_HOUR, { pattern: 'scheduled.cron' })
|
|
4609
|
+
getCronData() {
|
|
4610
|
+
return { ts: Date.now() };
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
@Module({ controllers: [ScheduledOnlyController] })
|
|
4615
|
+
class ScheduledOnlyModule {}
|
|
4616
|
+
|
|
4617
|
+
// No queue config at all — should auto-detect and use in-memory
|
|
4618
|
+
const app = createTestApp(ScheduledOnlyModule, { port: 0 });
|
|
4619
|
+
await app.start();
|
|
4620
|
+
|
|
4621
|
+
const qs = app.getQueueService();
|
|
4622
|
+
expect(qs).not.toBeNull();
|
|
4623
|
+
|
|
4624
|
+
const scheduler = qs!.getScheduler();
|
|
4625
|
+
const jobs = scheduler.getJobs();
|
|
4626
|
+
expect(jobs.length).toBe(2);
|
|
4627
|
+
|
|
4628
|
+
await app.stop();
|
|
4629
|
+
});
|
|
4630
|
+
});
|
|
4314
4631
|
});
|
|
@@ -1777,7 +1777,7 @@ export class OneBunApplication {
|
|
|
1777
1777
|
return false;
|
|
1778
1778
|
}
|
|
1779
1779
|
|
|
1780
|
-
return hasQueueDecorators(instance.constructor);
|
|
1780
|
+
return hasQueueDecorators(controller) || hasQueueDecorators(instance.constructor);
|
|
1781
1781
|
});
|
|
1782
1782
|
|
|
1783
1783
|
// Determine if queue should be enabled
|
|
@@ -1840,24 +1840,41 @@ export class OneBunApplication {
|
|
|
1840
1840
|
: queueOptions?.redis,
|
|
1841
1841
|
};
|
|
1842
1842
|
this.queueService = new QueueService(queueServiceConfig);
|
|
1843
|
-
|
|
1843
|
+
|
|
1844
1844
|
// Initialize with the adapter
|
|
1845
1845
|
await this.queueService.initialize(this.queueAdapter);
|
|
1846
1846
|
|
|
1847
|
+
// Wire scheduler error handler so failed jobs are logged
|
|
1848
|
+
this.queueService.getScheduler().setErrorHandler((jobName, error) => {
|
|
1849
|
+
this.logger.warn(`Scheduled job "${jobName}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1847
1852
|
// Register handlers from controllers using registerService
|
|
1848
1853
|
for (const controllerClass of controllers) {
|
|
1849
1854
|
const instance = this.ensureModule().getControllerInstance?.(controllerClass);
|
|
1850
1855
|
if (!instance) {
|
|
1856
|
+
this.logger.debug(`Queue: skipping controller ${controllerClass.name} (no instance found)`);
|
|
1851
1857
|
continue;
|
|
1852
1858
|
}
|
|
1853
|
-
|
|
1854
|
-
//
|
|
1855
|
-
if
|
|
1859
|
+
|
|
1860
|
+
// Check both controllerClass and instance.constructor for queue decorators
|
|
1861
|
+
// These should be identical, but if @Controller wrapping produces a different reference,
|
|
1862
|
+
// we check both to be safe
|
|
1863
|
+
const hasDecorators =
|
|
1864
|
+
hasQueueDecorators(controllerClass) || hasQueueDecorators(instance.constructor);
|
|
1865
|
+
|
|
1866
|
+
if (hasDecorators) {
|
|
1867
|
+
// Use whichever reference has the metadata for registration
|
|
1868
|
+
const registrationClass = hasQueueDecorators(controllerClass)
|
|
1869
|
+
? controllerClass
|
|
1870
|
+
: instance.constructor;
|
|
1856
1871
|
await this.queueService.registerService(
|
|
1857
1872
|
instance,
|
|
1858
|
-
|
|
1873
|
+
registrationClass as new (...args: unknown[]) => unknown,
|
|
1859
1874
|
);
|
|
1860
1875
|
this.logger.debug(`Registered queue handlers for controller: ${controllerClass.name}`);
|
|
1876
|
+
} else {
|
|
1877
|
+
this.logger.debug(`Queue: controller ${controllerClass.name} has no queue decorators`);
|
|
1861
1878
|
}
|
|
1862
1879
|
}
|
|
1863
1880
|
|
|
@@ -104,7 +104,7 @@ describe('decorators', () => {
|
|
|
104
104
|
|
|
105
105
|
const metadata = getControllerMetadata(TestController);
|
|
106
106
|
expect(metadata).toBeDefined();
|
|
107
|
-
expect(metadata?.path).toBe('
|
|
107
|
+
expect(metadata?.path).toBe('');
|
|
108
108
|
expect(metadata?.routes).toEqual([]);
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -156,7 +156,7 @@ export function controllerDecorator(basePath: string = '') {
|
|
|
156
156
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
157
|
return <T extends new (...args: any[]) => any>(target: T): T => {
|
|
158
158
|
const metadata: ControllerMetadata = {
|
|
159
|
-
path: basePath.startsWith('/') ? basePath : `/${basePath}
|
|
159
|
+
path: basePath === '' ? '' : (basePath.startsWith('/') ? basePath : `/${basePath}`),
|
|
160
160
|
routes: [],
|
|
161
161
|
};
|
|
162
162
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
defineMetadata,
|
|
17
|
+
diagnoseDecoratorMetadata,
|
|
17
18
|
getMetadata,
|
|
18
19
|
getConstructorParamTypes,
|
|
19
20
|
setConstructorParamTypes,
|
|
@@ -731,6 +732,91 @@ describe('Metadata System', () => {
|
|
|
731
732
|
});
|
|
732
733
|
});
|
|
733
734
|
|
|
735
|
+
describe('diagnoseDecoratorMetadata', () => {
|
|
736
|
+
test('should return ok when no classes have constructor params', () => {
|
|
737
|
+
class NoParams {}
|
|
738
|
+
|
|
739
|
+
const result = diagnoseDecoratorMetadata([NoParams]);
|
|
740
|
+
|
|
741
|
+
expect(result.ok).toBe(true);
|
|
742
|
+
expect(result.classesWithParams).toBe(0);
|
|
743
|
+
expect(result.classesWithMetadata).toBe(0);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('should return ok when classes with params have metadata', () => {
|
|
747
|
+
class DepA {}
|
|
748
|
+
class WithMeta {
|
|
749
|
+
constructor(_dep: DepA) {}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Simulate Bun emitting design:paramtypes via Reflect polyfill
|
|
753
|
+
(globalThis as any).Reflect.defineMetadata('design:paramtypes', [DepA], WithMeta);
|
|
754
|
+
|
|
755
|
+
const result = diagnoseDecoratorMetadata([WithMeta]);
|
|
756
|
+
|
|
757
|
+
expect(result.ok).toBe(true);
|
|
758
|
+
expect(result.classesWithParams).toBe(1);
|
|
759
|
+
expect(result.classesWithMetadata).toBe(1);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('should return not ok when classes with params have NO metadata', () => {
|
|
763
|
+
// Use a fresh class without any metadata set
|
|
764
|
+
class NoDep {}
|
|
765
|
+
class BrokenService {
|
|
766
|
+
constructor(_dep: NoDep) {}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const result = diagnoseDecoratorMetadata([BrokenService]);
|
|
770
|
+
|
|
771
|
+
expect(result.ok).toBe(false);
|
|
772
|
+
expect(result.classesWithParams).toBe(1);
|
|
773
|
+
expect(result.classesWithMetadata).toBe(0);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('should return ok when at least one class has metadata', () => {
|
|
777
|
+
class DepA {}
|
|
778
|
+
class DepB {}
|
|
779
|
+
|
|
780
|
+
class ServiceA {
|
|
781
|
+
constructor(_dep: DepA) {}
|
|
782
|
+
}
|
|
783
|
+
class ServiceB {
|
|
784
|
+
constructor(_dep: DepB) {}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Only one has metadata — still ok (metadata emission works in principle)
|
|
788
|
+
(globalThis as any).Reflect.defineMetadata('design:paramtypes', [DepA], ServiceA);
|
|
789
|
+
|
|
790
|
+
const result = diagnoseDecoratorMetadata([ServiceA, ServiceB]);
|
|
791
|
+
|
|
792
|
+
expect(result.ok).toBe(true);
|
|
793
|
+
expect(result.classesWithParams).toBe(2);
|
|
794
|
+
expect(result.classesWithMetadata).toBe(1);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('should return ok for empty class list', () => {
|
|
798
|
+
const result = diagnoseDecoratorMetadata([]);
|
|
799
|
+
|
|
800
|
+
expect(result.ok).toBe(true);
|
|
801
|
+
expect(result.classesWithParams).toBe(0);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('should handle mixed classes with and without params', () => {
|
|
805
|
+
class Dep {}
|
|
806
|
+
class NoParamsService {}
|
|
807
|
+
class WithParamsService {
|
|
808
|
+
constructor(_dep: Dep) {}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// No metadata for WithParamsService
|
|
812
|
+
const result = diagnoseDecoratorMetadata([NoParamsService, WithParamsService]);
|
|
813
|
+
|
|
814
|
+
expect(result.ok).toBe(false);
|
|
815
|
+
expect(result.classesWithParams).toBe(1);
|
|
816
|
+
expect(result.classesWithMetadata).toBe(0);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
734
820
|
describe('Metadata storage edge cases', () => {
|
|
735
821
|
test('should handle metadata on object values', () => {
|
|
736
822
|
const objectValue = { value: 42 };
|