-
-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fixed typing for Command Handler result
Without that, it wouldn't be possible to wrap decision logic with async middleware.
- Loading branch information
1 parent
e19c23a
commit db9aa8a
Showing
2 changed files
with
156 additions
and
5 deletions.
There are no files selected for viewing
155 changes: 155 additions & 0 deletions
155
src/packages/emmett/src/commandHandling/handleCommand.middleware.unit.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!', | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters