Allows factory providers to be asynchronous and ensures concurrent requests for the same singleton await the same promise instead of triggering circular dependency errors.
312 lines
6.3 KiB
TypeScript
312 lines
6.3 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import "reflect-metadata";
|
|
import {
|
|
AlveoFactory,
|
|
CircularDependencyError,
|
|
Container,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Module,
|
|
ProviderNotFoundError,
|
|
} from "../index";
|
|
|
|
describe("Alveo Container & DI", () => {
|
|
test("should resolve a simple singleton service", async () => {
|
|
@Injectable()
|
|
class SimpleService {
|
|
getValue() {
|
|
return "hello";
|
|
}
|
|
}
|
|
|
|
@Module({
|
|
providers: [SimpleService],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const service = await app.get(SimpleService);
|
|
|
|
expect(service).toBeInstanceOf(SimpleService);
|
|
expect(service.getValue()).toBe("hello");
|
|
});
|
|
|
|
test("should inject dependencies via constructor", async () => {
|
|
@Injectable()
|
|
class Dependency {
|
|
name = "dep";
|
|
}
|
|
|
|
@Injectable()
|
|
class MainService {
|
|
constructor(public readonly dep: Dependency) {}
|
|
}
|
|
|
|
@Module({
|
|
providers: [Dependency, MainService],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const main = await app.get(MainService);
|
|
|
|
expect(main.dep).toBeInstanceOf(Dependency);
|
|
expect(main.dep.name).toBe("dep");
|
|
});
|
|
|
|
test("should support Value Providers", async () => {
|
|
const TOKEN = Symbol("CONFIG");
|
|
|
|
@Injectable()
|
|
class Service {
|
|
constructor(
|
|
@Inject(TOKEN) public readonly config: { apiKey: string },
|
|
) {}
|
|
}
|
|
|
|
@Module({
|
|
providers: [
|
|
Service,
|
|
{ provide: TOKEN, useValue: { apiKey: "123" } },
|
|
],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const service = await app.get(Service);
|
|
|
|
expect(service.config.apiKey).toBe("123");
|
|
});
|
|
|
|
test("should support Factory Providers", async () => {
|
|
const TOKEN = "FACTORY";
|
|
|
|
@Module({
|
|
providers: [
|
|
{
|
|
provide: TOKEN,
|
|
useFactory: () => "dynamic_value",
|
|
},
|
|
],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const value = await app.get(TOKEN);
|
|
|
|
expect(value).toBe("dynamic_value");
|
|
});
|
|
|
|
test("should support Asynchronous Factory Providers", async () => {
|
|
const TOKEN = "ASYNC_FACTORY";
|
|
|
|
@Module({
|
|
providers: [
|
|
{
|
|
provide: TOKEN,
|
|
useFactory: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return "async_value";
|
|
},
|
|
},
|
|
],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const value = await app.get(TOKEN);
|
|
|
|
expect(value).toBe("async_value");
|
|
});
|
|
|
|
test("should handle concurrent resolution of the same async provider", async () => {
|
|
const TOKEN = "CONCURRENT_ASYNC";
|
|
let callCount = 0;
|
|
|
|
@Module({
|
|
providers: [
|
|
{
|
|
provide: TOKEN,
|
|
useFactory: async () => {
|
|
callCount++;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
return { id: Math.random() };
|
|
},
|
|
},
|
|
],
|
|
})
|
|
class RootModule {}
|
|
|
|
// We use a fresh container to test concurrent resolution
|
|
// bypassing the sequential resolveAll() of AlveoFactory.create()
|
|
const container = new Container();
|
|
container.registerRootModule(RootModule);
|
|
|
|
const [val1, val2] = await Promise.all([
|
|
container.get(TOKEN),
|
|
container.get(TOKEN),
|
|
]);
|
|
|
|
expect(val1).toBe(val2);
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
test("should handle Module imports and exports", async () => {
|
|
@Injectable()
|
|
class SharedService {
|
|
identify() {
|
|
return "shared";
|
|
}
|
|
}
|
|
|
|
@Module({
|
|
providers: [SharedService],
|
|
exports: [SharedService],
|
|
})
|
|
class LibModule {}
|
|
|
|
@Module({
|
|
imports: [LibModule],
|
|
})
|
|
class AppModule {}
|
|
|
|
const app = await AlveoFactory.create(AppModule);
|
|
const service = await app.get(SharedService);
|
|
|
|
expect(service.identify()).toBe("shared");
|
|
});
|
|
|
|
test("should throw ProviderNotFoundError when dependency is missing", async () => {
|
|
@Injectable()
|
|
class MissingDep {}
|
|
|
|
@Injectable()
|
|
class Target {
|
|
constructor(_dep: MissingDep) {}
|
|
}
|
|
|
|
@Module({
|
|
providers: [Target], // MissingDep is not provided
|
|
})
|
|
class RootModule {}
|
|
|
|
expect(AlveoFactory.create(RootModule)).rejects.toThrow(
|
|
ProviderNotFoundError,
|
|
);
|
|
});
|
|
|
|
test("should detect circular dependencies without forwardRef", async () => {
|
|
@Injectable()
|
|
class A {
|
|
constructor(_b: unknown) {}
|
|
}
|
|
@Injectable()
|
|
class B {
|
|
constructor(_a: A) {}
|
|
}
|
|
|
|
// Manually define metadata to simulate circular dep without forwardRef
|
|
Reflect.defineMetadata("design:paramtypes", [Object], A);
|
|
Reflect.defineMetadata("design:paramtypes", [A], B);
|
|
Reflect.defineMetadata("alveo:inject", [B], A);
|
|
|
|
@Module({
|
|
providers: [A, B],
|
|
})
|
|
class RootModule {}
|
|
|
|
expect(AlveoFactory.create(RootModule)).rejects.toThrow(
|
|
CircularDependencyError,
|
|
);
|
|
});
|
|
|
|
test("should resolve circular dependencies with forwardRef", async () => {
|
|
@Injectable()
|
|
class ServiceA {
|
|
constructor(
|
|
@Inject(forwardRef(() => ServiceB))
|
|
public readonly serviceB: { name: () => string },
|
|
) {}
|
|
name() {
|
|
return "A";
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
class ServiceB {
|
|
constructor(
|
|
@Inject(forwardRef(() => ServiceA))
|
|
public readonly serviceA: { name: () => string },
|
|
) {}
|
|
name() {
|
|
return "B";
|
|
}
|
|
}
|
|
|
|
@Module({
|
|
providers: [ServiceA, ServiceB],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const a = await app.get(ServiceA);
|
|
const b = await app.get(ServiceB);
|
|
|
|
expect(a.serviceB.name()).toBe("B");
|
|
expect(b.serviceA.name()).toBe("A");
|
|
});
|
|
|
|
test("should call lifecycle hooks", async () => {
|
|
let initCalled = false;
|
|
let destroyCalled = false;
|
|
|
|
@Injectable()
|
|
class LifecycleService {
|
|
async onModuleInit() {
|
|
initCalled = true;
|
|
}
|
|
async onModuleDestroy() {
|
|
destroyCalled = true;
|
|
}
|
|
}
|
|
|
|
@Module({
|
|
providers: [LifecycleService],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
expect(initCalled).toBe(true);
|
|
|
|
await app.close();
|
|
expect(destroyCalled).toBe(true);
|
|
});
|
|
|
|
test("should support existing providers (aliases)", async () => {
|
|
const ALIAS = "ALIAS_TOKEN";
|
|
|
|
@Injectable()
|
|
class RealService {
|
|
ok() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
@Module({
|
|
providers: [
|
|
RealService,
|
|
{ provide: ALIAS, useExisting: RealService },
|
|
],
|
|
})
|
|
class RootModule {}
|
|
|
|
const app = await AlveoFactory.create(RootModule);
|
|
const real = await app.get(RealService);
|
|
const aliased = await app.get(ALIAS);
|
|
|
|
expect(aliased).toBe(real);
|
|
expect((aliased as RealService).ok()).toBe(true);
|
|
});
|
|
});
|