diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index c767c29..8a2f69e 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -42,6 +42,19 @@ The generated container follows the project convention: one default-exported class per file. Application classes should receive dependencies through their constructor. +## Service Lookup + +Use `getService()` when a service must exist: + +```ts +const repository = kernel.di.getService(UserRepository); +``` + +Use `hasService()` when an adapter needs to check whether a token is registered +without resolving it. This does not call the underlying container `get()` method, +so missing services can be detected without emitting +`node-dependency-injection` “service is not registered” warnings. + ## Overrides Use dependency overrides when the application wants a different implementation diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index 2443d76..fe26b1a 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -36,6 +36,26 @@ const server = new ExpressKernelServer({ `controllers` are merged with `kernel.getRoutes()` before `routing-controllers` is configured. +Controllers passed through `controllers`, `registerControllers()` or +`kernel.registerRoutes()` are known to the Express adapter. When +`routing-controllers` asks for one of those classes, `ExpressKernelServer` first +checks the kernel container without resolving the service. If the class itself is +not registered, the adapter creates and caches a plain controller instance +instead of forcing every external controller to be a DI service. + +That avoids the “service is not registered” warning that +`node-dependency-injection` emits when a missing service is resolved directly. +Registered controllers still resolve through DI. Dependency errors raised while +resolving a registered controller are rethrown, so missing constructor +dependencies, broken factories and other container failures remain visible. + +Custom kernels or test doubles should expose `di.hasService()` when they need +registered controllers to resolve through DI. Without that optional lookup, +known controllers are treated as external controllers and constructed locally. +Classes requested by `routing-controllers` that are not known controllers, such +as plain middleware or interceptor classes, keep the normal `routing-controllers` +fallback when they are not registered in DI. + The same can be done after construction: ```ts diff --git a/package.json b/package.json index 196f412..db22da5 100644 --- a/package.json +++ b/package.json @@ -242,9 +242,9 @@ "lint": "eslint ./src --ext .ts", "lint:fix": "eslint ./src --ext .ts --fix", "prepack": "yarn build", - "test": "yarn build && c8 node --test \"tests/**/*.test.mjs\"", + "test": "yarn build && c8 node --test --test-concurrency=1 \"tests/**/*.test.mjs\"", "build:coverage": "rm -rf dist && tsc -p tsconfig.coverage.json", - "test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"", + "test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test --test-concurrency=1 \"tests/**/*.test.mjs\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [ diff --git a/src/adapters/ui/express/ExpressControllerResolver.ts b/src/adapters/ui/express/ExpressControllerResolver.ts new file mode 100644 index 0000000..6205d86 --- /dev/null +++ b/src/adapters/ui/express/ExpressControllerResolver.ts @@ -0,0 +1,59 @@ +import type { ExpressController } from './ExpressController.js'; +import type { ExpressKernelServerOptions } from './ExpressKernelServerOptions.js'; + +export class ExpressControllerResolver { + private readonly controllerInstances = new Map(); + + private readonly controllers: Set; + + constructor( + private readonly kernel: ExpressKernelServerOptions['kernel'], + controllers: readonly ExpressController[], + ) { + this.controllers = new Set(controllers); + } + + private createControllerInstance( + ClassDefinition: ExpressController, + ): unknown { + const Controller = ClassDefinition as new () => unknown; + + return new Controller(); + } + + private getCachedController(ClassDefinition: ExpressController): unknown { + const cached = this.controllerInstances.get(ClassDefinition); + + if (cached) { + return cached; + } + + const instance = this.createControllerInstance(ClassDefinition); + + this.controllerInstances.set(ClassDefinition, instance); + + return instance; + } + + private isKnownController(ClassDefinition: ExpressController): boolean { + return this.controllers.has(ClassDefinition); + } + + private canResolveService(ClassDefinition: ExpressController): boolean { + return this.kernel.di.hasService?.(ClassDefinition) === true; + } + + public get(ClassDefinition: ExpressController): unknown { + if (!this.canResolveService(ClassDefinition)) { + if (this.isKnownController(ClassDefinition)) { + return this.getCachedController(ClassDefinition); + } + + return undefined; + } + + return this.kernel.di.getService(ClassDefinition); + } +} + +export default ExpressControllerResolver; diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index 3dc4c1e..a7a00ad 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -10,6 +10,8 @@ import type { ExpressPhaseHook } from './ExpressPhaseHook.js'; import type { HttpApp } from './HttpApp.js'; import type { HttpServer } from './HttpServer.js'; +import { ExpressControllerResolver } from './ExpressControllerResolver.js'; + export class ExpressKernelServer { private readonly applicationRequire = createRequire( path.resolve(process.cwd(), 'package.json'), @@ -58,18 +60,23 @@ export class ExpressKernelServer { return [...(items ?? [])]; } - private configureControllerContainer(): void { + private configureControllerContainer( + controllers: readonly ExpressController[], + ): void { const { useContainer } = this.getRoutingControllers(); + const resolver = new ExpressControllerResolver( + this.options.kernel, + controllers, + ); useContainer( { - /* c8 ignore next */ - get: (ClassDefinition: ExpressController) => - this.options.kernel.di.getService(ClassDefinition), + get: (ClassDefinition: new (...args: never[]) => T): T => + resolver.get(ClassDefinition as unknown as ExpressController) as T, }, { fallback: true, - fallbackOnErrors: true, + fallbackOnErrors: false, }, ); } @@ -101,9 +108,14 @@ export class ExpressKernelServer { private defaultErrorHandler(): ErrorRequestHandler { return (error, request, response, next) => { - void next; void request; + if (response.headersSent) { + next(error); + + return; + } + response.status(500).json({ error: error instanceof Error ? error.message : String(error), }); @@ -250,10 +262,10 @@ export class ExpressKernelServer { throw new Error('HTTP server is already running.'); } - const controllers = [ + const controllers: ExpressController[] = [ ...this.options.kernel.getRoutes(), ...this.controllers, - ]; + ] as ExpressController[]; const express = this.getExpress(); const { useExpressServer } = this.getRoutingControllers(); const app = express() as HttpApp; @@ -262,7 +274,7 @@ export class ExpressKernelServer { this.applyMiddlewares(app, this.preControllerMiddlewares); await this.runHooks(this.beforeControllersHooks, app); await this.runPhaseHooks('beforeControllers', app); - this.configureControllerContainer(); + this.configureControllerContainer(controllers); useExpressServer(app, { ...this.options.routingControllersOptions, controllers, diff --git a/src/adapters/ui/express/index.ts b/src/adapters/ui/express/index.ts index 0da9c4b..c5b2c97 100644 --- a/src/adapters/ui/express/index.ts +++ b/src/adapters/ui/express/index.ts @@ -2,6 +2,7 @@ export * from './ErrorExplanation.js'; export * from './ErrorResponseHandler.js'; export * from './ExpressAppHook.js'; export * from './ExpressController.js'; +export * from './ExpressControllerResolver.js'; export * from './ExpressHookPhase.js'; export * from './ExpressKernelServer.js'; export * from './ExpressKernelServerOptions.js'; diff --git a/src/contracts/kernel/ServiceResolver.ts b/src/contracts/kernel/ServiceResolver.ts index aefe126..bbb3cbd 100644 --- a/src/contracts/kernel/ServiceResolver.ts +++ b/src/contracts/kernel/ServiceResolver.ts @@ -1,3 +1,4 @@ export interface ServiceResolver { getService(serviceName: unknown): T; + hasService?(serviceName: unknown): boolean; } diff --git a/src/infrastructure/dependency-injection/DependencyInjection.ts b/src/infrastructure/dependency-injection/DependencyInjection.ts index eb7320b..9e32357 100644 --- a/src/infrastructure/dependency-injection/DependencyInjection.ts +++ b/src/infrastructure/dependency-injection/DependencyInjection.ts @@ -79,6 +79,13 @@ export class DependencyInjection implements ServiceResolver { return `ddd-kernel.override.${prefix}.${tokenName}`; } + private hasDirectService(serviceName: unknown): boolean { + return ( + typeof serviceName === 'string' && + (this.definitions.has(serviceName) || this.aliases.has(serviceName)) + ); + } + private ensureSyntheticService(id: string, value: unknown): void { const definition = this.container.register(id); @@ -365,6 +372,16 @@ export class DependencyInjection implements ServiceResolver { return this.container.get(serviceName); } + + public hasService(serviceName: unknown): boolean { + return ( + this.overrideTokenIds.has(serviceName) || + this.findAliasServiceId(serviceName) !== undefined || + this.findConcreteChildServiceId(serviceName) !== undefined || + this.findRegisteredServiceId(serviceName) !== undefined || + this.hasDirectService(serviceName) + ); + } } export default DependencyInjection; diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index 40291c6..fc7c435 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -2,10 +2,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + ExpressControllerResolver, ExpressKernelServer, HttpErrorHandler, } from '../../../../dist/adapters/ui/express/index.js'; import { Kernel } from '../../../../dist/index.js'; +import { DependencyInjection } from '../../../../dist/infrastructure/dependency-injection/index.js'; +import { + Get, + JsonController, + Middleware, + getMetadataArgsStorage, +} from 'routing-controllers'; const getServerPort = (server) => { const address = server.server.address(); @@ -16,6 +24,412 @@ const getServerPort = (server) => { return address.port; }; +class ServiceNotFoundException extends Error {} + +const createKernelWithServiceResolver = ({ getService, hasService }) => ({ + di: { + getService, + hasService: hasService ?? (() => false), + }, + getRoutes: () => [], +}); + +const decorateGetController = (Controller, route, methodName) => { + JsonController(route)(Controller); + Get('/')(Controller.prototype, methodName); +}; + +const removeRoutingControllerMetadata = (ClassDefinition) => { + const storage = getMetadataArgsStorage(); + const doesNotTargetClass = (metadata) => + metadata.target !== ClassDefinition && + metadata.object?.constructor !== ClassDefinition; + + storage.actions = storage.actions.filter(doesNotTargetClass); + storage.controllers = storage.controllers.filter(doesNotTargetClass); + storage.params = storage.params.filter(doesNotTargetClass); + storage.responseHandlers = + storage.responseHandlers.filter(doesNotTargetClass); + storage.uses = storage.uses.filter(doesNotTargetClass); + storage.useInterceptors = storage.useInterceptors.filter(doesNotTargetClass); +}; + +const removeRoutingMiddlewareMetadata = (ClassDefinition) => { + const storage = getMetadataArgsStorage(); + + storage.middlewares = storage.middlewares.filter( + (metadata) => metadata.target !== ClassDefinition, + ); +}; + +test('resolves known controllers locally when they are not registered in DI', () => { + class ExternalController {} + + const calls = []; + const kernel = createKernelWithServiceResolver({ + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + + throw new ServiceNotFoundException( + `The service ${ClassDefinition.name} is not registered`, + ); + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return false; + }, + }); + const resolver = new ExpressControllerResolver(kernel, [ExternalController]); + + const firstInstance = resolver.get(ExternalController); + const secondInstance = resolver.get(ExternalController); + + assert.ok(firstInstance instanceof ExternalController); + assert.equal(firstInstance, secondInstance); + assert.deepEqual(calls, [ + ['hasService', 'ExternalController'], + ['hasService', 'ExternalController'], + ]); +}); + +test('resolves registered controllers through DI', () => { + class RegisteredController {} + + const controller = new RegisteredController(); + const calls = []; + const kernel = createKernelWithServiceResolver({ + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + + return controller; + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return true; + }, + }); + const resolver = new ExpressControllerResolver(kernel, [ + RegisteredController, + ]); + + assert.equal(resolver.get(RegisteredController), controller); + assert.deepEqual(calls, [ + ['hasService', 'RegisteredController'], + ['getService', 'RegisteredController'], + ]); +}); + +test('does not write container warnings for known external controllers missing from DI', () => { + class ExternalController {} + + const warnings = []; + const dependencyInjection = new DependencyInjection(); + + dependencyInjection.container.logger = { + debug() {}, + info() {}, + warn: (message) => warnings.push(message), + }; + + const kernel = { + di: dependencyInjection, + getRoutes: () => [], + }; + const resolver = new ExpressControllerResolver(kernel, [ExternalController]); + + const instance = resolver.get(ExternalController); + + assert.ok(instance instanceof ExternalController); + assert.deepEqual(warnings, []); +}); + +test('returns undefined for missing services that are not known controllers', () => { + class MissingService {} + + const calls = []; + const kernel = createKernelWithServiceResolver({ + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + + throw new ServiceNotFoundException( + `The service ${ClassDefinition.name} is not registered`, + ); + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return false; + }, + }); + const resolver = new ExpressControllerResolver(kernel, []); + + assert.equal(resolver.get(MissingService), undefined); + assert.deepEqual(calls, [['hasService', 'MissingService']]); +}); + +test('rethrows registered service resolution errors that are not controllers', () => { + class RegisteredService {} + + const error = new ServiceNotFoundException( + 'The service ServiceDependency is not registered', + ); + const kernel = createKernelWithServiceResolver({ + getService: () => { + throw error; + }, + hasService: () => true, + }); + const resolver = new ExpressControllerResolver(kernel, []); + + assert.throws(() => resolver.get(RegisteredService), error); +}); + +test('rethrows dependency resolution errors for known controllers', () => { + class ExternalController {} + + const error = new ServiceNotFoundException( + 'The service ControllerDependency is not registered', + ); + const kernel = createKernelWithServiceResolver({ + getService: () => { + throw error; + }, + hasService: () => true, + }); + const resolver = new ExpressControllerResolver(kernel, [ExternalController]); + + assert.throws(() => resolver.get(ExternalController), error); +}); + +test('runs external routing-controllers without requiring DI registration', async () => { + class ExternalHttpController { + index() { + return { ok: true }; + } + } + + decorateGetController(ExternalHttpController, '/external-http', 'index'); + + const calls = []; + const warnings = []; + const kernel = createKernelWithServiceResolver({ + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + warnings.push(`The service ${ClassDefinition.name} is not registered`); + + throw new ServiceNotFoundException( + `The service ${ClassDefinition.name} is not registered`, + ); + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return false; + }, + }); + const server = new ExpressKernelServer({ + controllers: [ExternalHttpController], + kernel, + port: 0, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/external-http`, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(calls, [['hasService', 'ExternalHttpController']]); + assert.deepEqual(warnings, []); + } finally { + await server.close(); + removeRoutingControllerMetadata(ExternalHttpController); + } +}); + +test('runs kernel registered routes without requiring DI registration', async () => { + class KernelRouteController { + index() { + return { ok: true }; + } + } + + decorateGetController(KernelRouteController, '/kernel-route', 'index'); + + const calls = []; + const warnings = []; + const kernel = new Kernel({ + di: { + compile: async () => {}, + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + warnings.push(`The service ${ClassDefinition.name} is not registered`); + + throw new ServiceNotFoundException( + `The service ${ClassDefinition.name} is not registered`, + ); + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return false; + }, + }, + }); + + await kernel.dependencyInjection(); + kernel.registerRoutes(KernelRouteController); + + const server = new ExpressKernelServer({ + kernel, + port: 0, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/kernel-route`, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(calls, [['hasService', 'KernelRouteController']]); + assert.deepEqual(warnings, []); + } finally { + await server.close(); + removeRoutingControllerMetadata(KernelRouteController); + } +}); + +test('does not fallback to plain construction for registered controller dependency errors', async () => { + class RegisteredHttpController { + index() { + return { ok: true }; + } + } + + decorateGetController(RegisteredHttpController, '/registered-http', 'index'); + + const kernel = createKernelWithServiceResolver({ + getService: () => { + throw new ServiceNotFoundException( + 'The service ControllerDependency is not registered', + ); + }, + hasService: () => true, + }); + const server = new ExpressKernelServer({ + controllers: [RegisteredHttpController], + kernel, + port: 0, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/registered-http`, + ); + + assert.equal(response.status, 500); + const body = await response.json(); + + assert.equal( + body.message, + 'The service ControllerDependency is not registered', + ); + assert.equal(body.name, 'ServiceNotFoundException'); + } finally { + await server.close(); + removeRoutingControllerMetadata(RegisteredHttpController); + } +}); + +test('keeps routing-controllers fallback for plain middleware classes', async () => { + class MiddlewareHttpController { + index() { + return { middleware: true }; + } + } + + class PlainMiddleware { + use(request, response, next) { + void response; + request.middlewareWasCalled = true; + next(); + } + } + + decorateGetController(MiddlewareHttpController, '/middleware-http', 'index'); + Middleware({ type: 'before' })(PlainMiddleware); + + const calls = []; + const warnings = []; + const kernel = createKernelWithServiceResolver({ + getService: (ClassDefinition) => { + calls.push(['getService', ClassDefinition.name]); + warnings.push(`The service ${ClassDefinition.name} is not registered`); + + throw new ServiceNotFoundException( + `The service ${ClassDefinition.name} is not registered`, + ); + }, + hasService: (ClassDefinition) => { + calls.push(['hasService', ClassDefinition.name]); + + return false; + }, + }); + const server = new ExpressKernelServer({ + controllers: [MiddlewareHttpController], + kernel, + port: 0, + routingControllersOptions: { + middlewares: [PlainMiddleware], + }, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/middleware-http`, + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { middleware: true }); + assert.equal( + calls.some( + ([method, service]) => + method === 'hasService' && service === 'PlainMiddleware', + ), + true, + ); + assert.equal( + calls.some( + ([method, service]) => + method === 'hasService' && service === 'MiddlewareHttpController', + ), + true, + ); + assert.equal( + calls.some(([method]) => method === 'getService'), + false, + ); + assert.deepEqual(warnings, []); + } finally { + await server.close(); + removeRoutingControllerMetadata(MiddlewareHttpController); + removeRoutingMiddlewareMetadata(PlainMiddleware); + } +}); + test('registers middleware, hooks and error handlers before running', async () => { const calls = []; const kernel = new Kernel(); diff --git a/tests/dependency-injection.test.mjs b/tests/dependency-injection.test.mjs index db3b46b..7abc711 100644 --- a/tests/dependency-injection.test.mjs +++ b/tests/dependency-injection.test.mjs @@ -70,6 +70,7 @@ test('resolves a concrete class registered in the container', async () => { const service = dependencyInjection.getService(ConcreteService); + assert.equal(dependencyInjection.hasService(ConcreteService), true); assert.ok(service instanceof ConcreteService); }); @@ -90,6 +91,7 @@ test('resolves an abstract parent to its concrete implementation', async () => { const repository = dependencyInjection.getService(ContractRepository); + assert.equal(dependencyInjection.hasService(ContractRepository), true); assert.ok(repository instanceof ConcreteRepository); }); @@ -105,6 +107,7 @@ test('resolves an alias to its target implementation', async () => { const repository = dependencyInjection.getService(AliasRepository); + assert.equal(dependencyInjection.hasService(AliasRepository), true); assert.ok(repository instanceof ConcreteRepository); }); @@ -140,6 +143,7 @@ test('overrides an abstract parent with another registered implementation', asyn const repository = dependencyInjection.getService(ContractRepository); + assert.equal(dependencyInjection.hasService(ContractRepository), true); assert.ok(repository instanceof InMemoryRepository); }); @@ -169,6 +173,7 @@ test('overrides a service with a value instance', async () => { dependencyInjection.applyOverrides(); await dependencyInjection.container.compile(); + assert.equal(dependencyInjection.hasService(ContractRepository), true); assert.equal(dependencyInjection.getService(ContractRepository), repository); }); @@ -198,6 +203,7 @@ test('overrides a service with a factory result', async () => { dependencyInjection.applyOverrides(); await dependencyInjection.container.compile(); + assert.equal(dependencyInjection.hasService(ContractRepository), true); assert.equal(dependencyInjection.getService(ContractRepository), repository); }); @@ -483,6 +489,15 @@ test('throws when singleton instance is requested before configuration', () => { } }); +test('reports missing services without resolving them', () => { + class MissingService {} + + const dependencyInjection = new DependencyInjection(); + + assert.equal(dependencyInjection.hasService(MissingService), false); + assert.equal(dependencyInjection.hasService('missing-service'), false); +}); + test('returns the configured singleton instance', () => { const dependencyInjection = DependencyInjection.configure({ containerBuild: true, @@ -536,6 +551,7 @@ test('falls back to literal container ids when service name is not a class', asy dependencyInjection.registerParentAliases(); await dependencyInjection.container.compile(); + assert.equal(dependencyInjection.hasService('literal-service'), true); assert.ok( dependencyInjection.getService('literal-service') instanceof LiteralService, ); diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index a26dada..2da64c3 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -119,6 +119,7 @@ test('resolves classes through DI registration methods and clears registrations' di: { compile: async () => calls.push('di:compile'), getService: (ClassDefinition) => services.get(ClassDefinition), + hasService: (ClassDefinition) => services.has(ClassDefinition), }, }); @@ -174,6 +175,7 @@ test('keeps the instance kernel active after dependency injection', async () => new Kernel(); }, getService: () => undefined, + hasService: () => false, }, });