diff --git a/src/packages/emmett/src/testing/deciderSpecification.async.unit.spec.ts b/src/packages/emmett/src/testing/deciderSpecification.async.unit.spec.ts new file mode 100644 index 00000000..f22e26ed --- /dev/null +++ b/src/packages/emmett/src/testing/deciderSpecification.async.unit.spec.ts @@ -0,0 +1,231 @@ +import { describe, it } from 'node:test'; +import { IllegalStateError, ValidationError } from '../errors'; +import { AssertionError, assertTrue } from '../testing/assertions'; +import { type Command, type Event } from '../typing'; +import { DeciderSpecification } from './deciderSpecification'; + +type DoSomething = Command<'Do', { something: string }>; +type SomethingHappened = Event<'Did', { something: string }>; +type Entity = { something: string }; + +const decide = async ( + { data: { something } }: DoSomething, + _entity: Entity, +): Promise => { + const event: SomethingHappened = { type: 'Did', data: { something } }; + + if (something === 'Ignore!') return []; + if (something === 'Array!') return [event]; + if (something !== 'Yes!') throw new IllegalStateError('Nope!'); + + return Promise.resolve(event); +}; + +const initialState = (): Entity => ({ something: 'Meh' }); + +const evolve = (_entity: Entity, _event: SomethingHappened): Entity => ({ + something: 'Nothing', +}); + +const given = DeciderSpecification.for({ + decide: decide, + evolve, + initialState: initialState, +}); + +void describe('AsyncDeciderSpecification', () => { + void describe('then', () => { + void it('then fails if returns event, but assertion has an empty array', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Yes!', + }, + }) + .then([]); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message === + `Arrays lengths don't match:\nExpected: 1\nActual: 0`, + ); + } + }); + }); + + void describe('thenNothingHappened', () => { + void it('thenNothingHappened succeeds if returns empty array', async () => { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Ignore!', + }, + }) + .thenNothingHappened(); + }); + + void it('thenNothingHappened fails if returns event', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Yes!', + }, + }) + .thenNothingHappened(); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message === + `Array is not empty [{"type":"Did","data":{"something":"Yes!"}}]:\nExpected: 1\nActual: 0`, + ); + } + }); + + void it('thenNothingHappened fails if returns array of events', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Array!', + }, + }) + .thenNothingHappened(); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message === + `Array is not empty [{"type":"Did","data":{"something":"Array!"}}]:\nExpected: 1\nActual: 0`, + ); + } + }); + }); + + void describe('thenThrows', () => { + void it('check error was thrown', async () => { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(); + }); + + void it('checks error condition', async () => { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows((error) => error.message === 'Nope!'); + }); + + void it('checks error type', async () => { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(IllegalStateError); + }); + + void it('checks error type and condition', async () => { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(IllegalStateError, (error) => error.message === 'Nope!'); + }); + + void it('fails if no error was thrown', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Yes!', + }, + }) + .thenThrows(); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message === 'Handler did not fail as expected', + ); + } + }); + + void it('fails if wrong error type', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(ValidationError); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message.startsWith( + 'Caught error is not an instance of the expected type:', + ), + ); + } + }); + + void it('fails if wrong error type and correct condition', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(ValidationError, (error) => error.message === 'Nope!'); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message.startsWith( + 'Caught error is not an instance of the expected type:', + ), + ); + } + }); + + void it('fails if correct error type but wrong correct condition', async () => { + try { + await given([]) + .when({ + type: 'Do', + data: { + something: 'Nope!', + }, + }) + .thenThrows(IllegalStateError, (error) => error.message !== 'Nope!'); + } catch (error) { + assertTrue( + error instanceof AssertionError && + error.message === + `Error didn't match the error condition: Error: Nope!`, + ); + } + }); + }); +}); diff --git a/src/packages/emmett/src/testing/deciderSpecification.ts b/src/packages/emmett/src/testing/deciderSpecification.ts index cb07440c..03e67592 100644 --- a/src/packages/emmett/src/testing/deciderSpecification.ts +++ b/src/packages/emmett/src/testing/deciderSpecification.ts @@ -12,7 +12,7 @@ export type ThenThrows = errorCheck?: ErrorCheck, ) => void); -export type DeciderSpecfication = ( +export type DeciderSpecification = ( givenEvents: Event | Event[], ) => { when: (command: Command) => { @@ -23,91 +23,155 @@ export type DeciderSpecfication = ( ) => void; }; }; +export type AsyncDeciderSpecification = ( + givenEvents: Event | Event[], +) => { + when: (command: Command) => { + then: (expectedEvents: Event | Event[]) => Promise; + thenNothingHappened: () => Promise; + thenThrows: ( + ...args: Parameters> + ) => Promise; + }; +}; export const DeciderSpecification = { - for: (decider: { - decide: (command: Command, state: State) => Event | Event[]; - evolve: (state: State, event: Event) => State; - initialState: () => State; - }): DeciderSpecfication => { - { - return (givenEvents: Event | Event[]) => { - return { - when: (command: Command) => { - const handle = () => { - const existingEvents = Array.isArray(givenEvents) - ? givenEvents - : [givenEvents]; - - const currentState = existingEvents.reduce( - decider.evolve, - decider.initialState(), - ); - - return decider.decide(command, currentState); - }; - - return { - then: (expectedEvents: Event | Event[]): void => { - const resultEvents = handle(); - - const resultEventsArray = Array.isArray(resultEvents) - ? resultEvents - : [resultEvents]; - - const expectedEventsArray = Array.isArray(expectedEvents) - ? expectedEvents - : [expectedEvents]; - - assertThatArray(resultEventsArray).containsOnlyElementsMatching( - expectedEventsArray, - ); - }, - thenNothingHappened: (): void => { - const resultEvents = handle(); - - const resultEventsArray = Array.isArray(resultEvents) - ? resultEvents - : [resultEvents]; - - assertThatArray(resultEventsArray).isEmpty(); - }, - thenThrows: ( - ...args: Parameters> - ): void => { - try { - handle(); - throw new AssertionError('Handler did not fail as expected'); - } catch (error) { - if (error instanceof AssertionError) throw error; - - if (args.length === 0) return; - - if (!isErrorConstructor(args[0])) { - assertTrue( - args[0](error as ErrorType), - `Error didn't match the error condition: ${error?.toString()}`, - ); - return; - } - - assertTrue( - error instanceof args[0], - `Caught error is not an instance of the expected type: ${error?.toString()}`, - ); - - if (args[1]) { - assertTrue( - args[1](error as ErrorType), - `Error didn't match the error condition: ${error?.toString()}`, - ); - } + for: deciderSpecificationFor, +}; + +function deciderSpecificationFor(decider: { + decide: (command: Command, state: State) => Event | Event[]; + evolve: (state: State, event: Event) => State; + initialState: () => State; +}): DeciderSpecification; +function deciderSpecificationFor(decider: { + decide: (command: Command, state: State) => Promise; + evolve: (state: State, event: Event) => State; + initialState: () => State; +}): AsyncDeciderSpecification; +function deciderSpecificationFor(decider: { + decide: ( + command: Command, + state: State, + ) => Event | Event[] | Promise; + evolve: (state: State, event: Event) => State; + initialState: () => State; +}): + | DeciderSpecification + | AsyncDeciderSpecification { + { + return (givenEvents: Event | Event[]) => { + return { + when: (command: Command) => { + const handle = () => { + const existingEvents = Array.isArray(givenEvents) + ? givenEvents + : [givenEvents]; + + const currentState = existingEvents.reduce( + decider.evolve, + decider.initialState(), + ); + + return decider.decide(command, currentState); + }; + + return { + then: (expectedEvents: Event | Event[]): void | Promise => { + const resultEvents = handle(); + + if (resultEvents instanceof Promise) { + return resultEvents.then((events) => { + thenHandler(events, expectedEvents); + }); + } + + thenHandler(resultEvents, expectedEvents); + }, + thenNothingHappened: (): void | Promise => { + const resultEvents = handle(); + + if (resultEvents instanceof Promise) { + return resultEvents.then((events) => { + thenNothingHappensHandler(events); + }); + } + + thenNothingHappensHandler(resultEvents); + }, + thenThrows: ( + ...args: Parameters> + ): void | Promise => { + try { + const result = handle(); + if (result instanceof Promise) { + return result + .then(() => { + throw new AssertionError( + 'Handler did not fail as expected', + ); + }) + .catch((error) => { + thenThrowsErrorHandler(error, args); + }); } - }, - }; - }, - }; + throw new AssertionError('Handler did not fail as expected'); + } catch (error) { + thenThrowsErrorHandler(error, args); + } + }, + }; + }, }; - } - }, -}; + }; + } +} + +function thenHandler( + events: Event | Event[], + expectedEvents: Event | Event[], +): void { + const resultEventsArray = Array.isArray(events) ? events : [events]; + + const expectedEventsArray = Array.isArray(expectedEvents) + ? expectedEvents + : [expectedEvents]; + + assertThatArray(resultEventsArray).containsOnlyElementsMatching( + expectedEventsArray, + ); +} + +function thenNothingHappensHandler(events: Event | Event[]): void { + const resultEventsArray = Array.isArray(events) ? events : [events]; + assertThatArray(resultEventsArray).isEmpty(); +} + +function thenThrowsErrorHandler( + error: unknown, + args: Parameters>, +): void { + if (error instanceof AssertionError) throw error; + + if (args.length === 0) return; + + if (!isErrorConstructor(args[0])) { + assertTrue( + args[0](error as ErrorType), + `Error didn't match the error condition: ${error?.toString()}`, + ); + return; + } + + assertTrue( + error instanceof args[0], + `Caught error is not an instance of the expected type: ${error?.toString()}`, + ); + + if (args[1]) { + assertTrue( + args[1](error as ErrorType), + `Error didn't match the error condition: ${error?.toString()}`, + ); + } +}