test/container/TestInject.ts
import { expect } from 'chai';
import { spy, stub } from 'sinon';
import { BaseOptions, Container, Contract } from '../../src/Container.js';
import { BaseError } from '../../src/error/BaseError.js';
import { MissingValueError } from '../../src/error/MissingValueError.js';
import { NullLogger, Provides } from '../../src/index.js';
import { Inject } from '../../src/Inject.js';
import { Module, ModuleOptions } from '../../src/Module.js';
import { MapModule } from '../../src/module/MapModule.js';
import { isNil, mustExist } from '../../src/utils/index.js';
import { Consumer, Implementation, Interface, TestModule } from '../HelperClass.js';
import { getTestLogger } from '../helpers/logger.js';
/* eslint-disable no-null/no-null, @typescript-eslint/no-explicit-any, @typescript-eslint/unbound-method */
describe('container', async () => {
it('should throw when no contract was passed', async () => {
const container = Container.from();
await container.configure();
await expect(container.create(null as any)).to.be.rejectedWith(BaseError);
});
it('should provide injected dependencies', async () => {
const ctorSpy = spy();
const instance = {};
class FooClass { /* noop */ }
@Inject(FooClass)
class TestClass {
constructor(...args: Array<any>) {
ctorSpy(...args);
}
}
class FooModule extends Module {
public async configure(options: ModuleOptions) {
this.bind(FooClass).toInstance(instance);
}
}
const module = new FooModule();
const container = Container.from(module);
await container.configure();
await container.create(TestClass);
expect(ctorSpy).to.have.been.called.callCount(1);
expect(ctorSpy).to.have.been.calledWithExactly({
container,
[FooClass.name]: instance,
});
});
it('should inject named dependencies', async () => {
interface FooOptions extends BaseOptions {
foo: FooClass;
}
class FooClass { /* noop */ }
@Inject({ contract: FooClass, name: 'foo' })
class TestClass {
public readonly foo: FooClass;
constructor(options: FooOptions) {
this.foo = options.foo;
}
}
class FooModule extends Module {
public async configure(options: ModuleOptions) {
this.bind(FooClass).toConstructor(FooClass);
}
}
const module = new FooModule();
const container = Container.from(module);
await container.configure();
const injected = await container.create(TestClass);
expect(injected.foo).to.be.an.instanceof(FooClass);
});
it('should pass arguments to the constructor', async () => {
const ctr = Container.from(new TestModule());
await ctr.configure();
const args = ['a', 'b', 'c'];
const impl = await ctr.create(Consumer, {}, ...args);
expect(impl.args).to.deep.equal(args);
});
it('should pass typed arguments to the constructor', async () => {
const ctr = Container.from(new TestModule());
await ctr.configure();
const args = ['a', 'b', 'c'];
class TypedConsumer {
public readonly others: Array<string>;
constructor(options: BaseOptions, ...others: Array<string>) {
this.others = others;
}
}
const impl = await ctr.create(TypedConsumer, {}, ...args);
expect(impl.others).to.deep.equal(args);
});
it('should call provider methods', async () => {
const modSpy = spy();
class SubModule extends Module {
@Provides(Interface)
public async create() {
modSpy();
return mustExist(this.container).create(Implementation);
}
}
const mod = new SubModule();
const ctr = Container.from(mod);
await ctr.configure();
const impl = await ctr.create(Consumer);
expect(modSpy).to.have.been.called.callCount(1);
expect(impl.deps[Interface.name]).to.be.an.instanceof(Implementation);
});
it('should call provider methods with dependencies', async () => {
class Outerface { /* empty */ }
const outerInstance = new Outerface();
const modSpy = spy();
class SubModule extends Module {
public async configure(options: ModuleOptions) {
await super.configure(options);
this.bind(Outerface).toInstance(outerInstance);
}
@Inject(Outerface)
@Provides(Interface)
public async create(outer: { outerface: Outerface }) {
modSpy(outer);
return mustExist(this.container).create(Implementation, outer as any);
}
}
const ctr = Container.from(new SubModule());
await ctr.configure();
const impl = await ctr.create(Consumer);
expect(modSpy).to.have.been.called.callCount(1);
expect(impl.deps[Interface.name]).to.be.an.instanceOf(Implementation);
expect(impl.deps[Interface.name].deps[Outerface.name]).to.equal(outerInstance);
});
it('should call bound factories', async () => {
let counter = 0;
class SubModule extends Module {
public async configure() {
this.bind(Interface).toFactory(async (deps: any, ...args: Array<any>) => {
counter += 1;
return new Implementation(deps, ...args);
});
}
}
const ctr = Container.from(new SubModule());
await ctr.configure();
const impl = await ctr.create(Consumer);
expect(impl.deps[Interface.name]).to.be.an.instanceof(Implementation);
expect(counter).to.equal(1);
});
it('should return bound instances', async () => {
const name = 'foobar';
const inst = {};
class SubModule extends Module {
public async configure() {
this.bind(name).toInstance(inst);
}
}
const ctr = Container.from(new SubModule());
await ctr.configure();
@Inject(name)
class NameConsumer {
public deps: any;
constructor(deps: any) {
this.deps = deps;
}
}
const impl = await ctr.create(NameConsumer);
expect(impl.deps[name]).to.equal(inst);
});
it('should invoke constructors', async () => {
class Other { }
class SubModule extends Module {
public async configure() {
this.bind(Interface).toConstructor(Implementation);
}
}
const ctr = Container.from(new SubModule());
await ctr.configure();
const impl = await ctr.create(Interface);
expect(impl).to.be.an.instanceof(Implementation);
const other = await ctr.create(Other);
expect(other).to.be.an.instanceof(Other);
});
it('should invoke factories', async () => {
class SubModule extends Module {
public async createInterface(deps: any, ...args: Array<any>) {
return new Implementation(deps, ...args);
}
public async configure() {
this.bind(Interface).toFactory((deps: any, ...args: Array<any>) => this.createInterface(deps, ...args));
}
}
const ctr = Container.from(new SubModule());
await ctr.configure();
const impl = await ctr.create(Interface);
expect(impl).to.be.an.instanceof(Implementation);
});
it('should not look up dependencies passed in options', async () => {
@Inject('foo', 'bar')
class TestClass {
public foo: any;
public bar: any;
constructor(options: any) {
this.foo = options.foo;
this.bar = options.bar;
}
}
const foo = {};
class FooModule extends Module {
public async configure(options: ModuleOptions) {
this.bind('foo').toInstance(foo);
}
}
const module = new FooModule();
module.has = spy(module.has) as (contract: Contract<any, any>) => boolean;
const container = Container.from(module);
await container.configure();
const bar = {};
const injected = await container.create(TestClass, {
bar,
});
const HAS_CALL_COUNT = 2;
expect(module.has).to.have.been.calledWith('foo');
expect(module.has).not.to.have.been.calledWith('bar');
expect(module.has, 'called for injected and foo').to.have.callCount(HAS_CALL_COUNT);
expect(injected.bar).to.equal(bar);
expect(injected.foo).to.equal(foo);
});
it('should inject a dependency into a factory method', async () => {
const ctr = Container.from(new TestModule());
await ctr.configure();
const arg = Math.random();
const impl = await ctr.create(Consumer, {}, arg);
expect(impl.args).to.deep.equal([arg]);
expect(impl.deps[Interface.name]).to.be.an.instanceof(Implementation);
});
it('should throw on missing dependencies', async () => {
// TestModule does not provide outerface
const ctr = Container.from(new TestModule());
await ctr.configure();
@Inject('outerface')
class FailingConsumer {
private readonly di: any;
/* c8 ignore next 3 */
constructor(di: any) {
this.di = di;
}
}
return expect(ctr.create(FailingConsumer)).to.eventually.be.rejectedWith(MissingValueError);
});
it('should resolve dependencies by contract', async () => {
const foo = {};
const fooSymbol = Symbol('foo');
class FooModule extends Module {
@Provides(fooSymbol)
public async createFoo() {
return foo;
}
}
const logger = getTestLogger();
spy(logger, 'debug');
const module = new FooModule();
const container = Container.from(module);
await container.configure({
logger,
});
expect(await container.create(fooSymbol)).to.equal(foo);
});
it('should fail and throw with a logger', async () => {
@Inject('foo')
class Bar {}
const container = Container.from();
await container.configure({
logger: NullLogger.global,
});
const module = new MapModule({
providers: {},
});
const getStub = stub(module, 'get').returns(undefined as any);
await expect(container.provide(module, Bar, {}, [])).to.eventually.be.rejectedWith(MissingValueError);
expect(getStub).to.have.callCount(1);
});
});