Skip to content

Commit

Permalink
Fixed typing for Command Handler result
Browse files Browse the repository at this point in the history
Without that, it wouldn't be possible to wrap decision logic with async middleware.
  • Loading branch information
oskardudycz committed Jan 21, 2025
1 parent e19c23a commit db9aa8a
Show file tree
Hide file tree
Showing 2 changed files with 156 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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<ShoppingCart, ShoppingCartEvent>({
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<void> => {
if (requestHeaders.authToken !== VALID_AUTH_TOKEN)
return Promise.reject(new IllegalStateError('Authorization failed!'));

return Promise.resolve();
};

export const handleCommand = async <Store extends EventStore>(
store: Store,
id: string,
decide: (state: ShoppingCart) => ShoppingCartEvent | ShoppingCartEvent[],
handleOptions: HandleOptions<Store> & { requestHeaders: RequestHeaders },
) =>
rawCommandHandler(
store,
id,
async (
state: ShoppingCart,
): Promise<ShoppingCartEvent | ShoppingCartEvent[]> => {
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<IllegalStateError>(
async () => {
await handleCommand(
eventStore,
shoppingCartId,
(state) => addProductItem(command, state),
{ requestHeaders: { authToken: INVALID_AUTH_TOKEN } },
);
},
(error: Error) =>
error instanceof IllegalStateError &&
error.message === 'Authorization failed!',
);
});
});
6 changes: 1 addition & 5 deletions src/packages/emmett/src/commandHandling/handleCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,7 @@ export const CommandHandler =
id: string,
handle: (
state: State,
) =>
| StreamEvent
| StreamEvent[]
| Promise<StreamEvent>
| Promise<StreamEvent[]>,
) => StreamEvent | StreamEvent[] | Promise<StreamEvent | StreamEvent[]>,
handleOptions?: HandleOptions<Store>,
): Promise<CommandHandlerResult<State, StreamEvent, Store>> =>
asyncRetry(
Expand Down

0 comments on commit db9aa8a

Please sign in to comment.