@navios/di 0.2.0 → 0.3.0

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 (62) hide show
  1. package/README.md +301 -39
  2. package/docs/README.md +122 -49
  3. package/docs/api-reference.md +763 -0
  4. package/docs/container.md +274 -0
  5. package/docs/examples/basic-usage.mts +97 -0
  6. package/docs/examples/factory-pattern.mts +318 -0
  7. package/docs/examples/injection-tokens.mts +225 -0
  8. package/docs/examples/request-scope-example.mts +254 -0
  9. package/docs/examples/service-lifecycle.mts +359 -0
  10. package/docs/factory.md +584 -0
  11. package/docs/getting-started.md +308 -0
  12. package/docs/injectable.md +496 -0
  13. package/docs/injection-tokens.md +400 -0
  14. package/docs/lifecycle.md +539 -0
  15. package/docs/scopes.md +749 -0
  16. package/lib/_tsup-dts-rollup.d.mts +495 -150
  17. package/lib/_tsup-dts-rollup.d.ts +495 -150
  18. package/lib/index.d.mts +26 -12
  19. package/lib/index.d.ts +26 -12
  20. package/lib/index.js +993 -462
  21. package/lib/index.js.map +1 -1
  22. package/lib/index.mjs +983 -453
  23. package/lib/index.mjs.map +1 -1
  24. package/package.json +2 -2
  25. package/project.json +10 -2
  26. package/src/__tests__/container.spec.mts +1301 -0
  27. package/src/__tests__/factory.spec.mts +137 -0
  28. package/src/__tests__/injectable.spec.mts +32 -88
  29. package/src/__tests__/injection-token.spec.mts +333 -17
  30. package/src/__tests__/request-scope.spec.mts +263 -0
  31. package/src/__type-tests__/factory.spec-d.mts +65 -0
  32. package/src/__type-tests__/inject.spec-d.mts +27 -28
  33. package/src/__type-tests__/injectable.spec-d.mts +42 -206
  34. package/src/container.mts +167 -0
  35. package/src/decorators/factory.decorator.mts +79 -0
  36. package/src/decorators/index.mts +1 -0
  37. package/src/decorators/injectable.decorator.mts +6 -56
  38. package/src/enums/injectable-scope.enum.mts +5 -1
  39. package/src/event-emitter.mts +18 -20
  40. package/src/factory-context.mts +2 -10
  41. package/src/index.mts +3 -2
  42. package/src/injection-token.mts +24 -9
  43. package/src/injector.mts +8 -20
  44. package/src/interfaces/factory.interface.mts +3 -3
  45. package/src/interfaces/index.mts +2 -0
  46. package/src/interfaces/on-service-destroy.interface.mts +3 -0
  47. package/src/interfaces/on-service-init.interface.mts +3 -0
  48. package/src/registry.mts +7 -16
  49. package/src/request-context-holder.mts +145 -0
  50. package/src/service-instantiator.mts +158 -0
  51. package/src/service-locator-event-bus.mts +0 -28
  52. package/src/service-locator-instance-holder.mts +27 -16
  53. package/src/service-locator-manager.mts +84 -0
  54. package/src/service-locator.mts +550 -395
  55. package/src/utils/defer.mts +73 -0
  56. package/src/utils/get-injectors.mts +93 -80
  57. package/src/utils/index.mts +2 -0
  58. package/src/utils/types.mts +52 -0
  59. package/docs/concepts/injectable.md +0 -182
  60. package/docs/concepts/injection-token.md +0 -145
  61. package/src/proxy-service-locator.mts +0 -83
  62. package/src/resolve-service.mts +0 -41
