@navios/di 0.5.1 → 0.6.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 (123) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +196 -219
  3. package/docs/README.md +69 -11
  4. package/docs/api-reference.md +281 -117
  5. package/docs/container.md +220 -56
  6. package/docs/examples/request-scope-example.mts +2 -2
  7. package/docs/factory.md +3 -8
  8. package/docs/getting-started.md +37 -8
  9. package/docs/migration.md +318 -37
  10. package/docs/request-contexts.md +263 -175
  11. package/docs/scopes.md +79 -42
  12. package/lib/browser/index.d.mts +1577 -0
  13. package/lib/browser/index.d.mts.map +1 -0
  14. package/lib/browser/index.mjs +3012 -0
  15. package/lib/browser/index.mjs.map +1 -0
  16. package/lib/index-S_qX2VLI.d.mts +1211 -0
  17. package/lib/index-S_qX2VLI.d.mts.map +1 -0
  18. package/lib/index-fKPuT65j.d.cts +1206 -0
  19. package/lib/index-fKPuT65j.d.cts.map +1 -0
  20. package/lib/index.cjs +389 -0
  21. package/lib/index.cjs.map +1 -0
  22. package/lib/index.d.cts +376 -0
  23. package/lib/index.d.cts.map +1 -0
  24. package/lib/index.d.mts +371 -78
  25. package/lib/index.d.mts.map +1 -0
  26. package/lib/index.mjs +325 -63
  27. package/lib/index.mjs.map +1 -1
  28. package/lib/testing/index.cjs +9 -0
  29. package/lib/testing/index.d.cts +2 -0
  30. package/lib/testing/index.d.mts +2 -2
  31. package/lib/testing/index.mjs +2 -72
  32. package/lib/testing-BMGmmxH7.cjs +2895 -0
  33. package/lib/testing-BMGmmxH7.cjs.map +1 -0
  34. package/lib/testing-DCXz8AJD.mjs +2655 -0
  35. package/lib/testing-DCXz8AJD.mjs.map +1 -0
  36. package/package.json +23 -1
  37. package/project.json +2 -2
  38. package/src/__tests__/async-local-storage.browser.spec.mts +240 -0
  39. package/src/__tests__/async-local-storage.spec.mts +333 -0
  40. package/src/__tests__/container.spec.mts +30 -25
  41. package/src/__tests__/e2e.browser.spec.mts +790 -0
  42. package/src/__tests__/e2e.spec.mts +1222 -0
  43. package/src/__tests__/errors.spec.mts +6 -6
  44. package/src/__tests__/factory.spec.mts +1 -1
  45. package/src/__tests__/get-injectors.spec.mts +1 -1
  46. package/src/__tests__/injectable.spec.mts +1 -1
  47. package/src/__tests__/injection-token.spec.mts +1 -1
  48. package/src/__tests__/library-findings.spec.mts +563 -0
  49. package/src/__tests__/registry.spec.mts +2 -2
  50. package/src/__tests__/request-scope.spec.mts +266 -274
  51. package/src/__tests__/service-instantiator.spec.mts +18 -17
  52. package/src/__tests__/service-locator-event-bus.spec.mts +9 -9
  53. package/src/__tests__/service-locator-manager.spec.mts +15 -15
  54. package/src/__tests__/service-locator.spec.mts +167 -244
  55. package/src/__tests__/unified-api.spec.mts +27 -27
  56. package/src/__type-tests__/factory.spec-d.mts +2 -2
  57. package/src/__type-tests__/inject.spec-d.mts +2 -2
  58. package/src/__type-tests__/injectable.spec-d.mts +1 -1
  59. package/src/browser.mts +16 -0
  60. package/src/container/container.mts +319 -0
  61. package/src/container/index.mts +2 -0
  62. package/src/container/scoped-container.mts +350 -0
  63. package/src/decorators/factory.decorator.mts +4 -4
  64. package/src/decorators/injectable.decorator.mts +5 -5
  65. package/src/errors/di-error.mts +13 -7
  66. package/src/errors/index.mts +0 -8
  67. package/src/index.mts +156 -15
  68. package/src/interfaces/container.interface.mts +82 -0
  69. package/src/interfaces/factory.interface.mts +2 -2
  70. package/src/interfaces/index.mts +1 -0
  71. package/src/internal/context/async-local-storage.mts +120 -0
  72. package/src/internal/context/factory-context.mts +18 -0
  73. package/src/internal/context/index.mts +3 -0
  74. package/src/{request-context-holder.mts → internal/context/request-context.mts} +40 -27
  75. package/src/internal/context/resolution-context.mts +63 -0
  76. package/src/internal/context/sync-local-storage.mts +51 -0
  77. package/src/internal/core/index.mts +5 -0
  78. package/src/internal/core/instance-resolver.mts +641 -0
  79. package/src/{service-instantiator.mts → internal/core/instantiator.mts} +31 -27
  80. package/src/internal/core/invalidator.mts +437 -0
  81. package/src/internal/core/service-locator.mts +202 -0
  82. package/src/{token-processor.mts → internal/core/token-processor.mts} +79 -60
  83. package/src/{base-instance-holder-manager.mts → internal/holder/base-holder-manager.mts} +91 -21
  84. package/src/internal/holder/holder-manager.mts +85 -0
  85. package/src/internal/holder/holder-storage.interface.mts +116 -0
  86. package/src/internal/holder/index.mts +6 -0
  87. package/src/internal/holder/instance-holder.mts +109 -0
  88. package/src/internal/holder/request-storage.mts +134 -0
  89. package/src/internal/holder/singleton-storage.mts +105 -0
  90. package/src/internal/index.mts +4 -0
  91. package/src/internal/lifecycle/circular-detector.mts +77 -0
  92. package/src/internal/lifecycle/index.mts +2 -0
  93. package/src/{service-locator-event-bus.mts → internal/lifecycle/lifecycle-event-bus.mts} +11 -4
  94. package/src/testing/__tests__/test-container.spec.mts +2 -2
  95. package/src/testing/test-container.mts +4 -4
  96. package/src/token/index.mts +2 -0
  97. package/src/{injection-token.mts → token/injection-token.mts} +1 -1
  98. package/src/{registry.mts → token/registry.mts} +1 -1
  99. package/src/utils/get-injectable-token.mts +1 -1
  100. package/src/utils/get-injectors.mts +32 -15
  101. package/src/utils/types.mts +1 -1
  102. package/tsdown.config.mts +67 -0
  103. package/lib/_tsup-dts-rollup.d.mts +0 -1283
  104. package/lib/_tsup-dts-rollup.d.ts +0 -1283
  105. package/lib/chunk-2M576LCC.mjs +0 -2043
  106. package/lib/chunk-2M576LCC.mjs.map +0 -1
  107. package/lib/index.d.ts +0 -78
  108. package/lib/index.js +0 -2127
  109. package/lib/index.js.map +0 -1
  110. package/lib/testing/index.d.ts +0 -2
  111. package/lib/testing/index.js +0 -2060
  112. package/lib/testing/index.js.map +0 -1
  113. package/lib/testing/index.mjs.map +0 -1
  114. package/src/container.mts +0 -227
  115. package/src/factory-context.mts +0 -8
  116. package/src/instance-resolver.mts +0 -559
  117. package/src/request-context-manager.mts +0 -149
  118. package/src/service-invalidator.mts +0 -429
  119. package/src/service-locator-instance-holder.mts +0 -70
  120. package/src/service-locator-manager.mts +0 -85
  121. package/src/service-locator.mts +0 -246
  122. package/tsup.config.mts +0 -12
  123. /package/src/{injector.mts → injectors.mts} +0 -0
@@ -0,0 +1,1222 @@
1
+ /**
2
+ * End-to-end tests for @navios/di
3
+ *
4
+ * These tests cover:
5
+ * 1. Basic setup and service resolution
6
+ * 2. Mixed scope scenarios (Singleton, Request, Transient)
7
+ * 3. Concurrent request handling
8
+ * 4. Service lifecycle methods (onServiceInit, onServiceDestroy)
9
+ * 5. Invalidation scenarios
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ import { Container } from '../container/container.mjs'
15
+ import { Injectable } from '../decorators/injectable.decorator.mjs'
16
+ import { InjectableScope } from '../enums/index.mjs'
17
+ import { InjectionToken } from '../token/injection-token.mjs'
18
+ import type { OnServiceDestroy } from '../interfaces/on-service-destroy.interface.mjs'
19
+ import type { OnServiceInit } from '../interfaces/on-service-init.interface.mjs'
20
+ import { Registry } from '../token/registry.mjs'
21
+ import { getInjectors } from '../utils/get-injectors.mjs'
22
+
23
+ // ============================================================================
24
+ // TEST UTILITIES
25
+ // ============================================================================
26
+
27
+ function delay(ms: number): Promise<void> {
28
+ return new Promise((resolve) => setTimeout(resolve, ms))
29
+ }
30
+
31
+ function createTestSetup() {
32
+ const registry = new Registry()
33
+ const injectors = getInjectors()
34
+ const container = new Container(registry, null, injectors)
35
+
36
+ return { registry, injectors, container }
37
+ }
38
+
39
+ // ============================================================================
40
+ // SECTION 1: BASIC SETUP TESTS
41
+ // ============================================================================
42
+
43
+ describe('E2E: Basic Setup', () => {
44
+ let registry: Registry
45
+ let container: Container
46
+ let injectors: ReturnType<typeof getInjectors>
47
+
48
+ beforeEach(() => {
49
+ const setup = createTestSetup()
50
+ registry = setup.registry
51
+ container = setup.container
52
+ injectors = setup.injectors
53
+ })
54
+
55
+ afterEach(async () => {
56
+ await container.dispose()
57
+ })
58
+
59
+ describe('Simple service registration and resolution', () => {
60
+ it('should register and resolve a simple singleton service', async () => {
61
+ @Injectable({ scope: InjectableScope.Singleton, registry })
62
+ class SimpleService {
63
+ getValue() {
64
+ return 'hello'
65
+ }
66
+ }
67
+
68
+ const instance = await container.get(SimpleService)
69
+ expect(instance).toBeInstanceOf(SimpleService)
70
+ expect(instance.getValue()).toBe('hello')
71
+ })
72
+
73
+ it('should return the same instance for singleton services', async () => {
74
+ @Injectable({ scope: InjectableScope.Singleton, registry })
75
+ class SingletonService {
76
+ id = Math.random()
77
+ }
78
+
79
+ const instance1 = await container.get(SingletonService)
80
+ const instance2 = await container.get(SingletonService)
81
+
82
+ expect(instance1).toBe(instance2)
83
+ expect(instance1.id).toBe(instance2.id)
84
+ })
85
+
86
+ it('should return different instances for transient services', async () => {
87
+ @Injectable({ scope: InjectableScope.Transient, registry })
88
+ class TransientService {
89
+ id = Math.random()
90
+ }
91
+
92
+ const instance1 = await container.get(TransientService)
93
+ const instance2 = await container.get(TransientService)
94
+
95
+ expect(instance1).not.toBe(instance2)
96
+ expect(instance1.id).not.toBe(instance2.id)
97
+ })
98
+
99
+ it('should resolve services with constructor arguments via schema', async () => {
100
+ // Define schema first, then class, then token to avoid hoisting issues
101
+ const { z } = await import('zod/v4')
102
+ const configSchema = z.object({ port: z.number() })
103
+
104
+ @Injectable({ scope: InjectableScope.Singleton, registry, schema: configSchema })
105
+ class ConfigService {
106
+ constructor(public config: { port: number }) {}
107
+ }
108
+
109
+ const instance = await container.get(ConfigService, { port: 3000 })
110
+ expect(instance.config.port).toBe(3000)
111
+ })
112
+ })
113
+
114
+ describe('Service with dependencies', () => {
115
+ it('should resolve a service that depends on another singleton', async () => {
116
+ @Injectable({ scope: InjectableScope.Singleton, registry })
117
+ class DatabaseService {
118
+ connect() {
119
+ return 'connected'
120
+ }
121
+ }
122
+
123
+ @Injectable({ scope: InjectableScope.Singleton, registry })
124
+ class UserRepository {
125
+ private db = injectors.inject(DatabaseService)
126
+
127
+ async getDatabase() {
128
+ return this.db
129
+ }
130
+ }
131
+
132
+ const repo = await container.get(UserRepository)
133
+ const db = await repo.getDatabase()
134
+ expect(db).toBeInstanceOf(DatabaseService)
135
+ expect(db.connect()).toBe('connected')
136
+ })
137
+
138
+ it('should resolve deep dependency chains', async () => {
139
+ @Injectable({ scope: InjectableScope.Singleton, registry })
140
+ class ServiceLevel1 {
141
+ name = 'Level1'
142
+ }
143
+
144
+ @Injectable({ scope: InjectableScope.Singleton, registry })
145
+ class ServiceLevel2 {
146
+ private level1 = injectors.inject(ServiceLevel1)
147
+ name = 'Level2'
148
+
149
+ async getLevel1() {
150
+ return this.level1
151
+ }
152
+ }
153
+
154
+ @Injectable({ scope: InjectableScope.Singleton, registry })
155
+ class ServiceLevel3 {
156
+ private level2 = injectors.inject(ServiceLevel2)
157
+ name = 'Level3'
158
+
159
+ async getLevel2() {
160
+ return this.level2
161
+ }
162
+ }
163
+
164
+ const level3 = await container.get(ServiceLevel3)
165
+ expect(level3.name).toBe('Level3')
166
+
167
+ const level2 = await level3.getLevel2()
168
+ expect(level2.name).toBe('Level2')
169
+
170
+ const level1 = await level2.getLevel1()
171
+ expect(level1.name).toBe('Level1')
172
+ })
173
+ })
174
+ })
175
+
176
+ // ============================================================================
177
+ // SECTION 2: MIXED SCOPES TESTS
178
+ // ============================================================================
179
+
180
+ describe('E2E: Mixed Scopes', () => {
181
+ let registry: Registry
182
+ let container: Container
183
+ let injectors: ReturnType<typeof getInjectors>
184
+
185
+ beforeEach(() => {
186
+ const setup = createTestSetup()
187
+ registry = setup.registry
188
+ container = setup.container
189
+ injectors = setup.injectors
190
+ })
191
+
192
+ afterEach(async () => {
193
+ await container.dispose()
194
+ })
195
+
196
+ describe('Singleton with Request-scoped dependencies', () => {
197
+ it('should allow singleton to inject request-scoped service within request context', async () => {
198
+ let requestServiceInstanceCount = 0
199
+
200
+ @Injectable({ scope: InjectableScope.Request, registry })
201
+ class RequestContextService {
202
+ id = ++requestServiceInstanceCount
203
+ }
204
+
205
+ @Injectable({ scope: InjectableScope.Singleton, registry })
206
+ class SingletonWithRequestDep {
207
+ private requestCtx = injectors.inject(RequestContextService)
208
+
209
+ async getRequestContext() {
210
+ return this.requestCtx
211
+ }
212
+ }
213
+
214
+ // Create request context
215
+ const scopedContainer = container.beginRequest('request-1')
216
+
217
+ const singleton = await scopedContainer.get(SingletonWithRequestDep)
218
+ const ctx1 = await singleton.getRequestContext()
219
+ const ctx2 = await singleton.getRequestContext()
220
+
221
+ // Within same request, should get same instance
222
+ expect(ctx1.id).toBe(ctx2.id)
223
+
224
+ await scopedContainer.endRequest()
225
+ })
226
+
227
+ it('should create different request-scoped instances for different requests', async () => {
228
+ let instanceCount = 0
229
+
230
+ @Injectable({ scope: InjectableScope.Request, registry })
231
+ class RequestService {
232
+ id = ++instanceCount
233
+ }
234
+
235
+ // First request
236
+ const scoped1 = container.beginRequest('request-1')
237
+ const service1 = await scoped1.get(RequestService)
238
+
239
+ // Second request
240
+ const scoped2 = container.beginRequest('request-2')
241
+ const service2 = await scoped2.get(RequestService)
242
+
243
+ expect(service1.id).toBe(1)
244
+ expect(service2.id).toBe(2)
245
+ expect(service1).not.toBe(service2)
246
+
247
+ await scoped1.endRequest()
248
+ await scoped2.endRequest()
249
+ })
250
+ })
251
+
252
+ describe('Transient within different scopes', () => {
253
+ it('should create new transient instance every time regardless of scope', async () => {
254
+ let instanceCount = 0
255
+
256
+ @Injectable({ scope: InjectableScope.Transient, registry })
257
+ class TransientService {
258
+ id = ++instanceCount
259
+ }
260
+
261
+ // From main container
262
+ const t1 = await container.get(TransientService)
263
+ const t2 = await container.get(TransientService)
264
+
265
+ // From scoped container
266
+ const scoped = container.beginRequest('request-1')
267
+ const t3 = await scoped.get(TransientService)
268
+ const t4 = await scoped.get(TransientService)
269
+
270
+ expect(t1.id).toBe(1)
271
+ expect(t2.id).toBe(2)
272
+ expect(t3.id).toBe(3)
273
+ expect(t4.id).toBe(4)
274
+
275
+ await scoped.endRequest()
276
+ })
277
+ })
278
+
279
+ describe('Complex dependency graphs', () => {
280
+ it('should handle complex dependency graph with all scope types', async () => {
281
+ const creationOrder: string[] = []
282
+
283
+ @Injectable({ scope: InjectableScope.Singleton, registry })
284
+ class DatabaseConnection {
285
+ constructor() {
286
+ creationOrder.push('DatabaseConnection')
287
+ }
288
+ query() {
289
+ return 'query result'
290
+ }
291
+ }
292
+
293
+ @Injectable({ scope: InjectableScope.Request, registry })
294
+ class RequestLogger {
295
+ private db = injectors.inject(DatabaseConnection)
296
+
297
+ constructor() {
298
+ creationOrder.push('RequestLogger')
299
+ }
300
+
301
+ async log(msg: string) {
302
+ const db = await this.db
303
+ return `${msg} - ${db.query()}`
304
+ }
305
+ }
306
+
307
+ @Injectable({ scope: InjectableScope.Transient, registry })
308
+ class ActionHandler {
309
+ private logger = injectors.inject(RequestLogger)
310
+
311
+ constructor() {
312
+ creationOrder.push('ActionHandler')
313
+ }
314
+
315
+ async handle() {
316
+ const log = await this.logger
317
+ return log.log('action')
318
+ }
319
+ }
320
+
321
+ @Injectable({ scope: InjectableScope.Request, registry })
322
+ class RequestProcessor {
323
+ private action = injectors.inject(ActionHandler)
324
+
325
+ constructor() {
326
+ creationOrder.push('RequestProcessor')
327
+ }
328
+
329
+ async process() {
330
+ const handler = await this.action
331
+ return handler.handle()
332
+ }
333
+ }
334
+
335
+ const scoped = container.beginRequest('request-1')
336
+
337
+ const processor = await scoped.get(RequestProcessor)
338
+ const result = await processor.process()
339
+
340
+ expect(result).toBe('action - query result')
341
+ expect(creationOrder).toContain('DatabaseConnection')
342
+ expect(creationOrder).toContain('RequestLogger')
343
+ expect(creationOrder).toContain('ActionHandler')
344
+ expect(creationOrder).toContain('RequestProcessor')
345
+
346
+ await scoped.endRequest()
347
+ })
348
+ })
349
+ })
350
+
351
+ // ============================================================================
352
+ // SECTION 3: CONCURRENT REQUEST TESTS
353
+ // ============================================================================
354
+
355
+ describe('E2E: Concurrent Requests', () => {
356
+ let registry: Registry
357
+ let container: Container
358
+ let injectors: ReturnType<typeof getInjectors>
359
+
360
+ beforeEach(() => {
361
+ const setup = createTestSetup()
362
+ registry = setup.registry
363
+ container = setup.container
364
+ injectors = setup.injectors
365
+ })
366
+
367
+ afterEach(async () => {
368
+ await container.dispose()
369
+ })
370
+
371
+ describe('Parallel request processing', () => {
372
+ it('should handle multiple concurrent requests without interference', async () => {
373
+ let instanceCounter = 0
374
+ const instancesByRequest: Record<string, number[]> = {}
375
+
376
+ @Injectable({ scope: InjectableScope.Request, registry })
377
+ class RequestScopedCounter {
378
+ id = ++instanceCounter
379
+
380
+ constructor() {
381
+ // Intentional delay to simulate async operation
382
+ }
383
+ }
384
+
385
+ @Injectable({ scope: InjectableScope.Request, registry })
386
+ class RequestHandler {
387
+ private counter = injectors.inject(RequestScopedCounter)
388
+
389
+ async handle(requestId: string) {
390
+ await delay(Math.random() * 10)
391
+ const c = await this.counter
392
+ if (!instancesByRequest[requestId]) {
393
+ instancesByRequest[requestId] = []
394
+ }
395
+ instancesByRequest[requestId].push(c.id)
396
+ return c.id
397
+ }
398
+ }
399
+
400
+ // Spawn 5 concurrent requests
401
+ const requests = Array.from({ length: 5 }, (_, i) => {
402
+ const requestId = `request-${i}`
403
+ const scoped = container.beginRequest(requestId)
404
+ return (async () => {
405
+ const handler = await scoped.get(RequestHandler)
406
+ // Call handler multiple times within same request
407
+ await handler.handle(requestId)
408
+ await handler.handle(requestId)
409
+ await handler.handle(requestId)
410
+ await scoped.endRequest()
411
+ return requestId
412
+ })()
413
+ })
414
+
415
+ await Promise.all(requests)
416
+
417
+ // Each request should have used the same counter instance (3 identical IDs)
418
+ for (const [requestId, ids] of Object.entries(instancesByRequest)) {
419
+ expect(ids.length).toBe(3)
420
+ expect(ids[0]).toBe(ids[1])
421
+ expect(ids[1]).toBe(ids[2])
422
+ }
423
+
424
+ // All requests should have different counter instances
425
+ const uniqueCounterIds = new Set(
426
+ Object.values(instancesByRequest).map((ids) => ids[0]),
427
+ )
428
+ expect(uniqueCounterIds.size).toBe(5)
429
+ })
430
+
431
+ it('should share singleton across all concurrent requests', async () => {
432
+ let singletonCreationCount = 0
433
+
434
+ @Injectable({ scope: InjectableScope.Singleton, registry })
435
+ class SharedCache {
436
+ id = ++singletonCreationCount
437
+ private data = new Map<string, any>()
438
+
439
+ set(key: string, value: any) {
440
+ this.data.set(key, value)
441
+ }
442
+
443
+ get(key: string) {
444
+ return this.data.get(key)
445
+ }
446
+ }
447
+
448
+ @Injectable({ scope: InjectableScope.Request, registry })
449
+ class RequestService {
450
+ private cache = injectors.inject(SharedCache)
451
+
452
+ async setInCache(key: string, value: any) {
453
+ const c = await this.cache
454
+ c.set(key, value)
455
+ return c.id
456
+ }
457
+
458
+ async getFromCache(key: string) {
459
+ const c = await this.cache
460
+ return c.get(key)
461
+ }
462
+ }
463
+
464
+ // Start multiple requests that all use the cache
465
+ const requests = Array.from({ length: 10 }, async (_, i) => {
466
+ const scoped = container.beginRequest(`request-${i}`)
467
+ const service = await scoped.get(RequestService)
468
+ const cacheId = await service.setInCache(`key-${i}`, `value-${i}`)
469
+ await scoped.endRequest()
470
+ return cacheId
471
+ })
472
+
473
+ const cacheIds = await Promise.all(requests)
474
+
475
+ // All requests should have used the same singleton
476
+ expect(singletonCreationCount).toBe(1)
477
+ expect(new Set(cacheIds).size).toBe(1)
478
+ })
479
+
480
+ it('should handle race conditions when creating the same singleton', async () => {
481
+ let creationCount = 0
482
+ let constructorCallCount = 0
483
+
484
+ @Injectable({ scope: InjectableScope.Singleton, registry })
485
+ class SlowSingleton {
486
+ id: number
487
+
488
+ constructor() {
489
+ constructorCallCount++
490
+ // Simulate slow initialization
491
+ this.id = ++creationCount
492
+ }
493
+ }
494
+
495
+ // Try to get the same singleton from many places concurrently
496
+ const results = await Promise.all([
497
+ container.get(SlowSingleton),
498
+ container.get(SlowSingleton),
499
+ container.get(SlowSingleton),
500
+ container.get(SlowSingleton),
501
+ container.get(SlowSingleton),
502
+ ])
503
+
504
+ // All should be the same instance
505
+ const uniqueInstances = new Set(results)
506
+ expect(uniqueInstances.size).toBe(1)
507
+ expect(creationCount).toBe(1)
508
+ expect(constructorCallCount).toBe(1)
509
+ })
510
+ })
511
+
512
+ describe('Request isolation', () => {
513
+ it('should not leak request-scoped instances between concurrent requests', async () => {
514
+ @Injectable({ scope: InjectableScope.Request, registry })
515
+ class UserSession {
516
+ constructor(public userId: string = '') {}
517
+
518
+ setUser(id: string) {
519
+ this.userId = id
520
+ }
521
+ }
522
+
523
+ @Injectable({ scope: InjectableScope.Request, registry })
524
+ class RequestProcessor {
525
+ private session = injectors.inject(UserSession)
526
+
527
+ async processForUser(userId: string) {
528
+ const s = await this.session
529
+ s.setUser(userId)
530
+ // Delay to allow other requests to interleave
531
+ await delay(5)
532
+ // Return the user ID we set
533
+ return s.userId
534
+ }
535
+ }
536
+
537
+ // Run concurrent requests with different user IDs
538
+ const results = await Promise.all(
539
+ ['user-A', 'user-B', 'user-C', 'user-D', 'user-E'].map(
540
+ async (userId, i) => {
541
+ const scoped = container.beginRequest(`request-${i}`)
542
+ const processor = await scoped.get(RequestProcessor)
543
+ const result = await processor.processForUser(userId)
544
+ await scoped.endRequest()
545
+ return { expected: userId, actual: result }
546
+ },
547
+ ),
548
+ )
549
+
550
+ // Each request should have its own isolated session
551
+ for (const { expected, actual } of results) {
552
+ expect(actual).toBe(expected)
553
+ }
554
+ })
555
+ })
556
+ })
557
+
558
+ // ============================================================================
559
+ // SECTION 4: SERVICE LIFECYCLE TESTS
560
+ // ============================================================================
561
+
562
+ describe('E2E: Service Lifecycle', () => {
563
+ let registry: Registry
564
+ let container: Container
565
+ let injectors: ReturnType<typeof getInjectors>
566
+
567
+ beforeEach(() => {
568
+ const setup = createTestSetup()
569
+ registry = setup.registry
570
+ container = setup.container
571
+ injectors = setup.injectors
572
+ })
573
+
574
+ afterEach(async () => {
575
+ await container.dispose()
576
+ })
577
+
578
+ describe('OnServiceInit lifecycle', () => {
579
+ it('should call onServiceInit when service is created', async () => {
580
+ const initSpy = vi.fn()
581
+
582
+ @Injectable({ scope: InjectableScope.Singleton, registry })
583
+ class ServiceWithInit implements OnServiceInit {
584
+ initialized = false
585
+
586
+ async onServiceInit() {
587
+ initSpy()
588
+ this.initialized = true
589
+ }
590
+ }
591
+
592
+ const service = await container.get(ServiceWithInit)
593
+
594
+ expect(initSpy).toHaveBeenCalledTimes(1)
595
+ expect(service.initialized).toBe(true)
596
+ })
597
+
598
+ it('should call onServiceInit for request-scoped services in each request', async () => {
599
+ let initCount = 0
600
+
601
+ @Injectable({ scope: InjectableScope.Request, registry })
602
+ class RequestServiceWithInit implements OnServiceInit {
603
+ initOrder = 0
604
+
605
+ onServiceInit() {
606
+ this.initOrder = ++initCount
607
+ }
608
+ }
609
+
610
+ const scoped1 = container.beginRequest('request-1')
611
+ const service1 = await scoped1.get(RequestServiceWithInit)
612
+
613
+ const scoped2 = container.beginRequest('request-2')
614
+ const service2 = await scoped2.get(RequestServiceWithInit)
615
+
616
+ expect(service1.initOrder).toBe(1)
617
+ expect(service2.initOrder).toBe(2)
618
+ expect(initCount).toBe(2)
619
+
620
+ await scoped1.endRequest()
621
+ await scoped2.endRequest()
622
+ })
623
+ })
624
+
625
+ describe('OnServiceDestroy lifecycle', () => {
626
+ it('should call onServiceDestroy when container is disposed', async () => {
627
+ const destroySpy = vi.fn()
628
+
629
+ @Injectable({ scope: InjectableScope.Singleton, registry })
630
+ class ServiceWithDestroy implements OnServiceDestroy {
631
+ onServiceDestroy() {
632
+ destroySpy()
633
+ }
634
+ }
635
+
636
+ await container.get(ServiceWithDestroy)
637
+ expect(destroySpy).not.toHaveBeenCalled()
638
+
639
+ await container.dispose()
640
+ expect(destroySpy).toHaveBeenCalledTimes(1)
641
+ })
642
+
643
+ it('should call onServiceDestroy for request-scoped services when request ends', async () => {
644
+ const destroySpy = vi.fn()
645
+
646
+ @Injectable({ scope: InjectableScope.Request, registry })
647
+ class RequestServiceWithDestroy implements OnServiceDestroy {
648
+ onServiceDestroy() {
649
+ destroySpy()
650
+ }
651
+ }
652
+
653
+ const scoped = container.beginRequest('request-1')
654
+ await scoped.get(RequestServiceWithDestroy)
655
+
656
+ expect(destroySpy).not.toHaveBeenCalled()
657
+
658
+ await scoped.endRequest()
659
+ expect(destroySpy).toHaveBeenCalledTimes(1)
660
+ })
661
+
662
+ it('should handle async onServiceDestroy', async () => {
663
+ const events: string[] = []
664
+
665
+ @Injectable({ scope: InjectableScope.Singleton, registry })
666
+ class SlowDestroyService implements OnServiceDestroy {
667
+ async onServiceDestroy() {
668
+ events.push('destroy-start')
669
+ await delay(10)
670
+ events.push('destroy-end')
671
+ }
672
+ }
673
+
674
+ await container.get(SlowDestroyService)
675
+ await container.dispose()
676
+
677
+ expect(events).toEqual(['destroy-start', 'destroy-end'])
678
+ })
679
+ })
680
+
681
+ describe('Combined lifecycle methods', () => {
682
+ it('should call lifecycle methods in correct order', async () => {
683
+ const events: string[] = []
684
+
685
+ @Injectable({ scope: InjectableScope.Request, registry })
686
+ class FullLifecycleService implements OnServiceInit, OnServiceDestroy {
687
+ onServiceInit() {
688
+ events.push('init')
689
+ }
690
+
691
+ onServiceDestroy() {
692
+ events.push('destroy')
693
+ }
694
+ }
695
+
696
+ const scoped = container.beginRequest('request-1')
697
+ await scoped.get(FullLifecycleService)
698
+ events.push('service-used')
699
+ await scoped.endRequest()
700
+
701
+ expect(events).toEqual(['init', 'service-used', 'destroy'])
702
+ })
703
+
704
+ it('should handle multiple services with lifecycle methods', async () => {
705
+ const events: string[] = []
706
+
707
+ @Injectable({ scope: InjectableScope.Request, registry })
708
+ class ServiceA implements OnServiceInit, OnServiceDestroy {
709
+ private serviceB = injectors.inject(ServiceB)
710
+
711
+ async onServiceInit() {
712
+ events.push('A-init')
713
+ }
714
+
715
+ onServiceDestroy() {
716
+ events.push('A-destroy')
717
+ }
718
+
719
+ async getB() {
720
+ return this.serviceB
721
+ }
722
+ }
723
+
724
+ @Injectable({ scope: InjectableScope.Request, registry })
725
+ class ServiceB implements OnServiceInit, OnServiceDestroy {
726
+ onServiceInit() {
727
+ events.push('B-init')
728
+ }
729
+
730
+ onServiceDestroy() {
731
+ events.push('B-destroy')
732
+ }
733
+ }
734
+
735
+ const scoped = container.beginRequest('request-1')
736
+ const serviceA = await scoped.get(ServiceA)
737
+ await serviceA.getB()
738
+ await scoped.endRequest()
739
+
740
+ expect(events).toContain('A-init')
741
+ expect(events).toContain('B-init')
742
+ expect(events).toContain('A-destroy')
743
+ expect(events).toContain('B-destroy')
744
+ })
745
+ })
746
+ })
747
+
748
+ // ============================================================================
749
+ // SECTION 5: INVALIDATION TESTS
750
+ // ============================================================================
751
+
752
+ describe('E2E: Service Invalidation', () => {
753
+ let registry: Registry
754
+ let container: Container
755
+ let injectors: ReturnType<typeof getInjectors>
756
+
757
+ beforeEach(() => {
758
+ const setup = createTestSetup()
759
+ registry = setup.registry
760
+ container = setup.container
761
+ injectors = setup.injectors
762
+ })
763
+
764
+ afterEach(async () => {
765
+ await container.dispose()
766
+ })
767
+
768
+ describe('Basic invalidation', () => {
769
+ it('should destroy service when invalidated', async () => {
770
+ const destroySpy = vi.fn()
771
+ let instanceCount = 0
772
+
773
+ @Injectable({ scope: InjectableScope.Singleton, registry })
774
+ class CachingService implements OnServiceDestroy {
775
+ id = ++instanceCount
776
+
777
+ onServiceDestroy() {
778
+ destroySpy(this.id)
779
+ }
780
+ }
781
+
782
+ const service1 = await container.get(CachingService)
783
+ expect(service1.id).toBe(1)
784
+
785
+ await container.invalidate(service1)
786
+ expect(destroySpy).toHaveBeenCalledWith(1)
787
+
788
+ // Getting service again should create new instance
789
+ const service2 = await container.get(CachingService)
790
+ expect(service2.id).toBe(2)
791
+ expect(service2).not.toBe(service1)
792
+ })
793
+
794
+ it('should cascade invalidation to dependents', async () => {
795
+ const destroyOrder: string[] = []
796
+
797
+ @Injectable({ scope: InjectableScope.Singleton, registry })
798
+ class BaseService implements OnServiceDestroy {
799
+ onServiceDestroy() {
800
+ destroyOrder.push('BaseService')
801
+ }
802
+ }
803
+
804
+ @Injectable({ scope: InjectableScope.Singleton, registry })
805
+ class DependentService implements OnServiceDestroy {
806
+ private base = injectors.inject(BaseService)
807
+
808
+ async getBase() {
809
+ return this.base
810
+ }
811
+
812
+ onServiceDestroy() {
813
+ destroyOrder.push('DependentService')
814
+ }
815
+ }
816
+
817
+ const dependent = await container.get(DependentService)
818
+ await dependent.getBase() // ensure base is created
819
+
820
+ const base = await container.get(BaseService)
821
+ await container.invalidate(base)
822
+
823
+ // Both should be destroyed, dependent first
824
+ expect(destroyOrder).toContain('BaseService')
825
+ expect(destroyOrder).toContain('DependentService')
826
+ })
827
+ })
828
+
829
+ describe('Request-scoped invalidation', () => {
830
+ it('should invalidate request-scoped service within request context', async () => {
831
+ let instanceCount = 0
832
+ const destroySpy = vi.fn()
833
+
834
+ @Injectable({ scope: InjectableScope.Request, registry })
835
+ class RequestCacheService implements OnServiceDestroy {
836
+ id = ++instanceCount
837
+
838
+ onServiceDestroy() {
839
+ destroySpy(this.id)
840
+ }
841
+ }
842
+
843
+ const scoped = container.beginRequest('request-1')
844
+
845
+ const service1 = await scoped.get(RequestCacheService)
846
+ expect(service1.id).toBe(1)
847
+
848
+ await scoped.invalidate(service1)
849
+ expect(destroySpy).toHaveBeenCalledWith(1)
850
+
851
+ // Getting service again should create new instance within same request
852
+ const service2 = await scoped.get(RequestCacheService)
853
+ expect(service2.id).toBe(2)
854
+ expect(service2).not.toBe(service1)
855
+
856
+ await scoped.endRequest()
857
+ })
858
+ })
859
+
860
+ describe('Complex invalidation scenarios', () => {
861
+ it('should handle deep dependency chain invalidation', async () => {
862
+ const destroyOrder: string[] = []
863
+
864
+ @Injectable({ scope: InjectableScope.Singleton, registry })
865
+ class Level1 implements OnServiceDestroy {
866
+ onServiceDestroy() {
867
+ destroyOrder.push('Level1')
868
+ }
869
+ }
870
+
871
+ @Injectable({ scope: InjectableScope.Singleton, registry })
872
+ class Level2 implements OnServiceDestroy {
873
+ private level1 = injectors.inject(Level1)
874
+
875
+ async getLevel1() {
876
+ return this.level1
877
+ }
878
+
879
+ onServiceDestroy() {
880
+ destroyOrder.push('Level2')
881
+ }
882
+ }
883
+
884
+ @Injectable({ scope: InjectableScope.Singleton, registry })
885
+ class Level3 implements OnServiceDestroy {
886
+ private level2 = injectors.inject(Level2)
887
+
888
+ async getLevel2() {
889
+ return this.level2
890
+ }
891
+
892
+ onServiceDestroy() {
893
+ destroyOrder.push('Level3')
894
+ }
895
+ }
896
+
897
+ const level3 = await container.get(Level3)
898
+ const level2 = await level3.getLevel2()
899
+ await level2.getLevel1()
900
+
901
+ const level1 = await container.get(Level1)
902
+ await container.invalidate(level1)
903
+
904
+ // All levels should be destroyed
905
+ expect(destroyOrder).toContain('Level1')
906
+ expect(destroyOrder).toContain('Level2')
907
+ expect(destroyOrder).toContain('Level3')
908
+ })
909
+
910
+ it('should handle invalidation during concurrent requests', async () => {
911
+ let singletonInstanceCount = 0
912
+ const destroySpy = vi.fn()
913
+
914
+ @Injectable({ scope: InjectableScope.Singleton, registry })
915
+ class SharedSingleton implements OnServiceDestroy {
916
+ id = ++singletonInstanceCount
917
+
918
+ onServiceDestroy() {
919
+ destroySpy(this.id)
920
+ }
921
+ }
922
+
923
+ @Injectable({ scope: InjectableScope.Request, registry })
924
+ class RequestUser {
925
+ private shared = injectors.inject(SharedSingleton)
926
+
927
+ async getSharedId() {
928
+ const s = await this.shared
929
+ return s.id
930
+ }
931
+ }
932
+
933
+ // Start a request
934
+ const scoped1 = container.beginRequest('request-1')
935
+ const user1 = await scoped1.get(RequestUser)
936
+ const sharedId1 = await user1.getSharedId()
937
+ expect(sharedId1).toBe(1)
938
+
939
+ // Invalidate the singleton while request is active
940
+ const singleton = await container.get(SharedSingleton)
941
+ await container.invalidate(singleton)
942
+ expect(destroySpy).toHaveBeenCalledWith(1)
943
+
944
+ // Start another request
945
+ const scoped2 = container.beginRequest('request-2')
946
+ const user2 = await scoped2.get(RequestUser)
947
+ const sharedId2 = await user2.getSharedId()
948
+ expect(sharedId2).toBe(2) // New instance created
949
+
950
+ await scoped1.endRequest()
951
+ await scoped2.endRequest()
952
+ })
953
+
954
+ it('should clear all services on container dispose', async () => {
955
+ const destroyOrder: string[] = []
956
+
957
+ @Injectable({ scope: InjectableScope.Singleton, registry })
958
+ class Service1 implements OnServiceDestroy {
959
+ onServiceDestroy() {
960
+ destroyOrder.push('Service1')
961
+ }
962
+ }
963
+
964
+ @Injectable({ scope: InjectableScope.Singleton, registry })
965
+ class Service2 implements OnServiceDestroy {
966
+ private s1 = injectors.inject(Service1)
967
+
968
+ async getS1() {
969
+ return this.s1
970
+ }
971
+
972
+ onServiceDestroy() {
973
+ destroyOrder.push('Service2')
974
+ }
975
+ }
976
+
977
+ @Injectable({ scope: InjectableScope.Singleton, registry })
978
+ class Service3 implements OnServiceDestroy {
979
+ onServiceDestroy() {
980
+ destroyOrder.push('Service3')
981
+ }
982
+ }
983
+
984
+ await container.get(Service1)
985
+ await container.get(Service2)
986
+ await container.get(Service3)
987
+
988
+ await container.dispose()
989
+
990
+ expect(destroyOrder).toContain('Service1')
991
+ expect(destroyOrder).toContain('Service2')
992
+ expect(destroyOrder).toContain('Service3')
993
+ })
994
+ })
995
+ })
996
+
997
+ // ============================================================================
998
+ // SECTION 6: ERROR HANDLING TESTS
999
+ // ============================================================================
1000
+
1001
+ describe('E2E: Error Handling', () => {
1002
+ let registry: Registry
1003
+ let container: Container
1004
+ let injectors: ReturnType<typeof getInjectors>
1005
+
1006
+ beforeEach(() => {
1007
+ const setup = createTestSetup()
1008
+ registry = setup.registry
1009
+ container = setup.container
1010
+ injectors = setup.injectors
1011
+ })
1012
+
1013
+ afterEach(async () => {
1014
+ try {
1015
+ await container.dispose()
1016
+ } catch {
1017
+ // Ignore dispose errors in error handling tests
1018
+ }
1019
+ })
1020
+
1021
+ describe('Resolution errors', () => {
1022
+ it('should throw when resolving request-scoped service outside request context', async () => {
1023
+ @Injectable({ scope: InjectableScope.Request, registry })
1024
+ class RequestOnlyService {}
1025
+
1026
+ await expect(container.get(RequestOnlyService)).rejects.toThrow()
1027
+ })
1028
+
1029
+ it('should throw when using duplicate request IDs', async () => {
1030
+ const scoped1 = container.beginRequest('duplicate-id')
1031
+
1032
+ expect(() => container.beginRequest('duplicate-id')).toThrow()
1033
+
1034
+ await scoped1.endRequest()
1035
+ })
1036
+
1037
+ it('should throw when using disposed scoped container', async () => {
1038
+ @Injectable({ scope: InjectableScope.Request, registry })
1039
+ class RequestService {}
1040
+
1041
+ const scoped = container.beginRequest('request-1')
1042
+ await scoped.endRequest()
1043
+
1044
+ await expect(scoped.get(RequestService)).rejects.toThrow()
1045
+ })
1046
+ })
1047
+
1048
+ describe('Initialization errors', () => {
1049
+ it('should propagate errors from onServiceInit', async () => {
1050
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1051
+ class FailingInitService implements OnServiceInit {
1052
+ onServiceInit() {
1053
+ throw new Error('Init failed!')
1054
+ }
1055
+ }
1056
+
1057
+ await expect(container.get(FailingInitService)).rejects.toThrow(
1058
+ 'Init failed!',
1059
+ )
1060
+ })
1061
+
1062
+ it('should propagate errors from async onServiceInit', async () => {
1063
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1064
+ class AsyncFailingInitService implements OnServiceInit {
1065
+ async onServiceInit() {
1066
+ await delay(1)
1067
+ throw new Error('Async init failed!')
1068
+ }
1069
+ }
1070
+
1071
+ await expect(container.get(AsyncFailingInitService)).rejects.toThrow(
1072
+ 'Async init failed!',
1073
+ )
1074
+ })
1075
+
1076
+ it('should propagate constructor errors', async () => {
1077
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1078
+ class FailingConstructorService {
1079
+ constructor() {
1080
+ throw new Error('Constructor failed!')
1081
+ }
1082
+ }
1083
+
1084
+ await expect(container.get(FailingConstructorService)).rejects.toThrow(
1085
+ 'Constructor failed!',
1086
+ )
1087
+ })
1088
+ })
1089
+ })
1090
+
1091
+ // ============================================================================
1092
+ // SECTION 7: ADVANCED SCENARIOS
1093
+ // ============================================================================
1094
+
1095
+ describe('E2E: Advanced Scenarios', () => {
1096
+ let registry: Registry
1097
+ let container: Container
1098
+ let injectors: ReturnType<typeof getInjectors>
1099
+
1100
+ beforeEach(() => {
1101
+ const setup = createTestSetup()
1102
+ registry = setup.registry
1103
+ container = setup.container
1104
+ injectors = setup.injectors
1105
+ })
1106
+
1107
+ afterEach(async () => {
1108
+ await container.dispose()
1109
+ })
1110
+
1111
+ describe('Metadata handling', () => {
1112
+ it('should pass metadata to request context', async () => {
1113
+ @Injectable({ scope: InjectableScope.Request, registry })
1114
+ class MetadataAwareService {
1115
+ constructor() {}
1116
+ }
1117
+
1118
+ const metadata = { userId: '123', roles: ['admin'] }
1119
+ const scoped = container.beginRequest('request-1', metadata)
1120
+
1121
+ expect(scoped.getMetadata('userId')).toBe('123')
1122
+ expect(scoped.getMetadata('roles')).toEqual(['admin'])
1123
+
1124
+ await scoped.endRequest()
1125
+ })
1126
+
1127
+ it('should allow modifying metadata during request', async () => {
1128
+ @Injectable({ scope: InjectableScope.Request, registry })
1129
+ class RequestTracker {
1130
+ startTime = Date.now()
1131
+ }
1132
+
1133
+ const scoped = container.beginRequest('request-1')
1134
+ scoped.setMetadata('stage', 'processing')
1135
+
1136
+ expect(scoped.getMetadata('stage')).toBe('processing')
1137
+
1138
+ scoped.setMetadata('stage', 'complete')
1139
+ expect(scoped.getMetadata('stage')).toBe('complete')
1140
+
1141
+ await scoped.endRequest()
1142
+ })
1143
+ })
1144
+
1145
+ describe('tryGetSync functionality', () => {
1146
+ it('should return null when service does not exist', () => {
1147
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1148
+ class LazyService {}
1149
+
1150
+ const result = container.tryGetSync<LazyService>(LazyService)
1151
+ expect(result).toBeNull()
1152
+ })
1153
+
1154
+ it('should return instance when service exists', async () => {
1155
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1156
+ class EagerService {
1157
+ value = 42
1158
+ }
1159
+
1160
+ // First create the service
1161
+ await container.get(EagerService)
1162
+
1163
+ // Now sync get should work
1164
+ const result = container.tryGetSync<EagerService>(EagerService)
1165
+ expect(result).not.toBeNull()
1166
+ expect(result!.value).toBe(42)
1167
+ })
1168
+ })
1169
+
1170
+ describe('Container self-registration', () => {
1171
+ it('should allow injecting the Container itself', async () => {
1172
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1173
+ class ContainerAwareService {
1174
+ private container = injectors.inject(Container)
1175
+
1176
+ async getContainer() {
1177
+ return this.container
1178
+ }
1179
+ }
1180
+
1181
+ const service = await container.get(ContainerAwareService)
1182
+ const injectedContainer = await service.getContainer()
1183
+
1184
+ expect(injectedContainer).toBe(container)
1185
+ })
1186
+ })
1187
+
1188
+ describe('Service ready state', () => {
1189
+ it('should wait for all services to be ready', async () => {
1190
+ const initOrder: string[] = []
1191
+
1192
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1193
+ class SlowService implements OnServiceInit {
1194
+ async onServiceInit() {
1195
+ await delay(10)
1196
+ initOrder.push('SlowService')
1197
+ }
1198
+ }
1199
+
1200
+ @Injectable({ scope: InjectableScope.Singleton, registry })
1201
+ class AnotherSlowService implements OnServiceInit {
1202
+ async onServiceInit() {
1203
+ await delay(5)
1204
+ initOrder.push('AnotherSlowService')
1205
+ }
1206
+ }
1207
+
1208
+ // Start getting services but don't await individually
1209
+ const p1 = container.get(SlowService)
1210
+ const p2 = container.get(AnotherSlowService)
1211
+
1212
+ // Wait for container to be ready
1213
+ await container.ready()
1214
+
1215
+ // Both should be initialized
1216
+ await p1
1217
+ await p2
1218
+ expect(initOrder).toContain('SlowService')
1219
+ expect(initOrder).toContain('AnotherSlowService')
1220
+ })
1221
+ })
1222
+ })