Files
core/test/container.test.ts
M1000fr deb9e704fb feat: implement Dynamic Modules support (sync and async)
Allows modules to be configured at runtime via static methods returning DynamicModule objects, similar to NestJS forRoot/register patterns.
2026-01-11 17:18:15 +01:00

377 lines
7.8 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();
await 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);
});
test("should support Synchronous Dynamic Modules", async () => {
@Injectable()
class DynamicService {
constructor(@Inject("CONFIG") public readonly config: string) {}
}
@Module({})
class DynamicModule {
static forRoot(config: string) {
return {
module: DynamicModule,
providers: [
DynamicService,
{ provide: "CONFIG", useValue: config },
],
exports: [DynamicService],
};
}
}
@Module({
imports: [DynamicModule.forRoot("dynamic_config")],
})
class AppModule {}
const app = await AlveoFactory.create(AppModule);
const service = await app.get(DynamicService);
expect(service.config).toBe("dynamic_config");
});
test("should support Asynchronous Dynamic Modules", async () => {
@Injectable()
class AsyncDynamicService {
constructor(
@Inject("ASYNC_CONFIG") public readonly config: string,
) {}
}
@Module({})
class AsyncDynamicModule {
static async forRoot(config: string) {
await new Promise((resolve) => setTimeout(resolve, 10));
return {
module: AsyncDynamicModule,
providers: [
AsyncDynamicService,
{ provide: "ASYNC_CONFIG", useValue: config },
],
exports: [AsyncDynamicService],
};
}
}
@Module({
imports: [AsyncDynamicModule.forRoot("async_dynamic_config")],
})
class AppModule {}
const app = await AlveoFactory.create(AppModule);
const service = await app.get(AsyncDynamicService);
expect(service.config).toBe("async_dynamic_config");
});
});