@@ -0,0 +1,1301 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { z } from 'zod/v4'
3
+
4
+ import type { FactoryContext } from '../factory-context.mjs'
5
+ import type {
6
+ Factorable,
7
+ FactorableWithArgs,
8
+ } from '../interfaces/factory.interface.mjs'
9
+
10
+ import { Container } from '../container.mjs'
11
+ import { Factory } from '../decorators/factory.decorator.mjs'
12
+ import { Injectable } from '../decorators/injectable.decorator.mjs'
13
+ import { InjectableScope } from '../enums/injectable-scope.enum.mjs'
14
+ import { getInjectors } from '../index.mjs'
15
+ import { InjectionToken } from '../injection-token.mjs'
16
+ import { asyncInject, inject } from '../injector.mjs'
17
+ import { Registry } from '../registry.mjs'
18
+ import { ServiceLocator } from '../service-locator.mjs'
19
+ import { createDeferred } from '../utils/defer.mjs'
20
+
21
+ describe('Container', () => {
22
+ let container: Container
23
+ let registry: Registry
24
+ let mockLogger: Console
25
+
26
+ beforeEach(() => {
27
+ registry = new Registry()
28
+ mockLogger = {
29
+ log: vi.fn(),
30
+ error: vi.fn(),
31
+ warn: vi.fn(),
32
+ info: vi.fn(),
33
+ debug: vi.fn(),
34
+ } as any
35
+ container = new Container(registry, mockLogger)
36
+ })
37
+
38
+ describe('Basic functionality', () => {
39
+ it('should create container with default registry', () => {
40
+ const defaultContainer = new Container()
41
+ expect(defaultContainer).toBeInstanceOf(Container)
42
+ })
43
+
44
+ it('should create container with custom registry and logger', () => {
45
+ expect(container).toBeInstanceOf(Container)
46
+ expect(container.getServiceLocator()).toBeInstanceOf(ServiceLocator)
47
+ })
48
+
49
+ it('should register itself in the container', async () => {
50
+ const selfInstance = await container.get(Container)
51
+ expect(selfInstance).toBe(container)
52
+ })
53
+
54
+ it('should return the same ServiceLocator instance', () => {
55
+ const serviceLocator1 = container.getServiceLocator()
56
+ const serviceLocator2 = container.getServiceLocator()
57
+ expect(serviceLocator1).toBe(serviceLocator2)
58
+ })
59
+ })
60
+
61
+ describe('Injectable decorator scenarios', () => {
62
+ describe('Singleton scope', () => {
63
+ it('should return the same instance for singleton services', async () => {
64
+ @Injectable({ registry })
65
+ class TestService {
66
+ public id = Math.random()
67
+ }
68
+
69
+ const instance1 = await container.get(TestService)
70
+ const instance2 = await container.get(TestService)
71
+
72
+ expect(instance1).toBeInstanceOf(TestService)
73
+ expect(instance2).toBeInstanceOf(TestService)
74
+ expect(instance1).toBe(instance2)
75
+ expect(instance1.id).toBe(instance2.id)
76
+ })
77
+
78
+ it('should work with default singleton scope', async () => {
79
+ @Injectable({ registry })
80
+ class TestService {
81
+ public id = Math.random()
82
+ }
83
+
84
+ const instance1 = await container.get(TestService)
85
+ const instance2 = await container.get(TestService)
86
+
87
+ expect(instance1).toBe(instance2)
88
+ })
89
+ })
90
+
91
+ describe('Transient scope', () => {
92
+ it('should return different instances for transient services', async () => {
93
+ @Injectable({ registry, scope: InjectableScope.Transient })
94
+ class TestService {
95
+ public id = Math.random()
96
+ }
97
+
98
+ const instance1 = await container.get(TestService)
99
+ const instance2 = await container.get(TestService)
100
+
101
+ expect(instance1).toBeInstanceOf(TestService)
102
+ expect(instance2).toBeInstanceOf(TestService)
103
+ expect(instance1).not.toBe(instance2)
104
+ expect(instance1.id).not.toBe(instance2.id)
105
+ })
106
+ })
107
+
108
+ describe('Custom injection tokens', () => {
109
+ it('should work with string tokens', async () => {
110
+ const token = InjectionToken.create<TestService>('TestService')
111
+
112
+ @Injectable({ token, registry })
113
+ class TestService {
114
+ public value = 'test'
115
+ }
116
+
117
+ const instance = await container.get(token)
118
+ expect(instance).toBeInstanceOf(TestService)
119
+ expect(instance.value).toBe('test')
120
+ })
121
+
122
+ it('should work with symbol tokens', async () => {
123
+ const token = InjectionToken.create<TestService>(Symbol('TestService'))
124
+
125
+ @Injectable({ token, registry })
126
+ class TestService {
127
+ public value = 'test'
128
+ }
129
+
130
+ const instance = await container.get(token)
131
+ expect(instance).toBeInstanceOf(TestService)
132
+ expect(instance.value).toBe('test')
133
+ })
134
+
135
+ it('should work with custom registry', async () => {
136
+ const customRegistry = new Registry()
137
+ const customContainer = new Container(customRegistry, mockLogger)
138
+ const token = InjectionToken.create<CustomService>('CustomService')
139
+
140
+ @Injectable({ token, registry: customRegistry })
141
+ class CustomService {
142
+ public value = 'custom'
143
+ }
144
+
145
+ const instance = await customContainer.get(token)
146
+ expect(instance).toBeInstanceOf(CustomService)
147
+ expect(instance.value).toBe('custom')
148
+ })
149
+ })
150
+
151
+ describe('Constructor arguments with schemas', () => {
152
+ it('should work with required schema arguments', async () => {
153
+ const schema = z.object({
154
+ name: z.string(),
155
+ age: z.number(),
156
+ })
157
+ const token = InjectionToken.create<UserService, typeof schema>(
158
+ 'UserService',
159
+ schema,
160
+ )
161
+
162
+ @Injectable({ token, registry })
163
+ class UserService {
164
+ constructor(public readonly config: z.output<typeof schema>) {}
165
+ }
166
+
167
+ const config = { name: 'John', age: 30 }
168
+ const instance = await container.get(token, config)
169
+
170
+ expect(instance).toBeInstanceOf(UserService)
171
+ expect(instance.config).toEqual(config)
172
+ })
173
+
174
+ it('should work with optional schema arguments', async () => {
175
+ const schema = z
176
+ .object({
177
+ name: z.string(),
178
+ age: z.number().optional(),
179
+ })
180
+ .optional()
181
+ const token = InjectionToken.create<OptionalService, typeof schema>(
182
+ 'OptionalService',
183
+ schema,
184
+ )
185
+
186
+ @Injectable({ token, registry })
187
+ class OptionalService {
188
+ constructor(public readonly config?: z.output<typeof schema>) {}
189
+ }
190
+
191
+ // Test with arguments
192
+ const config = { name: 'John', age: 30 }
193
+ const instance1 = await container.get(token, config)
194
+ expect(instance1).toBeInstanceOf(OptionalService)
195
+ expect(instance1.config).toEqual(config)
196
+
197
+ // Test without arguments
198
+ const instance2 = await container.get(token)
199
+ expect(instance2).toBeInstanceOf(OptionalService)
200
+ expect(instance2.config).toBeUndefined()
201
+ })
202
+
203
+ it('should work with complex nested schemas', async () => {
204
+ const schema = z.object({
205
+ user: z.object({
206
+ name: z.string(),
207
+ preferences: z.object({
208
+ theme: z.enum(['light', 'dark']),
209
+ notifications: z.boolean(),
210
+ }),
211
+ }),
212
+ settings: z.array(z.string()),
213
+ })
214
+ const token = InjectionToken.create<ComplexService, typeof schema>(
215
+ 'ComplexService',
216
+ schema,
217
+ )
218
+
219
+ @Injectable({ token, registry })
220
+ class ComplexService {
221
+ constructor(public readonly config: z.output<typeof schema>) {}
222
+ }
223
+
224
+ const config = {
225
+ user: {
226
+ name: 'Alice',
227
+ preferences: {
228
+ theme: 'dark' as const,
229
+ notifications: true,
230
+ },
231
+ },
232
+ settings: ['setting1', 'setting2'],
233
+ }
234
+
235
+ const instance = await container.get(token, config)
236
+ expect(instance).toBeInstanceOf(ComplexService)
237
+ expect(instance.config).toEqual(config)
238
+ })
239
+ })
240
+ })
241
+
242
+ describe('Factory decorator scenarios', () => {
243
+ describe('Basic factory', () => {
244
+ it('should work with simple factory', async () => {
245
+ @Factory({ registry })
246
+ class TestFactory implements Factorable<TestService> {
247
+ async create() {
248
+ return new TestService()
249
+ }
250
+ }
251
+
252
+ @Injectable({ registry })
253
+ class TestService {
254
+ public value = 'created by factory'
255
+ }
256
+
257
+ const instance = await container.get(TestFactory)
258
+ expect(instance).toBeInstanceOf(TestService)
259
+ expect(instance.value).toBe('created by factory')
260
+ })
261
+
262
+ it('should work with factory returning different instances', async () => {
263
+ @Factory({ scope: InjectableScope.Transient, registry })
264
+ class TestFactory implements Factorable<TestService> {
265
+ async create() {
266
+ return new TestService()
267
+ }
268
+ }
269
+
270
+ @Injectable({ registry })
271
+ class TestService {
272
+ public id = Math.random()
273
+ }
274
+
275
+ const instance1 = await container.get(TestFactory)
276
+ const instance2 = await container.get(TestFactory)
277
+
278
+ expect(instance1).toBeInstanceOf(TestService)
279
+ expect(instance2).toBeInstanceOf(TestService)
280
+ expect(instance1.id).not.toBe(instance2.id)
281
+ })
282
+
283
+ it('should work with factory using context', async () => {
284
+ @Factory({ registry })
285
+ class ContextFactory implements Factorable<TestService> {
286
+ async create(ctx: FactoryContext) {
287
+ const container = await ctx.inject(Container)
288
+ return new TestService(container)
289
+ }
290
+ }
291
+
292
+ @Injectable({ registry })
293
+ class TestService {
294
+ constructor(public readonly container?: Container) {}
295
+ }
296
+
297
+ const instance = await container.get(ContextFactory)
298
+ expect(instance).toBeInstanceOf(TestService)
299
+ expect(instance.container).toBe(container)
300
+ })
301
+ })
302
+
303
+ describe('Factory with arguments', () => {
304
+ it('should work with factory requiring arguments', async () => {
305
+ const schema = z.object({
306
+ name: z.string(),
307
+ value: z.number(),
308
+ })
309
+ const token = InjectionToken.create<TestService, typeof schema>(
310
+ 'ArgFactory',
311
+ schema,
312
+ )
313
+
314
+ @Factory({ token, registry })
315
+ // oxlint-disable-next-line no-unused-vars
316
+ class ArgFactory
317
+ implements FactorableWithArgs<TestService, typeof schema>
318
+ {
319
+ async create(ctx: any, args: z.output<typeof schema>) {
320
+ return new TestService(args.name, args.value)
321
+ }
322
+ }
323
+
324
+ @Injectable({ registry })
325
+ class TestService {
326
+ constructor(
327
+ public readonly name: string,
328
+ public readonly value: number,
329
+ ) {}
330
+ }
331
+
332
+ const args = { name: 'Test', value: 42 }
333
+ const instance = await container.get(token, args)
334
+
335
+ expect(instance).toBeInstanceOf(TestService)
336
+ expect(instance.name).toBe('Test')
337
+ expect(instance.value).toBe(42)
338
+ })
339
+
340
+ it('should work with factory and optional arguments', async () => {
341
+ const schema = z
342
+ .object({
343
+ name: z.string(),
344
+ optional: z.string().optional(),
345
+ })
346
+ .optional()
347
+ const token = InjectionToken.create<TestService, typeof schema>(
348
+ 'OptionalArgFactory',
349
+ schema,
350
+ )
351
+
352
+ @Factory({ token, registry })
353
+ // oxlint-disable-next-line no-unused-vars
354
+ class OptionalArgFactory
355
+ implements FactorableWithArgs<TestService, typeof schema>
356
+ {
357
+ async create(ctx: any, args: z.output<typeof schema>) {
358
+ return new TestService(args?.name || 'default', args?.optional)
359
+ }
360
+ }
361
+
362
+ @Injectable({ registry })
363
+ class TestService {
364
+ constructor(
365
+ public readonly name: string,
366
+ public readonly optional?: string,
367
+ ) {}
368
+ }
369
+
370
+ // Test with arguments
371
+ const args = { name: 'Test', optional: 'value' }
372
+ const instance1 = await container.get(token, args)
373
+ expect(instance1.name).toBe('Test')
374
+ expect(instance1.optional).toBe('value')
375
+
376
+ // Test without arguments
377
+ const instance2 = await container.get(token)
378
+ expect(instance2.name).toBe('default')
379
+ expect(instance2.optional).toBeUndefined()
380
+ })
381
+ })
382
+
383
+ describe('Factory with custom tokens', () => {
384
+ it('should work with factory using custom token', async () => {
385
+ const token = InjectionToken.create<TestService>(
386
+ Symbol('CustomFactory'),
387
+ )
388
+
389
+ @Factory({ token, registry })
390
+ // oxlint-disable-next-line no-unused-vars
391
+ class CustomFactory implements Factorable<TestService> {
392
+ async create() {
393
+ return new TestService('custom')
394
+ }
395
+ }
396
+
397
+ @Injectable({ registry })
398
+ class TestService {
399
+ constructor(public readonly type: string) {}
400
+ }
401
+
402
+ const instance = await container.get(token)
403
+ expect(instance).toBeInstanceOf(TestService)
404
+ expect(instance.type).toBe('custom')
405
+ })
406
+ })
407
+ })
408
+
409
+ describe('Injection token types', () => {
410
+ describe('Bound injection tokens', () => {
411
+ it('should work with bound tokens', async () => {
412
+ const schema = z.object({
413
+ config: z.string(),
414
+ })
415
+ const token = InjectionToken.create<ConfigService, typeof schema>(
416
+ 'ConfigService',
417
+ schema,
418
+ )
419
+
420
+ @Injectable({ token, registry })
421
+ class ConfigService {
422
+ constructor(public readonly config: z.output<typeof schema>) {}
423
+ }
424
+
425
+ const boundToken = InjectionToken.bound(token, {
426
+ config: 'bound-value',
427
+ })
428
+ const instance = await container.get(boundToken)
429
+
430
+ expect(instance).toBeInstanceOf(ConfigService)
431
+ expect(instance.config).toEqual({ config: 'bound-value' })
432
+ })
433
+
434
+ it('should work with bound tokens and factories', async () => {
435
+ const schema = z.object({
436
+ factory: z.string(),
437
+ })
438
+ const token = InjectionToken.create<TestService, typeof schema>(
439
+ 'FactoryService',
440
+ schema,
441
+ )
442
+
443
+ @Factory({ token, registry })
444
+ // oxlint-disable-next-line no-unused-vars
445
+ class FactoryService
446
+ implements FactorableWithArgs<TestService, typeof schema>
447
+ {
448
+ async create(ctx: any, args: z.output<typeof schema>) {
449
+ return new TestService(args.factory)
450
+ }
451
+ }
452
+
453
+ @Injectable({ registry })
454
+ class TestService {
455
+ constructor(public readonly factory: string) {}
456
+ }
457
+
458
+ const boundToken = InjectionToken.bound(token, {
459
+ factory: 'bound-factory',
460
+ })
461
+ const instance = await container.get(boundToken)
462
+
463
+ expect(instance).toBeInstanceOf(TestService)
464
+ expect(instance.factory).toBe('bound-factory')
465
+ })
466
+ })
467
+
468
+ describe('Factory injection tokens', () => {
469
+ it('should work with factory injection tokens', async () => {
470
+ const schema = z.object({
471
+ data: z.string(),
472
+ })
473
+ const token = InjectionToken.create<DataService, typeof schema>(
474
+ 'DataService',
475
+ schema,
476
+ )
477
+
478
+ @Injectable({ token, registry })
479
+ class DataService {
480
+ constructor(public readonly data: z.output<typeof schema>) {}
481
+ }
482
+
483
+ const factoryToken = InjectionToken.factory(token, async () => ({
484
+ data: 'factory-generated',
485
+ }))
486
+
487
+ const instance = await container.get(factoryToken)
488
+ expect(instance).toBeInstanceOf(DataService)
489
+ expect(instance.data).toEqual({ data: 'factory-generated' })
490
+ })
491
+
492
+ it('should resolve factory tokens only once', async () => {
493
+ const schema = z.object({
494
+ counter: z.number(),
495
+ })
496
+ const token = InjectionToken.create<CounterService, typeof schema>(
497
+ 'CounterService',
498
+ schema,
499
+ )
500
+
501
+ @Injectable({ token, registry })
502
+ class CounterService {
503
+ constructor(public readonly counter: z.output<typeof schema>) {}
504
+ }
505
+
506
+ let callCount = 0
507
+ const factoryToken = InjectionToken.factory(token, async () => {
508
+ callCount++
509
+ return { counter: callCount }
510
+ })
511
+
512
+ const instance1 = await container.get(factoryToken)
513
+ const instance2 = await container.get(factoryToken)
514
+
515
+ expect(instance1).toBeInstanceOf(CounterService)
516
+ expect(instance2).toBeInstanceOf(CounterService)
517
+ expect(callCount).toBe(1) // Factory should only be called once
518
+ expect(instance1.counter).toEqual({ counter: 1 })
519
+ expect(instance2.counter).toEqual({ counter: 1 })
520
+ })
521
+ })
522
+ })
523
+
524
+ describe('Complex dependency injection scenarios', () => {
525
+ it('should handle circular dependencies gracefully', async () => {
526
+ @Injectable({ registry })
527
+ class ServiceA {
528
+ serviceB = asyncInject(ServiceB)
529
+ }
530
+
531
+ @Injectable({ registry })
532
+ class ServiceB {
533
+ serviceA = asyncInject(ServiceA)
534
+ }
535
+
536
+ // This should not throw but handle the circular dependency
537
+ const serviceA = await container.get(ServiceA)
538
+ expect(serviceA).toBeInstanceOf(ServiceA)
539
+ expect(serviceA.serviceB).toBeInstanceOf(Promise)
540
+ const serviceB = await serviceA.serviceB
541
+ expect(serviceB).toBeInstanceOf(ServiceB)
542
+ })
543
+
544
+ it('should handle deep dependency chains', async () => {
545
+ @Injectable({ registry })
546
+ class Level1 {
547
+ public value = 'level1'
548
+ }
549
+
550
+ @Injectable({ registry })
551
+ class Level2 {
552
+ level1 = inject(Level1)
553
+ }
554
+
555
+ @Injectable({ registry })
556
+ class Level3 {
557
+ level2 = inject(Level2)
558
+ }
559
+
560
+ @Injectable({ registry })
561
+ class Level4 {
562
+ level3 = inject(Level3)
563
+ }
564
+
565
+ const level4 = await container.get(Level4)
566
+ expect(level4).toBeInstanceOf(Level4)
567
+
568
+ const level3 = await level4.level3
569
+ expect(level3).toBeInstanceOf(Level3)
570
+
571
+ const level2 = await level3.level2
572
+ expect(level2).toBeInstanceOf(Level2)
573
+
574
+ const level1 = await level2.level1
575
+ expect(level1).toBeInstanceOf(Level1)
576
+ expect(level1.value).toBe('level1')
577
+ })
578
+
579
+ it('should handle async factory dependencies', async () => {
580
+ @Injectable({ registry })
581
+ class DatabaseService {
582
+ public async connect() {
583
+ return 'connected'
584
+ }
585
+ }
586
+
587
+ @Factory({ registry })
588
+ class DatabaseFactory implements Factorable<DatabaseService> {
589
+ async create() {
590
+ const db = new DatabaseService()
591
+ await db.connect()
592
+ return db
593
+ }
594
+ }
595
+
596
+ @Injectable({ registry })
597
+ class AppService {
598
+ database = asyncInject(DatabaseFactory)
599
+ }
600
+
601
+ const app = await container.get(AppService)
602
+ const database = await app.database
603
+ expect(database).toBeInstanceOf(DatabaseService)
604
+ })
605
+ })
606
+
607
+ describe('Error handling and edge cases', () => {
608
+ it('should throw error for unregistered service', async () => {
609
+ class UnregisteredService {}
610
+
611
+ await expect(container.get(UnregisteredService)).rejects.toThrow()
612
+ })
613
+
614
+ it('should throw error for invalid arguments', async () => {
615
+ const schema = z.object({
616
+ required: z.string(),
617
+ })
618
+ const token = InjectionToken.create<RequiredService, typeof schema>(
619
+ 'RequiredService',
620
+ schema,
621
+ )
622
+
623
+ @Injectable({ token, registry })
624
+ class RequiredService {
625
+ constructor(public readonly config: z.output<typeof schema>) {}
626
+ }
627
+
628
+ await expect(container.get(token)).rejects.toThrow()
629
+ // @ts-expect-error This is a test
630
+ await expect(container.get(token, { invalid: 'arg' })).rejects.toThrow()
631
+ })
632
+
633
+ it.skip('should handle factory errors', async () => {
634
+ @Factory({ registry })
635
+ class ErrorFactory implements Factorable<TestService> {
636
+ async create() {
637
+ throw new Error('Factory error')
638
+ }
639
+ }
640
+
641
+ @Injectable({ registry })
642
+ class TestService {}
643
+
644
+ await expect(container.get(ErrorFactory)).rejects.toThrow('Factory error')
645
+ })
646
+
647
+ it.skip('should handle async factory errors', async () => {
648
+ @Factory({ registry })
649
+ class AsyncErrorFactory implements Factorable<TestService> {
650
+ async create() {
651
+ await new Promise((resolve) => setTimeout(resolve, 10))
652
+ throw new Error('Async factory error')
653
+ }
654
+ }
655
+
656
+ @Injectable({ registry })
657
+ class TestService {}
658
+
659
+ await expect(container.get(AsyncErrorFactory)).rejects.toThrow(
660
+ 'Async factory error',
661
+ )
662
+ })
663
+
664
+ it('should handle invalid schema validation', async () => {
665
+ const schema = z.object({
666
+ email: z.string().email(),
667
+ })
668
+ const token = InjectionToken.create<EmailService, typeof schema>(
669
+ 'EmailService',
670
+ schema,
671
+ )
672
+
673
+ @Injectable({ token, registry })
674
+ class EmailService {
675
+ constructor(public readonly config: z.output<typeof schema>) {}
676
+ }
677
+
678
+ await expect(
679
+ container.get(token, { email: 'invalid-email' }),
680
+ ).rejects.toThrow()
681
+ })
682
+ })
683
+
684
+ describe('Service invalidation', () => {
685
+ it('should invalidate singleton services', async () => {
686
+ @Injectable({ registry })
687
+ class TestService {
688
+ public id = Math.random()
689
+ }
690
+
691
+ const instance1 = await container.get(TestService)
692
+ await container.invalidate(instance1)
693
+
694
+ const instance2 = await container.get(TestService)
695
+ expect(instance1).not.toBe(instance2)
696
+ expect(instance1.id).not.toBe(instance2.id)
697
+ })
698
+
699
+ it('should invalidate services with dependencies', async () => {
700
+ @Injectable({ registry, scope: InjectableScope.Transient })
701
+ class DependencyService {
702
+ public id = Math.random()
703
+ }
704
+
705
+ @Injectable({ registry })
706
+ class MainService {
707
+ dependency = asyncInject(DependencyService)
708
+ public id = Math.random()
709
+ }
710
+
711
+ const main1 = await container.get(MainService)
712
+ const dep1 = await main1.dependency
713
+
714
+ await container.invalidate(main1)
715
+
716
+ const main2 = await container.get(MainService)
717
+ const dep2 = await main2.dependency
718
+
719
+ expect(main1).not.toBe(main2)
720
+ expect(dep1).not.toBe(dep2)
721
+ })
722
+
723
+ it('should handle invalidation of non-existent service', async () => {
724
+ const fakeService = { id: 'fake' }
725
+
726
+ // Should not throw
727
+ await expect(container.invalidate(fakeService)).resolves.toBeUndefined()
728
+ })
729
+
730
+ it('should invalidate factory services', async () => {
731
+ @Factory({ scope: InjectableScope.Singleton, registry })
732
+ class TestFactory implements Factorable<TestService> {
733
+ async create() {
734
+ return new TestService()
735
+ }
736
+ }
737
+
738
+ @Injectable({ registry })
739
+ class TestService {
740
+ public id = Math.random()
741
+ }
742
+
743
+ const instance1 = await container.get(TestFactory)
744
+ await container.invalidate(instance1)
745
+
746
+ const instance2 = await container.get(TestFactory)
747
+ expect(instance1).not.toBe(instance2)
748
+ expect(instance1.id).not.toBe(instance2.id)
749
+ })
750
+ })
751
+
752
+ describe('Ready method and async operations', () => {
753
+ it('should wait for all pending operations', async () => {
754
+ const deferred = createDeferred<string>()
755
+
756
+ @Factory({ registry })
757
+ class AsyncFactory implements Factorable<TestService> {
758
+ async create() {
759
+ const result = await deferred.promise
760
+ return new TestService(result)
761
+ }
762
+ }
763
+
764
+ @Injectable({ registry })
765
+ class TestService {
766
+ constructor(public readonly value: string) {}
767
+ }
768
+
769
+ const promise = container.get(AsyncFactory)
770
+
771
+ // Should not be ready yet
772
+ await expect(container.ready()).resolves.toBeUndefined()
773
+
774
+ // Resolve the deferred
775
+ deferred.resolve('async-result')
776
+
777
+ const instance = await promise
778
+ expect(instance.value).toBe('async-result')
779
+ })
780
+
781
+ it('should handle multiple concurrent operations', async () => {
782
+ const deferred1 = createDeferred<string>()
783
+ const deferred2 = createDeferred<string>()
784
+
785
+ @Factory({ registry })
786
+ class AsyncFactory1 implements Factorable<TestService> {
787
+ async create() {
788
+ const result = await deferred1.promise
789
+ return new TestService(result)
790
+ }
791
+ }
792
+
793
+ @Factory({ registry })
794
+ class AsyncFactory2 implements Factorable<TestService> {
795
+ async create() {
796
+ const result = await deferred2.promise
797
+ return new TestService(result)
798
+ }
799
+ }
800
+
801
+ @Injectable({ registry })
802
+ class TestService {
803
+ constructor(public readonly value: string) {}
804
+ }
805
+
806
+ const promise1 = container.get(AsyncFactory1)
807
+ const promise2 = container.get(AsyncFactory2)
808
+
809
+ // Resolve both
810
+ deferred1.resolve('result1')
811
+ deferred2.resolve('result2')
812
+
813
+ const [instance1, instance2] = await Promise.all([promise1, promise2])
814
+
815
+ expect(instance1.value).toBe('result1')
816
+ expect(instance2.value).toBe('result2')
817
+ })
818
+
819
+ it.skip('should handle factory errors in ready state', async () => {
820
+ const deferred = createDeferred<string>()
821
+
822
+ @Factory({ registry })
823
+ class ErrorFactory implements Factorable<TestService> {
824
+ async create() {
825
+ const result = await deferred.promise
826
+ if (result === 'error') {
827
+ throw new Error('Factory error')
828
+ }
829
+ return new TestService(result)
830
+ }
831
+ }
832
+
833
+ @Injectable({ registry })
834
+ class TestService {
835
+ constructor(public readonly value: string) {}
836
+ }
837
+
838
+ const promise = container.get(ErrorFactory)
839
+
840
+ // Reject the deferred
841
+ deferred.reject(new Error('Deferred error'))
842
+
843
+ await expect(promise).rejects.toThrow('Deferred error')
844
+ })
845
+ })
846
+
847
+ describe('Type safety and overloads', () => {
848
+ it('should work with class type overload', async () => {
849
+ @Injectable({ registry })
850
+ class TestService {
851
+ public value = 'test'
852
+ }
853
+
854
+ const instance = await container.get(TestService)
855
+ expect(instance).toBeInstanceOf(TestService)
856
+ expect(instance.value).toBe('test')
857
+ })
858
+
859
+ it('should work with token with required schema overload', async () => {
860
+ const schema = z.object({
861
+ name: z.string(),
862
+ })
863
+ const token = InjectionToken.create<RequiredService, typeof schema>(
864
+ 'RequiredService',
865
+ schema,
866
+ )
867
+
868
+ @Injectable({ token, registry })
869
+ class RequiredService {
870
+ constructor(public readonly config: z.output<typeof schema>) {}
871
+ }
872
+
873
+ const instance = await container.get(token, { name: 'test' })
874
+ expect(instance).toBeInstanceOf(RequiredService)
875
+ expect(instance.config.name).toBe('test')
876
+ })
877
+
878
+ it('should work with token with optional schema overload', async () => {
879
+ const schema = z
880
+ .object({
881
+ name: z.string(),
882
+ })
883
+ .optional()
884
+ const token = InjectionToken.create<OptionalService, typeof schema>(
885
+ 'OptionalService',
886
+ schema,
887
+ )
888
+
889
+ @Injectable({ token, registry })
890
+ class OptionalService {
891
+ constructor(public readonly config?: z.output<typeof schema>) {}
892
+ }
893
+
894
+ const instance = await container.get(token)
895
+ expect(instance).toBeInstanceOf(OptionalService)
896
+ expect(instance.config).toBeUndefined()
897
+ })
898
+
899
+ it('should work with token with no schema overload', async () => {
900
+ const token = InjectionToken.create<NoSchemaService>('NoSchemaService')
901
+
902
+ @Injectable({ token, registry })
903
+ class NoSchemaService {
904
+ public value = 'no-schema'
905
+ }
906
+
907
+ const instance = await container.get(token)
908
+ expect(instance).toBeInstanceOf(NoSchemaService)
909
+ expect(instance.value).toBe('no-schema')
910
+ })
911
+
912
+ it('should work with bound injection token overload', async () => {
913
+ const schema = z.object({
914
+ value: z.string(),
915
+ })
916
+ const token = InjectionToken.create<BoundService, typeof schema>(
917
+ 'BoundService',
918
+ schema,
919
+ )
920
+
921
+ @Injectable({ token, registry })
922
+ class BoundService {
923
+ constructor(public readonly config: z.output<typeof schema>) {}
924
+ }
925
+
926
+ const boundToken = InjectionToken.bound(token, { value: 'bound' })
927
+ const instance = await container.get(boundToken)
928
+
929
+ expect(instance).toBeInstanceOf(BoundService)
930
+ expect(instance.config.value).toBe('bound')
931
+ })
932
+
933
+ it('should work with factory injection token overload', async () => {
934
+ const schema = z.object({
935
+ data: z.string(),
936
+ })
937
+ const token = InjectionToken.create<FactoryService, typeof schema>(
938
+ 'FactoryService',
939
+ schema,
940
+ )
941
+
942
+ @Injectable({ token, registry })
943
+ class FactoryService {
944
+ constructor(public readonly config: z.output<typeof schema>) {}
945
+ }
946
+
947
+ const factoryToken = InjectionToken.factory(token, async () => ({
948
+ data: 'factory-data',
949
+ }))
950
+
951
+ const instance = await container.get(factoryToken)
952
+ expect(instance).toBeInstanceOf(FactoryService)
953
+ expect(instance.config.data).toBe('factory-data')
954
+ })
955
+ })
956
+
957
+ describe('Performance and memory', () => {
958
+ it('should handle large number of services efficiently', async () => {
959
+ const services: any[] = []
960
+
961
+ // Create 100 services
962
+ for (let i = 0; i < 100; i++) {
963
+ @Injectable({ registry })
964
+ class TestService {
965
+ public id = i
966
+ }
967
+ services.push(TestService)
968
+ }
969
+
970
+ // Get all services
971
+ const instances = await Promise.all(
972
+ services.map((Service) => container.get(Service)),
973
+ )
974
+
975
+ expect(instances).toHaveLength(100)
976
+ instances.forEach((instance, index) => {
977
+ expect(instance.id).toBe(index)
978
+ })
979
+ })
980
+ })
981
+
982
+ describe('inject scenarios', () => {
983
+ describe('Singleton scope with inject', () => {
984
+ it('should return same instances for singleton services with inject', async () => {
985
+ @Injectable({ registry })
986
+ class SingletonService {
987
+ public id = Math.random()
988
+ }
989
+
990
+ @Injectable({ registry })
991
+ class ServiceWithSyncInject {
992
+ singletonService = inject(SingletonService)
993
+ }
994
+
995
+ const instance1 = await container.get(ServiceWithSyncInject)
996
+ const instance2 = await container.get(ServiceWithSyncInject)
997
+
998
+ expect(instance1).toBeInstanceOf(ServiceWithSyncInject)
999
+ expect(instance2).toBeInstanceOf(ServiceWithSyncInject)
1000
+ expect(instance1).toBe(instance2) // ServiceWithSyncInject is singleton
1001
+
1002
+ // The singleton service should be the same instance
1003
+ expect(instance1.singletonService).toBeInstanceOf(SingletonService)
1004
+ expect(instance2.singletonService).toBeInstanceOf(SingletonService)
1005
+ expect(instance1.singletonService).toBe(instance2.singletonService)
1006
+ expect(instance1.singletonService.id).toBe(
1007
+ instance2.singletonService.id,
1008
+ )
1009
+ })
1010
+
1011
+ it('should handle nested singleton services with inject', async () => {
1012
+ @Injectable({ registry })
1013
+ class Level1Singleton {
1014
+ public id = Math.random()
1015
+ }
1016
+
1017
+ @Injectable({ registry })
1018
+ class Level2Singleton {
1019
+ level1 = inject(Level1Singleton)
1020
+ public id = Math.random()
1021
+ }
1022
+
1023
+ @Injectable({ registry })
1024
+ class RootService {
1025
+ level2 = inject(Level2Singleton)
1026
+ }
1027
+
1028
+ const root1 = await container.get(RootService)
1029
+ const root2 = await container.get(RootService)
1030
+
1031
+ expect(root1).toBe(root2) // RootService is singleton
1032
+
1033
+ // Level2 should be the same instances
1034
+ expect(root1.level2).toBe(root2.level2)
1035
+ expect(root1.level2.id).toBe(root2.level2.id)
1036
+
1037
+ // Level1 should also be the same instances
1038
+ expect(root1.level2.level1).toBe(root2.level2.level1)
1039
+ expect(root1.level2.level1.id).toBe(root2.level2.level1.id)
1040
+ })
1041
+
1042
+ it('should handle mixed singleton services with inject', async () => {
1043
+ @Injectable({ registry })
1044
+ class SingletonService1 {
1045
+ public id = Math.random()
1046
+ }
1047
+
1048
+ @Injectable({ registry })
1049
+ class SingletonService2 {
1050
+ singleton1 = inject(SingletonService1)
1051
+ public id = Math.random()
1052
+ }
1053
+
1054
+ @Injectable({ registry })
1055
+ class MixedService {
1056
+ singleton2 = inject(SingletonService2)
1057
+ }
1058
+
1059
+ const mixed1 = await container.get(MixedService)
1060
+ const mixed2 = await container.get(MixedService)
1061
+
1062
+ expect(mixed1).toBe(mixed2) // MixedService is singleton
1063
+
1064
+ // SingletonService2 should be the same instances
1065
+ expect(mixed1.singleton2).toBe(mixed2.singleton2)
1066
+ expect(mixed1.singleton2.id).toBe(mixed2.singleton2.id)
1067
+
1068
+ // SingletonService1 should also be the same instance
1069
+ expect(mixed1.singleton2.singleton1).toBe(mixed2.singleton2.singleton1)
1070
+ expect(mixed1.singleton2.singleton1.id).toBe(
1071
+ mixed2.singleton2.singleton1.id,
1072
+ )
1073
+ })
1074
+ })
1075
+
1076
+ describe('inject with invalidation', () => {
1077
+ it('should invalidate singleton services accessed via inject', async () => {
1078
+ @Injectable({ registry })
1079
+ class SingletonService {
1080
+ public id = Math.random()
1081
+ }
1082
+
1083
+ @Injectable({ registry })
1084
+ class ServiceWithSyncInject {
1085
+ singletonService = inject(SingletonService)
1086
+ }
1087
+
1088
+ const instance1 = await container.get(ServiceWithSyncInject)
1089
+ const singleton1 = instance1.singletonService
1090
+
1091
+ // Invalidate the singleton service
1092
+ await container.invalidate(singleton1)
1093
+
1094
+ const instance2 = await container.get(ServiceWithSyncInject)
1095
+ const singleton2 = instance2.singletonService
1096
+
1097
+ // Should get a new singleton instance
1098
+ expect(singleton1).not.toBe(singleton2)
1099
+ expect(singleton1.id).not.toBe(singleton2.id)
1100
+ })
1101
+
1102
+ it('should invalidate services with nested inject dependencies', async () => {
1103
+ @Injectable({ registry })
1104
+ class Level1Service {
1105
+ public id = Math.random()
1106
+ }
1107
+
1108
+ @Injectable({ registry })
1109
+ class Level2Service {
1110
+ level1 = inject(Level1Service)
1111
+ public id = Math.random()
1112
+ }
1113
+
1114
+ @Injectable({ registry })
1115
+ class RootService {
1116
+ level2 = inject(Level2Service)
1117
+ }
1118
+
1119
+ const root1 = await container.get(RootService)
1120
+ const level2_1 = root1.level2
1121
+ const level1_1 = level2_1.level1
1122
+
1123
+ // Invalidate the root service
1124
+ await container.invalidate(level1_1)
1125
+
1126
+ const root2 = await container.get(RootService)
1127
+ const level2_2 = root2.level2
1128
+ const level1_2 = level2_2.level1
1129
+
1130
+ // All should be new instances
1131
+ expect(root1).not.toBe(root2)
1132
+ expect(level2_1).not.toBe(level2_2)
1133
+ expect(level1_1).not.toBe(level1_2)
1134
+ expect(level2_1.id).not.toBe(level2_2.id)
1135
+ expect(level1_1.id).not.toBe(level1_2.id)
1136
+ })
1137
+
1138
+ it('should handle invalidation of services with mixed inject and asyncInject', async () => {
1139
+ @Injectable({ registry })
1140
+ class AsyncService {
1141
+ public id = Math.random()
1142
+ }
1143
+
1144
+ @Injectable({ registry })
1145
+ class SyncService {
1146
+ public id = Math.random()
1147
+ }
1148
+
1149
+ @Injectable({ registry })
1150
+ class MixedService {
1151
+ asyncService = asyncInject(AsyncService)
1152
+ syncService = inject(SyncService)
1153
+ }
1154
+
1155
+ const mixed1 = await container.get(MixedService)
1156
+ const async1 = await mixed1.asyncService
1157
+ const sync1 = mixed1.syncService
1158
+
1159
+ // Invalidate the mixed service
1160
+ await container.invalidate(mixed1)
1161
+
1162
+ const mixed2 = await container.get(MixedService)
1163
+ const async2 = await mixed2.asyncService
1164
+ const sync2 = mixed2.syncService
1165
+
1166
+ // All should be new instances
1167
+ expect(mixed1).not.toBe(mixed2)
1168
+ expect(async1).toBe(async2)
1169
+ expect(sync1).toBe(sync2)
1170
+ })
1171
+
1172
+ it('should handle invalidation of factory services accessed via inject', async () => {
1173
+ @Injectable({ registry })
1174
+ class TestService {
1175
+ public id = Math.random()
1176
+ }
1177
+
1178
+ @Factory({ registry })
1179
+ class TestFactory implements Factorable<TestService> {
1180
+ async create() {
1181
+ return new TestService()
1182
+ }
1183
+ }
1184
+
1185
+ @Injectable({ registry })
1186
+ class ServiceWithSyncInject {
1187
+ factoryService = inject(TestFactory)
1188
+ }
1189
+
1190
+ const instance1 = await container.get(ServiceWithSyncInject)
1191
+ const factory1 = instance1.factoryService
1192
+
1193
+ // Invalidate the factory service
1194
+ await container.invalidate(factory1)
1195
+
1196
+ const instance2 = await container.get(ServiceWithSyncInject)
1197
+ const factory2 = instance2.factoryService
1198
+
1199
+ // Should get a new factory instance
1200
+ expect(factory1).not.toBe(factory2)
1201
+ expect(factory1.id).not.toBe(factory2.id)
1202
+ })
1203
+
1204
+ it('should handle invalidation of services with complex dependency chains using inject', async () => {
1205
+ @Injectable({ registry })
1206
+ class DatabaseService {
1207
+ public id = Math.random()
1208
+ public async connect() {
1209
+ return 'connected'
1210
+ }
1211
+ }
1212
+
1213
+ @Injectable({ registry })
1214
+ class CacheService {
1215
+ public id = Math.random()
1216
+ }
1217
+
1218
+ @Injectable({ registry })
1219
+ class UserService {
1220
+ database = inject(DatabaseService)
1221
+ cache = inject(CacheService)
1222
+ public id = Math.random()
1223
+ }
1224
+
1225
+ @Injectable({ registry })
1226
+ class AuthService {
1227
+ userService = inject(UserService)
1228
+ public id = Math.random()
1229
+ }
1230
+
1231
+ @Injectable({ registry })
1232
+ class AppService {
1233
+ authService = inject(AuthService)
1234
+ }
1235
+
1236
+ const app1 = await container.get(AppService)
1237
+ const auth1 = app1.authService
1238
+ const user1 = auth1.userService
1239
+ const db1 = user1.database
1240
+ const cache1 = user1.cache
1241
+
1242
+ // Invalidate the user service
1243
+ await container.invalidate(user1)
1244
+
1245
+ const app2 = await container.get(AppService)
1246
+ const auth2 = app2.authService
1247
+ const user2 = auth2.userService
1248
+ const db2 = user2.database
1249
+ const cache2 = user2.cache
1250
+
1251
+ // User service should be new instance but its dependencies should be the same
1252
+ expect(user1).not.toBe(user2)
1253
+ expect(db1).toBe(db2)
1254
+ expect(cache1).toBe(cache2)
1255
+ expect(user1.id).not.toBe(user2.id)
1256
+ expect(db1.id).toBe(db2.id)
1257
+ expect(cache1.id).toBe(cache2.id)
1258
+
1259
+ // Auth service should also be new since it depends on user service
1260
+ expect(auth1).not.toBe(auth2)
1261
+ expect(auth1.id).not.toBe(auth2.id)
1262
+
1263
+ // App service should also be new since it depends on auth service
1264
+ expect(app1).not.toBe(app2)
1265
+ })
1266
+ })
1267
+
1268
+ describe('inject error handling', () => {
1269
+ it('should handle unregistered services with inject', async () => {
1270
+ class UnregisteredService {}
1271
+
1272
+ @Injectable({ registry })
1273
+ class ServiceWithUnregistered {
1274
+ unregistered = inject(UnregisteredService)
1275
+ }
1276
+
1277
+ // This should throw during instantiation, not during get()
1278
+ await expect(container.get(ServiceWithUnregistered)).rejects.toThrow()
1279
+ })
1280
+ })
1281
+ })
1282
+ describe('custom injectors', () => {
1283
+ it('should work with custom injectors', async () => {
1284
+ const injectors = getInjectors()
1285
+ const container = new Container(registry, mockLogger, injectors)
1286
+ expect(container).toBeInstanceOf(Container)
1287
+ const { inject } = injectors
1288
+ @Injectable({ registry })
1289
+ class TestService {
1290
+ test = 'a'
1291
+ }
1292
+ @Injectable({ registry })
1293
+ class TestService2 {
1294
+ test = inject(TestService)
1295
+ }
1296
+ const instance = await container.get(TestService2)
1297
+ expect(instance).toBeInstanceOf(TestService2)
1298
+ expect(instance.test.test).toBe('a')
1299
+ })
1300
+ })
1301
+ })