From 547307855f6ed9fa15efb64d1faba1ea95322617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gr=C3=A9goire?= Date: Sun, 16 Feb 2025 20:01:05 +0100 Subject: [PATCH 1/3] feat: add generic parameter for IEventPublisher in EventBus --- src/event-bus.ts | 47 ++++++++++++------- .../events/event-publisher.interface.ts | 20 ++++++-- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/event-bus.ts b/src/event-bus.ts index 5c65dad1..cf551c5e 100644 --- a/src/event-bus.ts +++ b/src/event-bus.ts @@ -28,6 +28,8 @@ import { IEventHandler, IEventPublisher, ISaga, + PublisherPublishAllResult, + PublisherPublishResult, UnhandledExceptionInfo, } from './interfaces'; import { AsyncContext } from './scopes'; @@ -47,7 +49,16 @@ export type EventOperator = UnaryFunction< * @publicApi */ @Injectable() -export class EventBus +export class EventBus< + EventBase extends IEvent = IEvent, + Publisher extends IEventPublisher< + EventBase, + PublishResult, + PublishAllResult + > = IEventPublisher, + PublishResult = PublisherPublishResult, + PublishAllResult = PublisherPublishAllResult, + > extends ObservableBus implements IEventBus, OnModuleDestroy { @@ -56,7 +67,7 @@ export class EventBus public readonly eventOperators: EventOperator[] = []; - private _publisher: IEventPublisher; + private _publisher: Publisher; private readonly _logger = new Logger(EventBus.name); constructor( @@ -73,7 +84,7 @@ export class EventBus this.options?.eventIdProvider ?? defaultEventIdProvider; if (this.options?.eventPublisher) { - this._publisher = this.options.eventPublisher; + this._publisher = this.options.eventPublisher as Publisher; } else { this.useDefaultPublisher(); } @@ -83,7 +94,7 @@ export class EventBus * Returns the publisher. * Default publisher is `DefaultPubSub` (in memory). */ - get publisher(): IEventPublisher { + get publisher(): Publisher { return this._publisher; } @@ -92,7 +103,7 @@ export class EventBus * Default publisher is `DefaultPubSub` (in memory). * @param _publisher The publisher to set. */ - set publisher(_publisher: IEventPublisher) { + set publisher(_publisher: Publisher) { this._publisher = _publisher; } @@ -104,7 +115,7 @@ export class EventBus * Publishes an event. * @param event The event to publish. */ - publish(event: TEvent): any; + publish(event: TEvent): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -113,7 +124,7 @@ export class EventBus publish( event: TEvent, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -122,7 +133,7 @@ export class EventBus publish( event: TEvent, dispatcherContext: TContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -133,7 +144,7 @@ export class EventBus event: TEvent, dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishResult; /** * Publishes an event. * @param event The event to publish. @@ -144,7 +155,7 @@ export class EventBus event: TEvent, dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -165,7 +176,7 @@ export class EventBus * Publishes multiple events. * @param events The events to publish. */ - publishAll(events: TEvent[]): any; + publishAll(events: TEvent[]): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -174,7 +185,7 @@ export class EventBus publishAll( events: TEvent[], asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -183,7 +194,7 @@ export class EventBus publishAll( events: TEvent[], dispatcherContext: TContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -194,7 +205,7 @@ export class EventBus events: TEvent[], dispatcherContext: TContext, asyncContext: AsyncContext, - ): any; + ): PublishAllResult; /** * Publishes multiple events. * @param events The events to publish. @@ -205,7 +216,7 @@ export class EventBus events: TEvent[], dispatcherOrAsyncContext?: TContext | AsyncContext, asyncContext?: AsyncContext, - ) { + ): PublishAllResult { if (!asyncContext && dispatcherOrAsyncContext instanceof AsyncContext) { asyncContext = dispatcherOrAsyncContext; dispatcherOrAsyncContext = undefined; @@ -229,7 +240,7 @@ export class EventBus } return (events || []).map((event) => this._publisher.publish(event, dispatcherOrAsyncContext, asyncContext), - ); + ) as PublishAllResult; } bind(handler: InstanceWrapper>, id: string) { @@ -391,7 +402,9 @@ export class EventBus } private useDefaultPublisher() { - this._publisher = new DefaultPubSub(this.subject$); + this._publisher = new DefaultPubSub( + this.subject$, + ) as unknown as Publisher; } private mapToUnhandledErrorInfo( diff --git a/src/interfaces/events/event-publisher.interface.ts b/src/interfaces/events/event-publisher.interface.ts index 45b0d6dd..1f05b88b 100644 --- a/src/interfaces/events/event-publisher.interface.ts +++ b/src/interfaces/events/event-publisher.interface.ts @@ -1,7 +1,11 @@ import { AsyncContext } from '../../scopes'; import { IEvent } from './event.interface'; -export interface IEventPublisher { +export interface IEventPublisher< + EventBase extends IEvent = IEvent, + PublishResult = any, + PublishAllResult = any, +> { /** * Publishes an event. * @param event The event to publish. @@ -12,7 +16,7 @@ export interface IEventPublisher { event: TEvent, dispatcherContext?: unknown, asyncContext?: AsyncContext, - ): any; + ): PublishResult; /** * Publishes multiple events. @@ -24,5 +28,15 @@ export interface IEventPublisher { events: TEvent[], dispatcherContext?: unknown, asyncContext?: AsyncContext, - ): any; + ): PublishAllResult; } + +export type PublisherPublishResult

= + P extends IEventPublisher ? PublishResult : never; + +export type PublisherPublishAllResult

= + P extends IEventPublisher + ? P['publishAll'] extends Function + ? PublishAllResult + : PublishResult[] + : never; From 455720aa3d4203120692e7c48e0e3291e5b6afc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gr=C3=A9goire?= Date: Sun, 16 Feb 2025 21:32:09 +0100 Subject: [PATCH 2/3] test: Add tests for EventBus and query handlers generics --- test/e2e/generics.spec.ts | 225 ++++++++++++++++++++++++++++++++------ 1 file changed, 192 insertions(+), 33 deletions(-) diff --git a/test/e2e/generics.spec.ts b/test/e2e/generics.spec.ts index 2e369e52..d2bb87fb 100644 --- a/test/e2e/generics.spec.ts +++ b/test/e2e/generics.spec.ts @@ -3,7 +3,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Command, CommandBus, + EventBus, ICommandHandler, + IEvent, + IEventPublisher, + IQueryHandler, Query, QueryBus, } from '../../src'; @@ -32,12 +36,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -53,10 +54,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - value as number; + expectTypeOf(value).toBeAny(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -70,12 +70,9 @@ describe('Generics', () => { try { await commandBus.execute(command).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -91,12 +88,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -112,10 +106,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - value as number; + expectTypeOf(value).toBeAny(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -129,12 +122,9 @@ describe('Generics', () => { try { await queryBus.execute(query).then((value) => { - value as string; - - // @ts-expect-error - value as number; + expectTypeOf(value).toBeString(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -150,14 +140,14 @@ describe('Generics', () => { }> {} class ValidHandler implements ICommandHandler { - execute(command: Test): Promise<{ value: string }> { + execute(): Promise<{ value: string }> { throw new Error('Method not implemented.'); } } class InvalidHandler implements ICommandHandler { - // @ts-expect-error - execute(command: Test): Promise<{ value: number }> { + // @ts-expect-error Expected return type is string + execute(): Promise<{ value: number }> { throw new Error('Method not implemented.'); } } @@ -179,12 +169,55 @@ describe('Generics', () => { ); await commandBus.execute(new Test()).then((value) => { - value.value as string; + expectTypeOf(value).toEqualTypeOf<{ value: string }>(); + }); + } catch { + // Do nothing + } finally { + expect(true).toBeTruthy(); + } + }); + }); + + describe('Query handlers', () => { + it('should infer return type', async () => { + class Test extends Query<{ + value: string; + }> {} + + class ValidHandler implements IQueryHandler { + execute(): Promise<{ value: string }> { + throw new Error('Method not implemented.'); + } + } + + class InvalidHandler implements IQueryHandler { + // @ts-expect-error Expected return type is string + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + try { + queryBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + queryBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); - // @ts-expect-error - value as number; + await queryBus.execute(new Test()).then((value) => { + expectTypeOf(value).toEqualTypeOf<{ value: string }>(); }); - } catch (err) { + } catch { // Do nothing } finally { expect(true).toBeTruthy(); @@ -192,6 +225,132 @@ describe('Generics', () => { }); }); + describe('EventBus', () => { + describe('when custom event type is passed', () => { + class CustomEvent { + constructor(readonly foo: string) {} + } + + class ExtendedCustomEvent extends CustomEvent { + constructor( + foo: string, + readonly bar: string, + ) { + super(foo); + } + } + + let eventBus: EventBus; + + beforeAll(() => { + eventBus = moduleRef.get(EventBus); + }); + + it('publish method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publish requires a CustomEvent + eventBus.publish({ id: 'test' }); + }); + + it('publish method should accept CustomEvent', () => { + eventBus.publish(new CustomEvent('foo')); + }); + + it('publish method should accept CustomEvent extensions', () => { + eventBus.publish(new ExtendedCustomEvent('foo', 'bar')); + }); + + it('publishAll method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publish requires a CustomEvent + eventBus.publishAll([{ id: 'test' }]); + }); + + it('publishAll method should accept CustomEvent', () => { + eventBus.publishAll([new CustomEvent('foo')]); + }); + + it('publishAll method should accept CustomEvent extensions', () => { + eventBus.publishAll([new ExtendedCustomEvent('foo', 'bar')]); + }); + }); + + describe('when default event publisher is used', () => { + let eventBus: EventBus; + + beforeAll(() => { + eventBus = moduleRef.get(EventBus); + }); + + it('publish method should return any', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeAny(); + }); + + it('publishAll method should return array of any', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeAny(); + }); + }); + + describe('when a custom event publisher is used', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + publishAll() { + return true; + } + } + + let eventBus: EventBus; + + beforeAll(() => { + eventBus = moduleRef.get(EventBus); + }); + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should return boolean', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeBoolean(); + }); + }); + + describe('when a custom event publisher is used, but does not implement publishAll', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + } + + let eventBus: EventBus; + + beforeAll(() => { + eventBus = moduleRef.get(EventBus); + }); + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should return boolean', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeString(); + }); + }); + }); + afterAll(async () => { await moduleRef.close(); }); From e27d3ad49464446f1bb78f42b9ac646b04c8e112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Gr=C3=A9goire?= Date: Tue, 28 Jul 2026 22:11:58 +0200 Subject: [PATCH 3/3] test: actually type-check the generics assertions The expectTypeOf assertions in generics.spec.ts were never verified. They are a no-op at runtime, and nothing in the pipeline ran tsc over test/, so a deliberately wrong assertion like expectTypeOf(false).toBeNumber() passed with 44/44 green. Enabling Vitest's typecheck is not sufficient on its own either: its default include only matches *.{test,spec}-d.ts, so *.spec.ts files are silently skipped, and it falls back to the root tsconfig, whose "node10" resolution cannot read the ESM-only type exports of Vitest 4 / Vite 8. - rename generics.spec.ts to generics.spec-d.ts so it is picked up, and drop the try/catch/finally + expect(true).toBeTruthy() scaffolding, which asserted nothing at runtime - enable typecheck in vitest.config.e2e.ts, pointed at test/tsconfig.json - set moduleResolution "bundler" for the test tsconfig Verified by mutation: a wrong expectTypeOf, a removed @ts-expect-error, and a return-type regression on EventBus#publish each fail the e2e run. Co-Authored-By: Claude Opus 5 (1M context) --- test/e2e/generics.spec-d.ts | 265 ++++++++++++++++++++++++++ test/e2e/generics.spec.ts | 357 ------------------------------------ test/tsconfig.json | 5 +- vitest.config.e2e.ts | 4 + 4 files changed, 273 insertions(+), 358 deletions(-) create mode 100644 test/e2e/generics.spec-d.ts delete mode 100644 test/e2e/generics.spec.ts diff --git a/test/e2e/generics.spec-d.ts b/test/e2e/generics.spec-d.ts new file mode 100644 index 00000000..40a01e62 --- /dev/null +++ b/test/e2e/generics.spec-d.ts @@ -0,0 +1,265 @@ +import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; +import { + Command, + CommandBus, + EventBus, + ICommandHandler, + IEvent, + IEventPublisher, + IQueryHandler, + Query, + QueryBus, +} from '../../src'; + +describe('Generics', () => { + let commandBus!: CommandBus; + let queryBus!: QueryBus; + + describe('Commands', () => { + describe('when "Command" utility class is used', () => { + it('should infer return type', () => { + const command = new Command(); + + expectTypeOf(commandBus.execute(command)).resolves.toBeString(); + }); + }); + + describe('when any other class is used', () => { + it('should fallback to any return type', () => { + class MyCommand {} + + const command = new MyCommand(); + + expectTypeOf(commandBus.execute(command)).resolves.toBeAny(); + }); + + it('should use the 2nd generic parameter as return type', () => { + class MyCommand {} + + const command = new MyCommand(); + + expectTypeOf( + commandBus.execute(command), + ).resolves.toBeString(); + }); + }); + }); + + describe('Queries', () => { + describe('when "Query" utility class is used', () => { + it('should infer return type', () => { + const query = new Query(); + + expectTypeOf(queryBus.execute(query)).resolves.toBeString(); + }); + }); + + describe('when any other class is used', () => { + it('should fallback to any return type', () => { + class MyQuery {} + + const query = new MyQuery(); + + expectTypeOf(queryBus.execute(query)).resolves.toBeAny(); + }); + + it('should use the 2nd generic parameter as return type', () => { + class MyQuery {} + + const query = new MyQuery(); + + expectTypeOf( + queryBus.execute(query), + ).resolves.toBeString(); + }); + }); + }); + + describe('Command handlers', () => { + it('should infer return type', () => { + class MyCommand extends Command<{ + value: string; + }> {} + + class ValidHandler implements ICommandHandler { + execute(): Promise<{ value: string }> { + throw new Error('Method not implemented.'); + } + } + + class InvalidHandler implements ICommandHandler { + // @ts-expect-error Expected return type is { value: string } + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + commandBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + commandBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); + + expectTypeOf(commandBus.execute(new MyCommand())).resolves.toEqualTypeOf<{ + value: string; + }>(); + }); + }); + + describe('Query handlers', () => { + it('should infer return type', () => { + class MyQuery extends Query<{ + value: string; + }> {} + + class ValidHandler implements IQueryHandler { + execute(): Promise<{ value: string }> { + throw new Error('Method not implemented.'); + } + } + + class InvalidHandler implements IQueryHandler { + // @ts-expect-error Expected return type is { value: string } + execute(): Promise<{ value: number }> { + throw new Error('Method not implemented.'); + } + } + + queryBus.bind( + new InstanceWrapper({ + metatype: ValidHandler, + instance: new ValidHandler(), + }), + 'Test', + ); + queryBus.bind( + new InstanceWrapper({ + metatype: InvalidHandler, + instance: new InvalidHandler() as any, + }), + 'Test2', + ); + + expectTypeOf(queryBus.execute(new MyQuery())).resolves.toEqualTypeOf<{ + value: string; + }>(); + }); + }); + + describe('EventBus', () => { + describe('when custom event type is passed', () => { + class CustomEvent { + constructor(readonly foo: string) {} + } + + class ExtendedCustomEvent extends CustomEvent { + constructor( + foo: string, + readonly bar: string, + ) { + super(foo); + } + } + + let eventBus!: EventBus; + + it('publish method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publish requires a CustomEvent + eventBus.publish({ id: 'test' }); + }); + + it('publish method should accept CustomEvent', () => { + eventBus.publish(new CustomEvent('foo')); + }); + + it('publish method should accept CustomEvent extensions', () => { + eventBus.publish(new ExtendedCustomEvent('foo', 'bar')); + }); + + it('publishAll method should forbid other objects than CustomEvent', () => { + // @ts-expect-error publishAll requires a CustomEvent + eventBus.publishAll([{ id: 'test' }]); + }); + + it('publishAll method should accept CustomEvent', () => { + eventBus.publishAll([new CustomEvent('foo')]); + }); + + it('publishAll method should accept CustomEvent extensions', () => { + eventBus.publishAll([new ExtendedCustomEvent('foo', 'bar')]); + }); + }); + + describe('when default event publisher is used', () => { + let eventBus!: EventBus; + + it('publish method should return any', () => { + expectTypeOf(eventBus.publish({ id: 'test' })).toBeAny(); + }); + + it('publishAll method should return array of any', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeAny(); + }); + }); + + describe('when a custom event publisher is used', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + publishAll() { + return true; + } + } + + let eventBus!: EventBus; + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should return boolean', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeBoolean(); + }); + }); + + describe('when a custom event publisher is used, but does not implement publishAll', () => { + class Publisher implements IEventPublisher { + publish() { + return 'any string here'; + } + } + + let eventBus!: EventBus; + + it('publish method should return string', () => { + const result = eventBus.publish({ id: 'test' }); + + expectTypeOf(result).toBeString(); + }); + + it('publishAll method should fall back to an array of publish results', () => { + const result = eventBus.publishAll([{ id: 'test' }]); + + expectTypeOf(result).toBeArray(); + expectTypeOf(result).items.toBeString(); + }); + }); + }); +}); diff --git a/test/e2e/generics.spec.ts b/test/e2e/generics.spec.ts deleted file mode 100644 index d2bb87fb..00000000 --- a/test/e2e/generics.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; -import { Test, TestingModule } from '@nestjs/testing'; -import { - Command, - CommandBus, - EventBus, - ICommandHandler, - IEvent, - IEventPublisher, - IQueryHandler, - Query, - QueryBus, -} from '../../src'; -import { AppModule } from '../src/app.module'; - -describe('Generics', () => { - let moduleRef: TestingModule; - let commandBus: CommandBus; - let queryBus: QueryBus; - - beforeAll(async () => { - moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - await moduleRef.init(); - - commandBus = moduleRef.get(CommandBus); - queryBus = moduleRef.get(QueryBus); - }); - - describe('Commands', () => { - describe('when "Command" utility class is used', () => { - it('should infer return type', async () => { - const command = new Command(); - - try { - await commandBus.execute(command).then((value) => { - expectTypeOf(value).toBeString(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('when any other class is used', () => { - it('should fallback to any return type', async () => { - class MyCommand {} - - const command = new MyCommand(); - - try { - await commandBus.execute(command).then((value) => { - expectTypeOf(value).toBeAny(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - - it('should use the 2nd generic parameter as return type', async () => { - class MyCommand {} - - const command = new MyCommand(); - - try { - await commandBus.execute(command).then((value) => { - expectTypeOf(value).toBeString(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - }); - - describe('Queries', () => { - describe('when "Query" utility class is used', () => { - it('should infer return type', async () => { - const query = new Query(); - - try { - await queryBus.execute(query).then((value) => { - expectTypeOf(value).toBeString(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('when any other class is used', () => { - it('should fallback to any return type', async () => { - class MyQuery {} - - const query = new MyQuery(); - - try { - await queryBus.execute(query).then((value) => { - expectTypeOf(value).toBeAny(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - - it('should use the 2nd generic parameter as return type', async () => { - class MyQuery {} - - const query = new MyQuery(); - - try { - await queryBus.execute(query).then((value) => { - expectTypeOf(value).toBeString(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - }); - - describe('Command handlers', () => { - it('should infer return type', async () => { - class Test extends Command<{ - value: string; - }> {} - - class ValidHandler implements ICommandHandler { - execute(): Promise<{ value: string }> { - throw new Error('Method not implemented.'); - } - } - - class InvalidHandler implements ICommandHandler { - // @ts-expect-error Expected return type is string - execute(): Promise<{ value: number }> { - throw new Error('Method not implemented.'); - } - } - - try { - commandBus.bind( - new InstanceWrapper({ - metatype: ValidHandler, - instance: new ValidHandler(), - }), - 'Test', - ); - commandBus.bind( - new InstanceWrapper({ - metatype: InvalidHandler, - instance: new InvalidHandler() as any, - }), - 'Test2', - ); - - await commandBus.execute(new Test()).then((value) => { - expectTypeOf(value).toEqualTypeOf<{ value: string }>(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('Query handlers', () => { - it('should infer return type', async () => { - class Test extends Query<{ - value: string; - }> {} - - class ValidHandler implements IQueryHandler { - execute(): Promise<{ value: string }> { - throw new Error('Method not implemented.'); - } - } - - class InvalidHandler implements IQueryHandler { - // @ts-expect-error Expected return type is string - execute(): Promise<{ value: number }> { - throw new Error('Method not implemented.'); - } - } - - try { - queryBus.bind( - new InstanceWrapper({ - metatype: ValidHandler, - instance: new ValidHandler(), - }), - 'Test', - ); - queryBus.bind( - new InstanceWrapper({ - metatype: InvalidHandler, - instance: new InvalidHandler() as any, - }), - 'Test2', - ); - - await queryBus.execute(new Test()).then((value) => { - expectTypeOf(value).toEqualTypeOf<{ value: string }>(); - }); - } catch { - // Do nothing - } finally { - expect(true).toBeTruthy(); - } - }); - }); - - describe('EventBus', () => { - describe('when custom event type is passed', () => { - class CustomEvent { - constructor(readonly foo: string) {} - } - - class ExtendedCustomEvent extends CustomEvent { - constructor( - foo: string, - readonly bar: string, - ) { - super(foo); - } - } - - let eventBus: EventBus; - - beforeAll(() => { - eventBus = moduleRef.get(EventBus); - }); - - it('publish method should forbid other objects than CustomEvent', () => { - // @ts-expect-error publish requires a CustomEvent - eventBus.publish({ id: 'test' }); - }); - - it('publish method should accept CustomEvent', () => { - eventBus.publish(new CustomEvent('foo')); - }); - - it('publish method should accept CustomEvent extensions', () => { - eventBus.publish(new ExtendedCustomEvent('foo', 'bar')); - }); - - it('publishAll method should forbid other objects than CustomEvent', () => { - // @ts-expect-error publish requires a CustomEvent - eventBus.publishAll([{ id: 'test' }]); - }); - - it('publishAll method should accept CustomEvent', () => { - eventBus.publishAll([new CustomEvent('foo')]); - }); - - it('publishAll method should accept CustomEvent extensions', () => { - eventBus.publishAll([new ExtendedCustomEvent('foo', 'bar')]); - }); - }); - - describe('when default event publisher is used', () => { - let eventBus: EventBus; - - beforeAll(() => { - eventBus = moduleRef.get(EventBus); - }); - - it('publish method should return any', () => { - const result = eventBus.publish({ id: 'test' }); - - expectTypeOf(result).toBeAny(); - }); - - it('publishAll method should return array of any', () => { - const result = eventBus.publishAll([{ id: 'test' }]); - - expectTypeOf(result).toBeArray(); - expectTypeOf(result).items.toBeAny(); - }); - }); - - describe('when a custom event publisher is used', () => { - class Publisher implements IEventPublisher { - publish() { - return 'any string here'; - } - publishAll() { - return true; - } - } - - let eventBus: EventBus; - - beforeAll(() => { - eventBus = moduleRef.get(EventBus); - }); - - it('publish method should return string', () => { - const result = eventBus.publish({ id: 'test' }); - - expectTypeOf(result).toBeString(); - }); - - it('publishAll method should return boolean', () => { - const result = eventBus.publishAll([{ id: 'test' }]); - - expectTypeOf(result).toBeBoolean(); - }); - }); - - describe('when a custom event publisher is used, but does not implement publishAll', () => { - class Publisher implements IEventPublisher { - publish() { - return 'any string here'; - } - } - - let eventBus: EventBus; - - beforeAll(() => { - eventBus = moduleRef.get(EventBus); - }); - - it('publish method should return string', () => { - const result = eventBus.publish({ id: 'test' }); - - expectTypeOf(result).toBeString(); - }); - - it('publishAll method should return boolean', () => { - const result = eventBus.publishAll([{ id: 'test' }]); - - expectTypeOf(result).toBeArray(); - expectTypeOf(result).items.toBeString(); - }); - }); - }); - - afterAll(async () => { - await moduleRef.close(); - }); -}); diff --git a/test/tsconfig.json b/test/tsconfig.json index c776541e..951aa555 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "rootDir": "../" + "rootDir": "../", + // The root config uses "node10" for the published CommonJS build, which + // cannot resolve the ESM-only type exports of Vitest 4 / Vite 8. + "moduleResolution": "bundler" }, "include": [ "src/**/*.ts", diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index ef2048eb..04f4cd4a 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -9,5 +9,9 @@ export default defineConfig({ environment: 'node', include: ['test/e2e/**/*.spec.ts'], fileParallelism: false, + // Type-checks the *.spec-d.ts files. Without an explicit tsconfig this + // falls back to the root one, whose "node10" resolution cannot read the + // ESM-only type exports of Vitest 4 / Vite 8. + typecheck: { enabled: true, tsconfig: 'test/tsconfig.json' }, }, });