@furystack/inject
Version:
Core FuryStack package
360 lines (301 loc) • 11.8 kB
text/typescript
import { usingAsync } from '@furystack/utils'
import { describe, expect, it, vi } from 'vitest'
import { Injectable } from './injectable.js'
import { Injected } from './injected.js'
import { Injector } from './injector.js'
describe('Injector', () => {
it('Shold be constructed', () => {
const i = new Injector()
expect(i).toBeInstanceOf(Injector)
})
it('Should be disposed', async () => {
await usingAsync(new Injector(), async () => {
/** */
})
})
it('Parent should be undefined by default', () => {
const i = new Injector()
expect(i.options.parent).toBeUndefined()
})
it('Should throw an error when setting an Injector instance', async () => {
await usingAsync(new Injector(), async (i) => {
expect(() => i.setExplicitInstance(new Injector())).toThrowError('Cannot set an injector instance as injectable')
})
})
it('Should throw an error when trying to set an instance without decorator', async () => {
await usingAsync(new Injector(), async (i) => {
class TestClass {}
expect(() => i.setExplicitInstance(new TestClass())).toThrowError(`The class 'TestClass' is not an injectable`)
})
})
describe('Transient lifetime', () => {
it('Should not store an instance in the cache', async () => {
await usingAsync(new Injector(), async (i) => {
@Injectable({ lifetime: 'transient' })
class InstanceClass {}
const instance = i.getInstance(InstanceClass)
expect(instance).toBeInstanceOf(InstanceClass)
expect(i.cachedSingletons.get(InstanceClass)).toBeUndefined()
})
})
it('Should throw an error if you try to set an explicit instance', async () => {
await usingAsync(new Injector(), async (i) => {
@Injectable({ lifetime: 'transient' })
class InstanceClass {}
const instance = new InstanceClass()
expect(() => i.setExplicitInstance(instance)).toThrowError(
`Cannot set an instance of 'InstanceClass' as it's lifetime is set to 'transient'`,
)
})
})
})
describe('Scoped lifetime', () => {
it('Should set and return instance from cache', () => {
const i = new Injector()
@Injectable({ lifetime: 'scoped' })
class InstanceClass {}
const instance = new InstanceClass()
i.setExplicitInstance(instance)
expect(i.getInstance(InstanceClass)).toBe(instance)
})
it('Should instantiate and return an instance', () => {
const i = new Injector()
@Injectable({ lifetime: 'scoped' })
class InstanceClass {}
expect(i.getInstance(InstanceClass)).toBeInstanceOf(InstanceClass)
})
it('Scoped with transient dependencies should throw an error', async () => {
@Injectable({ lifetime: 'transient' })
class Tr2 {}
@Injectable({ lifetime: 'scoped' })
class Sc2 {
@Injected(Tr2)
declare sc: Tr2
}
await usingAsync(new Injector(), async (i) => {
expect(() => i.getInstance(Sc2)).toThrowError(
`Injector error: Scoped type 'Sc2' depends on transient injectables: Tr2:transient`,
)
})
})
})
describe('Singleton lifetime', () => {
it('Should return from a parent injector if available', () => {
const parent = new Injector()
const i = parent.createChild()
@Injectable({ lifetime: 'singleton' })
class InstanceClass {}
const instance = new InstanceClass()
parent.setExplicitInstance(instance)
expect(i.getInstance(InstanceClass)).toBe(instance)
expect(parent.cachedSingletons.get(InstanceClass)).toBe(instance)
})
it('Should create instance on a parent injector if not available', () => {
const parent = new Injector()
const i = parent.createChild()
@Injectable({ lifetime: 'singleton' })
class InstanceClass {}
expect(i.getInstance(InstanceClass)).toBeInstanceOf(InstanceClass)
expect(parent.cachedSingletons.get(InstanceClass)).toBeInstanceOf(InstanceClass)
})
it('Singleton with transient dependencies should throw an error', async () => {
@Injectable({ lifetime: 'transient' })
class Trs1 {}
@Injectable({ lifetime: 'singleton' })
class St1 {
@Injected(Trs1)
declare lt: Trs1
}
await usingAsync(new Injector(), async (i) => {
expect(() => i.getInstance(St1)).toThrowError(
`Injector error: Singleton type 'St1' depends on non-singleton injectables: Trs1:transient`,
)
})
})
it('Singleton with transient dependencies should throw an error', async () => {
@Injectable({ lifetime: 'scoped' })
class Sc1 {}
@Injectable({ lifetime: 'singleton' })
class St2 {
@Injected(Sc1)
declare sc: Sc1
}
await usingAsync(new Injector(), async (i) => {
expect(() => i.getInstance(St2)).toThrowError(
`Injector error: Singleton type 'St2' depends on non-singleton injectables: Sc1:scoped`,
)
})
})
})
describe('Explicit lifetime', () => {
it('Should return the instance from the cache', async () => {
await usingAsync(new Injector(), async (i) => {
@Injectable({ lifetime: 'explicit' })
class InstanceClass {}
const instance = new InstanceClass()
i.setExplicitInstance(instance)
expect(i.getInstance(InstanceClass)).toBe(instance)
})
})
it('Should return an instance from the parent injector', async () => {
await usingAsync(new Injector(), async (i) => {
const child = i.createChild()
@Injectable({ lifetime: 'explicit' })
class InstanceClass {}
const instance = new InstanceClass()
i.setExplicitInstance(instance)
expect(child.getInstance(InstanceClass)).toBe(instance)
})
})
it('Should throw an error if the instance is not set', async () => {
await usingAsync(new Injector(), async (i) => {
@Injectable({ lifetime: 'explicit' })
class InstanceClass {}
expect(() => i.getInstance(InstanceClass)).toThrowError(
`Cannot instantiate an instance of 'InstanceClass' as it's lifetime is set to 'explicit'. Ensure to initialize it properly`,
)
})
})
})
it('Should resolve injectable fields', () => {
const i = new Injector()
@Injectable()
class Injected1 {}
@Injectable()
class Injected2 {}
@Injectable()
class InstanceClass {
@Injected(Injected1)
declare injected1: Injected1
@Injected(Injected2)
declare injected2: Injected2
}
const instance = i.getInstance(InstanceClass)
expect(instance).toBeInstanceOf(InstanceClass)
expect(instance.injected1).toBeInstanceOf(Injected1)
expect(instance.injected2).toBeInstanceOf(Injected2)
})
it('Should resolve injectable fields recursively', () => {
const i = new Injector()
@Injectable()
class Injected1 {}
@Injectable()
class Injected2 {
@Injected(Injected1)
declare injected1: Injected1
}
@Injectable()
class InstanceClass {
@Injected(Injected2)
declare injected2: Injected2
}
expect(i.getInstance(InstanceClass)).toBeInstanceOf(InstanceClass)
expect(i.getInstance(InstanceClass).injected2.injected1).toBeInstanceOf(Injected1)
})
it('Should throw if failed to dispose one or more entries', async () => {
expect.assertions(1)
@Injectable({ lifetime: 'singleton' })
class TestDisposableThrows implements Disposable {
public [Symbol.dispose]() {
throw Error(':(')
}
}
const i = new Injector()
i.getInstance(TestDisposableThrows)
await expect(async () => await i[Symbol.asyncDispose]()).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: There was an error during disposing '1' global disposable objects: Error: :(]`,
)
})
it('Should throw if failed to dispose async one or more entries', async () => {
expect.assertions(1)
@Injectable({ lifetime: 'singleton' })
class TestDisposableThrows implements AsyncDisposable {
public async [Symbol.asyncDispose]() {
throw Error(':(')
}
}
const i = new Injector()
i.getInstance(TestDisposableThrows)
await expect(async () => await i[Symbol.asyncDispose]()).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: There was an error during disposing '1' global disposable objects: Error: :(]`,
)
})
it('Should dispose cached entries on dispose and tolerate non-disposable ones', async () => {
const doneCallback = vi.fn()
@Injectable({ lifetime: 'explicit' })
class TestDisposable implements Disposable {
public [Symbol.dispose]() {
doneCallback()
}
}
@Injectable({ lifetime: 'explicit' })
class TestInstance {}
await usingAsync(new Injector(), async (i) => {
i.setExplicitInstance(new TestDisposable())
i.setExplicitInstance(new TestInstance())
})
expect(doneCallback).toBeCalledTimes(1)
})
it('Remove should remove an entity from the cached instance list', async () => {
await usingAsync(new Injector(), async (i) => {
@Injectable({ lifetime: 'scoped' })
class InjectableClass {}
i.setExplicitInstance({}, InjectableClass)
i.remove(InjectableClass)
expect(i.cachedSingletons.size).toBe(0)
})
})
it('Requesting an Injector instance should return self', async () => {
await usingAsync(new Injector(), async (i) => {
expect(i.getInstance(Injector)).toBe(i)
})
})
it('Requesting an undecorated instance should throw an error', async () => {
class UndecoratedTestClass {}
await usingAsync(new Injector(), async (i) => {
expect(() => i.getInstance(UndecoratedTestClass)).toThrowError(
`The class 'UndecoratedTestClass' is not an injectable`,
)
})
})
it('Should exec an init() method, if present', async () => {
@Injectable()
class InitClass {
public initWasCalled = false
public init() {
this.initWasCalled = true
}
}
await usingAsync(new Injector(), async (i) => {
const instance = i.getInstance(InitClass)
expect(instance.initWasCalled).toBe(true)
})
})
describe('Disposed injector', () => {
it('Should throw an error on getInstance', async () => {
const i = new Injector()
await i[Symbol.asyncDispose]()
expect(() => i.getInstance(Injector)).toThrowError('Injector already disposed')
})
it('Should throw an error on setExplicitInstance', async () => {
const i = new Injector()
await i[Symbol.asyncDispose]()
expect(() => i.setExplicitInstance({})).toThrowError('Injector already disposed')
})
it('Should throw an error on remove', async () => {
const i = new Injector()
await i[Symbol.asyncDispose]()
expect(() => i.remove(Object)).toThrowError('Injector already disposed')
})
it('Should throw an error on createChild', async () => {
const i = new Injector()
await i[Symbol.asyncDispose]()
expect(() => i.createChild()).toThrowError('Injector already disposed')
})
it('Should throw an error on dispose', async () => {
const i = new Injector()
await i[Symbol.asyncDispose]()
await expect(async () => await i[Symbol.asyncDispose]()).rejects.toThrowError('Injector already disposed')
})
})
})