From db9aa8a4f300667f8b6358d46d9bca278f320219 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 21 Jan 2025 17:49:41 +0100 Subject: [PATCH] Fixed typing for Command Handler result Without that, it wouldn't be possible to wrap decision logic with async middleware. --- .../handleCommand.middleware.unit.spec.ts | 155 ++++++++++++++++++ .../src/commandHandling/handleCommand.ts | 6 +- 2 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/packages/emmett/src/commandHandling/handleCommand.middleware.unit.spec.ts diff --git a/src/packages/emmett/src/commandHandling/handleCommand.middleware.unit.spec.ts b/src/packages/emmett/src/commandHandling/handleCommand.middleware.unit.spec.ts new file mode 100644 index 00000000..f710177a --- /dev/null +++ b/src/packages/emmett/src/commandHandling/handleCommand.middleware.unit.spec.ts @@ -0,0 +1,155 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it } from 'node:test'; +import { IllegalStateError } from '../errors'; +import { getInMemoryEventStore, type EventStore } from '../eventStore'; +import { assertThrowsAsync, assertTrue } from '../testing'; +import { type Event } from '../typing'; +import { CommandHandler, type HandleOptions } from './handleCommand'; + +// Events & Entity + +type PricedProductItem = { productId: string; quantity: number; price: number }; + +type ShoppingCart = { + productItems: PricedProductItem[]; + totalAmount: number; +}; + +type ProductItemAdded = Event< + 'ProductItemAdded', + { productItem: PricedProductItem } +>; + +type ShoppingCartEvent = ProductItemAdded; + +const evolve = ( + state: ShoppingCart, + { type, data }: ShoppingCartEvent, +): ShoppingCart => { + switch (type) { + case 'ProductItemAdded': { + const productItem = data.productItem; + return { + productItems: [...state.productItems, productItem], + totalAmount: + state.totalAmount + productItem.price * productItem.quantity, + }; + } + } +}; + +const initialState = (): ShoppingCart => { + return { productItems: [], totalAmount: 0 }; +}; + +// Decision making + +type AddProductItem = Event< + 'AddProductItem', + { productItem: PricedProductItem } +>; + +const addProductItem = ( + command: AddProductItem, + _state: ShoppingCart, +): ShoppingCartEvent => { + return { + type: 'ProductItemAdded', + data: { productItem: command.data.productItem }, + }; +}; + +const rawCommandHandler = CommandHandler({ + evolve, + initialState, +}); + +type RequestHeaders = { + authToken: string; +}; + +const VALID_AUTH_TOKEN = 'VALID_AUTH_TOKEN'; +const INVALID_AUTH_TOKEN = 'INVALID_AUTH_TOKEN'; + +export const authorize = (requestHeaders: RequestHeaders): Promise => { + if (requestHeaders.authToken !== VALID_AUTH_TOKEN) + return Promise.reject(new IllegalStateError('Authorization failed!')); + + return Promise.resolve(); +}; + +export const handleCommand = async ( + store: Store, + id: string, + decide: (state: ShoppingCart) => ShoppingCartEvent | ShoppingCartEvent[], + handleOptions: HandleOptions & { requestHeaders: RequestHeaders }, +) => + rawCommandHandler( + store, + id, + async ( + state: ShoppingCart, + ): Promise => { + await authorize(handleOptions.requestHeaders); + + const result = Promise.resolve(decide(state)); + + return result; + }, + handleOptions, + ); + +void describe('Command Handler', () => { + const eventStore = getInMemoryEventStore(); + + void it('Succeeds when middleware allows processing', async () => { + const productItem: PricedProductItem = { + productId: '123', + quantity: 10, + price: 3, + }; + + const shoppingCartId = randomUUID(); + const command: AddProductItem = { + type: 'AddProductItem', + data: { productItem }, + }; + + const { createdNewStream } = await handleCommand( + eventStore, + shoppingCartId, + (state) => addProductItem(command, state), + { requestHeaders: { authToken: VALID_AUTH_TOKEN } }, + ); + + assertTrue(createdNewStream); + }); + + void it('Fails when middleware rejects processing', async () => { + const productItem: PricedProductItem = { + productId: '123', + quantity: 10, + price: 3, + }; + + const shoppingCartId = randomUUID(); + const command: AddProductItem = { + type: 'AddProductItem', + data: { productItem }, + }; + + await assertThrowsAsync( + async () => { + await handleCommand( + eventStore, + shoppingCartId, + (state) => addProductItem(command, state), + { requestHeaders: { authToken: INVALID_AUTH_TOKEN } }, + ); + }, + (error: Error) => + error instanceof IllegalStateError && + error.message === 'Authorization failed!', + ); + }); +}); diff --git a/src/packages/emmett/src/commandHandling/handleCommand.ts b/src/packages/emmett/src/commandHandling/handleCommand.ts index 7718a43f..27d68d35 100644 --- a/src/packages/emmett/src/commandHandling/handleCommand.ts +++ b/src/packages/emmett/src/commandHandling/handleCommand.ts @@ -84,11 +84,7 @@ export const CommandHandler = id: string, handle: ( state: State, - ) => - | StreamEvent - | StreamEvent[] - | Promise - | Promise, + ) => StreamEvent | StreamEvent[] | Promise, handleOptions?: HandleOptions, ): Promise> => asyncRetry(