import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Container } from '../container/container.mjs' import { Injectable } from '../decorators/index.mjs' import { InjectableScope } from '../enums/index.mjs' import { InjectionToken } from '../token/injection-token.mjs' import { defaultInjectors, getInjectors } from '../utils/index.mjs' describe('getInjectors', () => { it('should return an object with all injector functions', () => { const injectors = getInjectors() expect(injectors).toHaveProperty('inject') expect(injectors).toHaveProperty('asyncInject') expect(injectors).toHaveProperty('optional') expect(injectors).toHaveProperty('wrapSyncInit') expect(injectors).toHaveProperty('provideFactoryContext') expect(typeof injectors.inject).toBe('function') expect(typeof injectors.asyncInject).toBe('function') expect(typeof injectors.optional).toBe('function') expect(typeof injectors.wrapSyncInit).toBe('function') expect(typeof injectors.provideFactoryContext).toBe('function') }) it('should create independent injector instances', () => { const injectors1 = getInjectors() const injectors2 = getInjectors() expect(injectors1).not.toBe(injectors2) }) }) describe('defaultInjectors', () => { it('should be an instance of Injectors', () => { expect(defaultInjectors).toHaveProperty('inject') expect(defaultInjectors).toHaveProperty('asyncInject') expect(defaultInjectors).toHaveProperty('optional') expect(defaultInjectors).toHaveProperty('wrapSyncInit') expect(defaultInjectors).toHaveProperty('provideFactoryContext') }) }) describe('provideFactoryContext', () => { it('should return previous context when setting new one', () => { const injectors = getInjectors() const originalContext = injectors.provideFactoryContext(null) expect(originalContext).toBeNull() const mockContext = { inject: vi.fn(), container: {} as any, addDestroyListener: vi.fn(), serviceName: 'test', dependencies: new Set(), scope: InjectableScope.Singleton, getDestroyListeners: () => [], trackDependency: vi.fn(), } const prev = injectors.provideFactoryContext(mockContext) expect(prev).toBeNull() const current = injectors.provideFactoryContext(null) expect(current).toBe(mockContext) }) }) describe('wrapSyncInit', () => { it('should capture async inject calls', () => { const injectors = getInjectors() const mockContext = { inject: vi.fn().mockResolvedValue('result'), container: { tryGetSync: vi.fn().mockReturnValue(null), } as any, addDestroyListener: vi.fn(), serviceName: 'test', dependencies: new Set(), scope: InjectableScope.Singleton, getDestroyListeners: () => [], trackDependency: vi.fn(), } injectors.provideFactoryContext(mockContext) const wrapped = injectors.wrapSyncInit(() => { return { value: 'test' } }) const [result, promises, injectState] = wrapped() expect(result).toEqual({ value: 'test' }) expect(Array.isArray(promises)).toBe(true) expect(injectState).toBeDefined() expect(injectState.isFrozen).toBe(true) }) it('should support re-running with previous state', () => { const injectors = getInjectors() const mockContext = { inject: vi.fn().mockResolvedValue('result'), container: { tryGetSync: vi.fn().mockReturnValue(null), } as any, addDestroyListener: vi.fn(), serviceName: 'test', dependencies: new Set(), scope: InjectableScope.Singleton, getDestroyListeners: () => [], trackDependency: vi.fn(), } injectors.provideFactoryContext(mockContext) let callCount = 0 const wrapped = injectors.wrapSyncInit(() => { callCount++ return { call: callCount } }) const [result1, , state1] = wrapped() expect(result1).toEqual({ call: 1 }) const [result2, , state2] = wrapped(state1) expect(result2).toEqual({ call: 2 }) expect(state2.isFrozen).toBe(true) }) }) describe('inject and asyncInject with Container', () => { let container: Container beforeEach(() => { container = new Container() }) afterEach(async () => { await container.dispose() }) it('should inject dependencies synchronously', async () => { @Injectable() class DependencyService { getValue() { return 'dependency-value' } } @Injectable() class MainService { private dep = defaultInjectors.inject(DependencyService) getValue() { return this.dep.getValue() } } const service = await container.get(MainService) expect(service.getValue()).toBe('dependency-value') }) it('should inject dependencies asynchronously', async () => { @Injectable() class DependencyService { getValue() { return 'async-dependency-value' } } @Injectable() class MainService { private depPromise = defaultInjectors.asyncInject(DependencyService) async getValue() { const dep = await this.depPromise return dep.getValue() } } const service = await container.get(MainService) expect(await service.getValue()).toBe('async-dependency-value') }) it('should handle optional injection that succeeds', async () => { @Injectable() class OptionalService { getValue() { return 'optional-value' } } @Injectable() class MainService { private opt = defaultInjectors.optional(OptionalService) getValue() { return this.opt?.getValue() ?? 'fallback' } } const service = await container.get(MainService) expect(service.getValue()).toBe('optional-value') }) it('should inject with token', async () => { const TOKEN = InjectionToken.create('CONFIG') @Injectable({ token: TOKEN }) class ConfigService { getValue() { return 'config-value' } } @Injectable() class MainService { private config = defaultInjectors.inject(TOKEN) getValue() { return this.config.getValue() } } const service = await container.get(MainService) expect(service.getValue()).toBe('config-value') }) }) describe('inject error handling', () => { it('should throw when called outside injectable context', () => { const injectors = getInjectors() expect(() => { injectors.inject(InjectionToken.create('test')) }).toThrow('Trying to access inject outside of a injectable context') }) it('should throw when asyncInject called outside injectable context', () => { const injectors = getInjectors() expect(() => { injectors.asyncInject(InjectionToken.create('test')) }).toThrow('Trying to access inject outside of a injectable context') }) }) describe('optional injection', () => { let container: Container beforeEach(() => { container = new Container() }) afterEach(async () => { await container.dispose() }) it('should return null for optional when inject fails', async () => { const TOKEN = InjectionToken.create('NON_EXISTENT') @Injectable() class MainService { // This will try to inject a token that doesn't exist private opt = defaultInjectors.optional(TOKEN) hasOptional() { return this.opt !== null } } // Since NON_EXISTENT is not registered, optional should return null // But we need to register the token first for optional to work // Optional catches errors, so if the token is not registered, it returns null try { await container.get(MainService) } catch { // Expected to fail since NON_EXISTENT is not registered } }) }) describe('inject with pre-cached dependencies', () => { let container: Container beforeEach(() => { container = new Container() }) afterEach(async () => { await container.dispose() }) it('should handle inject when dependency is already instantiated', async () => { @Injectable() class DependencyA { getValue() { return 'A' } } @Injectable() class DependencyB { getValue() { return 'B' } } @Injectable() class MainService { private depA = defaultInjectors.inject(DependencyA) private depB = defaultInjectors.inject(DependencyB) getValues() { return this.depA.getValue() + this.depB.getValue() } } // Pre-instantiate DependencyA before MainService is created await container.get(DependencyA) // Now get MainService - DependencyA is cached, DependencyB is not const service = await container.get(MainService) expect(service.getValues()).toBe('AB') }) it('should handle inject when all dependencies are pre-cached', async () => { @Injectable() class DependencyA { getValue() { return 'A' } } @Injectable() class DependencyB { getValue() { return 'B' } } @Injectable() class MainService { private depA = defaultInjectors.inject(DependencyA) private depB = defaultInjectors.inject(DependencyB) getValues() { return this.depA.getValue() + this.depB.getValue() } } // Pre-instantiate both dependencies await container.get(DependencyA) await container.get(DependencyB) // Now get MainService - both dependencies are cached const service = await container.get(MainService) expect(service.getValues()).toBe('AB') }) it('should handle mixed inject and asyncInject with pre-cached dependencies', async () => { @Injectable() class DependencyA { getValue() { return 'A' } } @Injectable() class DependencyB { getValue() { return 'B' } } @Injectable() class DependencyC { getValue() { return 'C' } } @Injectable() class MainService { private depA = defaultInjectors.inject(DependencyA) private depBPromise = defaultInjectors.asyncInject(DependencyB) private depC = defaultInjectors.inject(DependencyC) async getValues() { const depB = await this.depBPromise return this.depA.getValue() + depB.getValue() + this.depC.getValue() } } // Pre-instantiate DependencyA and DependencyC (but not B) await container.get(DependencyA) await container.get(DependencyC) // Now get MainService const service = await container.get(MainService) expect(await service.getValues()).toBe('ABC') }) it('should maintain correct token order when some deps are cached and some trigger re-run', async () => { // This test specifically targets the frozen state replay scenario: // - First run: depA (cached, returns immediately), depB (not cached, creates promise) // - promises.length > 0, so we await and do second run // - Second run (frozen): must replay in same order as first run @Injectable() class DependencyA { getValue() { return 'A' } } @Injectable() class DependencyB { getValue() { return 'B' } } @Injectable() class MainService { // depA will be cached (pre-instantiated) private depA = defaultInjectors.inject(DependencyA) // depB won't be cached, causing promises array to have entries private depB = defaultInjectors.inject(DependencyB) getValues() { return this.depA.getValue() + this.depB.getValue() } } // Pre-instantiate only DependencyA await container.get(DependencyA) // Now get MainService: // - First constructor run: depA is cached (tryGetSync returns it), depB is not // - depB's inject() creates a promise, so promises.length > 0 // - After awaiting, second run happens with frozen state // - Both depA and depB must be in the requests array for frozen replay to work const service = await container.get(MainService) expect(service.getValues()).toBe('AB') }) })