From c9a75e5b042fb2e15c6233170661b9370d436db7 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Sun, 1 Feb 2026 09:03:05 +0100 Subject: [PATCH] feat: add Given/When/Then test scenario support to @nimbus/eventsourcingdb Introduce a generic Scenario and ThenResult infrastructure for writing Given/When/Then style tests for event-sourced command handlers, and add first domain tests for the user aggregate. --- deno.lock | 3 + examples/eventsourcing-demo/deno.json | 1 + .../iam/users/core/domain/user.test.helper.ts | 54 +++++ .../write/iam/users/core/domain/user.test.ts | 119 +++++++++++ packages/eventsourcingdb/deno.json | 1 + packages/eventsourcingdb/src/index.ts | 1 + packages/eventsourcingdb/src/lib/scenario.ts | 196 ++++++++++++++++++ 7 files changed, 375 insertions(+) create mode 100644 examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.helper.ts create mode 100644 examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.ts create mode 100644 packages/eventsourcingdb/src/lib/scenario.ts diff --git a/deno.lock b/deno.lock index d2a4386..37fb001 100644 --- a/deno.lock +++ b/deno.lock @@ -11,6 +11,7 @@ "jsr:@std/ulid@1": "1.0.0", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@types/node@*": "18.19.130", + "npm:eventsourcingdb@1.8.1": "1.8.1", "npm:eventsourcingdb@^1.8.1": "1.8.1", "npm:hono@^4.11.4": "4.11.4", "npm:mongodb@7": "7.0.0", @@ -1003,6 +1004,7 @@ "members": { "examples/eventsourcing-demo": { "dependencies": [ + "jsr:@std/assert@^1.0.10", "jsr:@std/dotenv@~0.225.6", "jsr:@std/ulid@1", "npm:eventsourcingdb@^1.8.1", @@ -1031,6 +1033,7 @@ }, "packages/eventsourcingdb": { "dependencies": [ + "jsr:@std/assert@^1.0.10", "npm:eventsourcingdb@^1.8.1" ] }, diff --git a/examples/eventsourcing-demo/deno.json b/examples/eventsourcing-demo/deno.json index 287d61c..ac8a875 100644 --- a/examples/eventsourcing-demo/deno.json +++ b/examples/eventsourcing-demo/deno.json @@ -38,6 +38,7 @@ ] }, "imports": { + "@std/assert": "jsr:@std/assert@^1.0.10", "@std/dotenv": "jsr:@std/dotenv@^0.225.6", "@std/ulid": "jsr:@std/ulid@^1.0.0", "eventsourcingdb": "npm:eventsourcingdb@^1.8.1", diff --git a/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.helper.ts b/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.helper.ts new file mode 100644 index 0000000..ace3eb5 --- /dev/null +++ b/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.helper.ts @@ -0,0 +1,54 @@ +import { createCommand, createEvent } from '@nimbus/core'; +import { createScenario } from '@nimbus/eventsourcingdb'; +import { applyEventToUserState, type UserState } from './user.state.ts'; +import { + INVITE_USER_COMMAND_TYPE, + type InviteUserCommand, +} from '../commands/inviteUser.command.ts'; +import { + ACCEPT_USER_INVITATION_COMMAND_TYPE, + type AcceptUserInvitationCommand, +} from '../commands/acceptUserInvitation.command.ts'; +import { + USER_INVITED_EVENT_TYPE, + type UserInvitedEvent, +} from '../events/userInvited.event.ts'; + +const TEST_SOURCE = 'https://test.overlap.at'; + +export const userScenario = () => + createScenario({ id: 'test-user-id' }, applyEventToUserState); + +export const anInviteUserCommand = ( + data: { email: string; firstName: string; lastName: string }, +): InviteUserCommand => + createCommand({ + type: INVITE_USER_COMMAND_TYPE, + source: TEST_SOURCE, + data, + }); + +export const anAcceptUserInvitationCommand = ( + data: { id: string; expectedRevision: string }, +): AcceptUserInvitationCommand => + createCommand({ + type: ACCEPT_USER_INVITATION_COMMAND_TYPE, + source: TEST_SOURCE, + data, + }); + +export const aUserInvitedEvent = ( + data: { + id: string; + email: string; + firstName: string; + lastName: string; + invitedAt: string; + }, +): UserInvitedEvent => + createEvent({ + type: USER_INVITED_EVENT_TYPE, + source: TEST_SOURCE, + subject: `/users/${data.id}`, + data, + }); diff --git a/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.ts b/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.ts new file mode 100644 index 0000000..578138a --- /dev/null +++ b/examples/eventsourcing-demo/src/write/iam/users/core/domain/user.test.ts @@ -0,0 +1,119 @@ +import { inviteUser } from '../commands/inviteUser.command.ts'; +import { acceptUserInvitation } from '../commands/acceptUserInvitation.command.ts'; +import { USER_INVITED_EVENT_TYPE } from '../events/userInvited.event.ts'; +import { USER_INVITATION_ACCEPTED_EVENT_TYPE } from '../events/userInvitationAccepted.event.ts'; +import { + anAcceptUserInvitationCommand, + anInviteUserCommand, + aUserInvitedEvent, + userScenario, +} from './user.test.helper.ts'; + +Deno.test('inviteUser emits UserInvited event', () => { + userScenario() + .given([]) + .when((state) => + inviteUser( + state, + anInviteUserCommand({ + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + }), + ) + ) + .then([{ + type: USER_INVITED_EVENT_TYPE, + data: { + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + }, + }]); +}); + +Deno.test('inviteUser lowercases email', () => { + userScenario() + .given([]) + .when((state) => + inviteUser( + state, + anInviteUserCommand({ + email: 'Jane@Example.COM', + firstName: 'Jane', + lastName: 'Doe', + }), + ) + ) + .then([{ + type: USER_INVITED_EVENT_TYPE, + data: { email: 'jane@example.com' }, + }]); +}); + +Deno.test('acceptUserInvitation emits UserInvitationAccepted event', () => { + userScenario() + .given([ + aUserInvitedEvent({ + id: 'test-user-id', + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + invitedAt: new Date().toISOString(), + }), + ]) + .when((state) => + acceptUserInvitation( + state, + anAcceptUserInvitationCommand({ + id: 'test-user-id', + expectedRevision: 'any', + }), + ) + ) + .then([{ + type: USER_INVITATION_ACCEPTED_EVENT_TYPE, + }]); +}); + +Deno.test('acceptUserInvitation throws if no pending invitation', () => { + userScenario() + .given([]) + .when((state) => + acceptUserInvitation( + state, + anAcceptUserInvitationCommand({ + id: 'test-user-id', + expectedRevision: 'any', + }), + ) + ) + .thenThrows('USER_HAS_NO_PENDING_INVITATION'); +}); + +Deno.test('acceptUserInvitation throws if invitation expired', () => { + const expiredDate = new Date( + Date.now() - 25 * 60 * 60 * 1000, + ).toISOString(); + + userScenario() + .given([ + aUserInvitedEvent({ + id: 'test-user-id', + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + invitedAt: expiredDate, + }), + ]) + .when((state) => + acceptUserInvitation( + state, + anAcceptUserInvitationCommand({ + id: 'test-user-id', + expectedRevision: 'any', + }), + ) + ) + .thenThrows('INVITATION_EXPIRED'); +}); diff --git a/packages/eventsourcingdb/deno.json b/packages/eventsourcingdb/deno.json index 507f817..f5ce54a 100644 --- a/packages/eventsourcingdb/deno.json +++ b/packages/eventsourcingdb/deno.json @@ -34,6 +34,7 @@ ] }, "imports": { + "@std/assert": "jsr:@std/assert@^1.0.10", "eventsourcingdb": "npm:eventsourcingdb@^1.8.1" } } \ No newline at end of file diff --git a/packages/eventsourcingdb/src/index.ts b/packages/eventsourcingdb/src/index.ts index 7cdfaf1..0f16907 100644 --- a/packages/eventsourcingdb/src/index.ts +++ b/packages/eventsourcingdb/src/index.ts @@ -1 +1,2 @@ export * from './lib/client.ts'; +export * from './lib/scenario.ts'; diff --git a/packages/eventsourcingdb/src/lib/scenario.ts b/packages/eventsourcingdb/src/lib/scenario.ts new file mode 100644 index 0000000..b466c1c --- /dev/null +++ b/packages/eventsourcingdb/src/lib/scenario.ts @@ -0,0 +1,196 @@ +import { assertEquals } from '@std/assert'; +import type { Event } from '@nimbus/core'; +import type { Event as EventSourcingDBEvent } from 'eventsourcingdb'; + +/** + * The result of executing a command in a {@link Scenario}. + * + * Provides assertion methods to verify the outcome of the command, + * either by checking the emitted events or by asserting that an + * error was thrown. + * + * @example + * ```ts + * scenario + * .when((state) => inviteUser(state, command)) + * .then([{ type: 'UserInvited', data: { email: 'jane@example.com' } }]); + * ``` + */ +export class ThenResult { + #events: Event[] | undefined; + #error: Error | undefined; + + constructor(handleCommand: () => Event[]) { + try { + this.#events = handleCommand(); + } catch (error) { + this.#error = error as Error; + } + } + + /** + * Assert that the command produced the expected events. + * + * Each expected event is matched by index against the actual events. + * Only the fields present in the expected event are compared, so you + * can assert on a subset of fields. + * + * @param expectedEvents - The expected events to compare against. + * + * @throws If the command threw an error instead of returning events. + * @throws If the number of events does not match. + * @throws If any of the compared fields differ. + */ + then(expectedEvents: Partial[]): void { + if (this.#error) { + throw this.#error; + } + + assertEquals(this.#events!.length, expectedEvents.length); + + for (let i = 0; i < expectedEvents.length; i++) { + const actual = this.#events![i]; + const expected = expectedEvents[i]; + + if (expected.type !== undefined) { + assertEquals(actual.type, expected.type); + } + if (expected.data !== undefined) { + for ( + const [key, value] of Object.entries( + expected.data as Record, + ) + ) { + assertEquals( + (actual.data as Record)[key], + value, + ); + } + } + if (expected.subject !== undefined) { + assertEquals(actual.subject, expected.subject); + } + if (expected.source !== undefined) { + assertEquals(actual.source, expected.source); + } + } + } + + /** + * Assert that the command threw an error with the given error code. + * + * The error code is expected to be found in the `details.errorCode` + * property of the thrown error. + * + * @param errorCode - The expected error code. + * + * @throws If no error was thrown by the command. + * @throws If the error code does not match. + */ + thenThrows(errorCode: string): void { + if (!this.#error) { + throw new Error( + `Expected an error with code '${errorCode}' but none was thrown`, + ); + } + + const details = (this.#error as Error & { + details?: Record; + }).details; + + assertEquals(details?.errorCode, errorCode); + } +} + +/** + * A test scenario for command handlers in an event-sourced domain. + * + * Follows the Given/When/Then pattern: + * - **given**: Replay past events to build up the state. + * - **when**: Execute a command against the current state. + * - **then** / **thenThrows**: Assert the outcome. + * + * @typeParam TState - The type of the domain state. + * + * @example + * ```ts + * import { createScenario } from '@nimbus/eventsourcingdb'; + * + * const scenario = createScenario( + * { id: 'test-id' }, + * applyEventToMyState, + * ); + * + * scenario + * .given([somePastEvent]) + * .when((state) => handleCommand(state, someCommand)) + * .then([{ type: 'SomethingHappened' }]); + * ``` + */ +export class Scenario { + #state: TState; + #applyEvent: (state: TState, event: EventSourcingDBEvent) => TState; + + constructor( + initialState: TState, + applyEvent: (state: TState, event: EventSourcingDBEvent) => TState, + ) { + this.#state = initialState; + this.#applyEvent = applyEvent; + } + + /** + * Replay past events to build up the domain state. + * + * @param events - The events to replay. + * @returns The scenario instance for chaining. + */ + given(events: Event[]): this { + for (const event of events) { + this.#state = this.#applyEvent( + this.#state, + event as unknown as EventSourcingDBEvent, + ); + } + + return this; + } + + /** + * Execute a command against the current state. + * + * @param handleCommand - A function that receives the current state + * and returns the events produced by the command. + * @returns A {@link ThenResult} to assert the outcome. + */ + when(handleCommand: (state: TState) => Event[]): ThenResult { + return new ThenResult(() => handleCommand(this.#state)); + } +} + +/** + * Create a new test scenario for an event-sourced domain. + * + * This is the recommended entry point for setting up Given/When/Then + * style tests for command handlers. + * + * @typeParam TState - The type of the domain state. + * @param initialState - The initial state before any events are applied. + * @param applyEvent - A function that folds an event into the state. + * @returns A new {@link Scenario} instance. + * + * @example + * ```ts + * import { createScenario } from '@nimbus/eventsourcingdb'; + * + * const userScenario = () => + * createScenario( + * { id: 'test-user-id' }, + * applyEventToUserState, + * ); + * ``` + */ +export const createScenario = ( + initialState: TState, + applyEvent: (state: TState, event: EventSourcingDBEvent) => TState, +): Scenario => new Scenario(initialState, applyEvent);