From 9f322e7e8bdc0ab59634f4aeb689d4c118071643 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Wed, 11 Dec 2024 10:31:22 +0100 Subject: [PATCH] Added exercise with MongoDB event store single stream projection --- .../README.md | 18 + .../mongodb/projections.exercise.test.ts | 517 +++++++++++++++ .../src/core/testing/mongoDB/index.ts | 6 +- .../README.md | 18 + .../mongodb/projections.solved.test.ts | 619 ++++++++++++++++++ 5 files changed, 1177 insertions(+), 1 deletion(-) create mode 100644 workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/README.md create mode 100644 workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/mongodb/projections.exercise.test.ts create mode 100644 workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/README.md create mode 100644 workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/mongodb/projections.solved.test.ts diff --git a/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/README.md b/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/README.md new file mode 100644 index 0000000..f54eb76 --- /dev/null +++ b/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/README.md @@ -0,0 +1,18 @@ +# Exercise 14 - Projections + +With the selected Emmett's storage, implement the following projections: + +1. Detailed view of the shopping cart: + - total amount of products in the basket, + - total number of products + - list of products (e.g. if someone added the same product twice, then we should have one element with the sum). +2. View with short information about pending shopping carts. It's intended to be used as list view for administration: + - total amount of products in the basket, + - total number of products + - confirmed and canceled shopping carts should not be visible. + +Define evolve function in projection definition at the bottom of [projections.exercise.test.ts](./mongodb/projections.exercise.test.ts). + +Read more about projections in my article: + +- [Guide to Projections and Read Models in Event-Driven Architecture](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/?utm_source=event_sourcing_nodejs&utm_campaign=workshop) diff --git a/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/mongodb/projections.exercise.test.ts b/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/mongodb/projections.exercise.test.ts new file mode 100644 index 0000000..c037465 --- /dev/null +++ b/workshops/introduction_to_event_sourcing/src/14_projections_single_stream_emmett/mongodb/projections.exercise.test.ts @@ -0,0 +1,517 @@ +import { + getMongoDBTestClient, + releaseMongoDBContainer, +} from '#core/testing/mongoDB'; +import { projections, type Event } from '@event-driven-io/emmett'; +import { + EventStream, + MongoDBEventStore, + MongoDBReadModel, + getMongoDBEventStore, + mongoDBInlineProjection, + toStreamCollectionName, + toStreamName, +} from '@event-driven-io/emmett-mongodb'; +import { MongoClient } from 'mongodb'; +import { v4 as uuid } from 'uuid'; + +export interface ProductItem { + productId: string; + quantity: number; +} + +export type PricedProductItem = ProductItem & { + unitPrice: number; +}; + +export type ShoppingCartOpened = Event< + 'ShoppingCartOpened', + { + shoppingCartId: string; + clientId: string; + openedAt: string; + } +>; + +export type ProductItemAddedToShoppingCart = Event< + 'ProductItemAddedToShoppingCart', + { + shoppingCartId: string; + productItem: PricedProductItem; + } +>; + +export type ProductItemRemovedFromShoppingCart = Event< + 'ProductItemRemovedFromShoppingCart', + { + shoppingCartId: string; + productItem: PricedProductItem; + } +>; + +export type ShoppingCartConfirmed = Event< + 'ShoppingCartConfirmed', + { + shoppingCartId: string; + confirmedAt: string; + } +>; + +export type ShoppingCartCanceled = Event< + 'ShoppingCartCanceled', + { + shoppingCartId: string; + canceledAt: string; + } +>; + +export type ShoppingCartEvent = + | ShoppingCartOpened + | ProductItemAddedToShoppingCart + | ProductItemRemovedFromShoppingCart + | ShoppingCartConfirmed + | ShoppingCartCanceled; + +export enum ShoppingCartStatus { + Pending = 'Pending', + Confirmed = 'Confirmed', + Canceled = 'Canceled', +} + +export type ShoppingCartDetails = { + id: string; + clientId: string; + status: ShoppingCartStatus; + productItems: PricedProductItem[]; + openedAt: string; + confirmedAt?: string; + canceledAt?: string; + totalAmount: number; + totalItemsCount: number; +}; + +export type ShoppingCartShortInfo = { + id: string; + clientId: string; + totalAmount: number; + totalItemsCount: number; +}; + +describe('Getting state from events', () => { + let eventStore: MongoDBEventStore; + let mongo: MongoClient; + + beforeAll(async () => { + mongo = await getMongoDBTestClient(); + + eventStore = getMongoDBEventStore({ + client: mongo, + projections: projections.inline([detailsProjection, shortInfoProjection]), + }); + }); + + afterAll(async () => { + await eventStore.close(); + await releaseMongoDBContainer(); + }); + + it('Should return the state from the sequence of events', async () => { + const openedAt = new Date().toISOString(); + const confirmedAt = new Date().toISOString(); + const canceledAt = new Date().toISOString(); + + const shoesId = uuid(); + + const twoPairsOfShoes: PricedProductItem = { + productId: shoesId, + quantity: 2, + unitPrice: 200, + }; + const pairOfShoes: PricedProductItem = { + productId: shoesId, + quantity: 1, + unitPrice: 200, + }; + + const tShirtId = uuid(); + const tShirt: PricedProductItem = { + productId: tShirtId, + quantity: 1, + unitPrice: 50, + }; + + const dressId = uuid(); + const dress: PricedProductItem = { + productId: dressId, + quantity: 3, + unitPrice: 150, + }; + + const trousersId = uuid(); + const trousers: PricedProductItem = { + productId: trousersId, + quantity: 1, + unitPrice: 300, + }; + + const streamType = 'shopping_cart'; + + const shoppingCartId = toStreamName(streamType, uuid()); + const cancelledShoppingCartId = toStreamName(streamType, uuid()); + const otherClientShoppingCartId = toStreamName(streamType, uuid()); + const otherConfirmedShoppingCartId = toStreamName(streamType, uuid()); + const otherPendingShoppingCartId = toStreamName(streamType, uuid()); + + const clientId = uuid(); + const otherClientId = uuid(); + + const database = mongo.db(); + + const shoppingCartStreams = database.collection< + EventStream + >(toStreamCollectionName(streamType)); + + // TODO: + // 1. Register here your event handlers using `eventStore.subscribe`. + // 2. Store results in database. + + // first confirmed + await eventStore.appendToStream(shoppingCartId, [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem: twoPairsOfShoes, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem: tShirt, + }, + }, + { + type: 'ProductItemRemovedFromShoppingCart', + data: { + shoppingCartId, + productItem: pairOfShoes, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId, + confirmedAt, + }, + }, + ]); + + // cancelled + await eventStore.appendToStream( + cancelledShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: cancelledShoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: cancelledShoppingCartId, + productItem: dress, + }, + }, + { + type: 'ShoppingCartCanceled', + data: { + shoppingCartId: cancelledShoppingCartId, + canceledAt, + }, + }, + ], + ); + + // confirmed but other client + await eventStore.appendToStream( + otherClientShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherClientShoppingCartId, + clientId: otherClientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: otherClientShoppingCartId, + productItem: dress, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId: otherClientShoppingCartId, + confirmedAt, + }, + }, + ], + ); + + // second confirmed + await eventStore.appendToStream( + otherConfirmedShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + productItem: trousers, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + confirmedAt, + }, + }, + ], + ); + + // first pending + await eventStore.appendToStream( + otherPendingShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherPendingShoppingCartId, + clientId, + openedAt, + }, + }, + ], + ); + + // first confirmed + let shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: shoppingCartId, + }); + + expect(shoppingCartStream).not.toBeNull(); + + let shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: shoppingCartId, + clientId, + status: ShoppingCartStatus.Confirmed, + productItems: [pairOfShoes, tShirt], + openedAt, + confirmedAt, + totalAmount: + pairOfShoes.unitPrice * pairOfShoes.quantity + + tShirt.unitPrice * tShirt.quantity, + totalItemsCount: pairOfShoes.quantity + tShirt.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 5, + }, + }); + + let shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // cancelled + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: cancelledShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: cancelledShoppingCartId, + clientId, + status: ShoppingCartStatus.Canceled, + productItems: [dress], + openedAt, + canceledAt, + totalAmount: dress.unitPrice * dress.quantity, + totalItemsCount: dress.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // confirmed but other client + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherClientShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: otherClientShoppingCartId, + clientId: otherClientId, + status: ShoppingCartStatus.Confirmed, + productItems: [dress], + openedAt, + confirmedAt, + totalAmount: dress.unitPrice * dress.quantity, + totalItemsCount: dress.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // second confirmed + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherConfirmedShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: otherConfirmedShoppingCartId, + clientId, + status: ShoppingCartStatus.Confirmed, + productItems: [trousers], + openedAt, + confirmedAt, + totalAmount: trousers.unitPrice * trousers.quantity, + totalItemsCount: trousers.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // first pending + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherPendingShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + expect(shoppingCart).toEqual({ + id: otherPendingShoppingCartId, + clientId, + status: ShoppingCartStatus.Pending, + productItems: [], + openedAt, + totalAmount: 0, + totalItemsCount: 0, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 1, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + + expect(shoppingCartShortInfo).toStrictEqual({ + id: otherPendingShoppingCartId, + clientId, + totalAmount: 0, + totalItemsCount: 0, + _metadata: { + name: 'short_info', + schemaVersion: 1, + streamPosition: 1, + }, + }); + }); +}); + +const detailsProjection = mongoDBInlineProjection({ + canHandle: [ + 'ShoppingCartOpened', + 'ProductItemAddedToShoppingCart', + 'ProductItemRemovedFromShoppingCart', + 'ShoppingCartConfirmed', + 'ShoppingCartCanceled', + ], + evolve: ( + _document: ShoppingCartDetails | null, + _event: ShoppingCartEvent, + ): ShoppingCartDetails => { + throw new Error('Not implemented!'); + }, +}); + +const shortInfoProjection = mongoDBInlineProjection({ + name: 'short_info', + canHandle: [ + 'ShoppingCartOpened', + 'ProductItemAddedToShoppingCart', + 'ProductItemRemovedFromShoppingCart', + 'ShoppingCartConfirmed', + 'ShoppingCartCanceled', + ], + initialState: () => ({}) as ShoppingCartShortInfo, + evolve: ( + _document: ShoppingCartShortInfo, + _event: ShoppingCartEvent, + ): ShoppingCartShortInfo | null => { + throw new Error('Not implemented!'); + }, +}); diff --git a/workshops/introduction_to_event_sourcing/src/core/testing/mongoDB/index.ts b/workshops/introduction_to_event_sourcing/src/core/testing/mongoDB/index.ts index 8ecedab..6d9d1ef 100644 --- a/workshops/introduction_to_event_sourcing/src/core/testing/mongoDB/index.ts +++ b/workshops/introduction_to_event_sourcing/src/core/testing/mongoDB/index.ts @@ -22,9 +22,13 @@ export const getMongoDBTestClient = async ( connectionString = 'mongodb://localhost:27017/'; } - return new MongoClient(connectionString, { + const client = new MongoClient(connectionString, { directConnection: true, }); + + await client.connect(); + + return client; }; export const releaseMongoDBContainer = async () => { diff --git a/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/README.md b/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/README.md new file mode 100644 index 0000000..f54eb76 --- /dev/null +++ b/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/README.md @@ -0,0 +1,18 @@ +# Exercise 14 - Projections + +With the selected Emmett's storage, implement the following projections: + +1. Detailed view of the shopping cart: + - total amount of products in the basket, + - total number of products + - list of products (e.g. if someone added the same product twice, then we should have one element with the sum). +2. View with short information about pending shopping carts. It's intended to be used as list view for administration: + - total amount of products in the basket, + - total number of products + - confirmed and canceled shopping carts should not be visible. + +Define evolve function in projection definition at the bottom of [projections.exercise.test.ts](./mongodb/projections.exercise.test.ts). + +Read more about projections in my article: + +- [Guide to Projections and Read Models in Event-Driven Architecture](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/?utm_source=event_sourcing_nodejs&utm_campaign=workshop) diff --git a/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/mongodb/projections.solved.test.ts b/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/mongodb/projections.solved.test.ts new file mode 100644 index 0000000..6d69ce0 --- /dev/null +++ b/workshops/introduction_to_event_sourcing/src/solved/14_projections_single_stream_emmett/mongodb/projections.solved.test.ts @@ -0,0 +1,619 @@ +import { + getMongoDBTestClient, + releaseMongoDBContainer, +} from '#core/testing/mongoDB'; +import { projections, type Event } from '@event-driven-io/emmett'; +import { + EventStream, + MongoDBEventStore, + MongoDBReadModel, + getMongoDBEventStore, + mongoDBInlineProjection, + toStreamCollectionName, + toStreamName, +} from '@event-driven-io/emmett-mongodb'; +import { MongoClient } from 'mongodb'; +import { v4 as uuid } from 'uuid'; + +export interface ProductItem { + productId: string; + quantity: number; +} + +export type PricedProductItem = ProductItem & { + unitPrice: number; +}; + +export type ShoppingCartOpened = Event< + 'ShoppingCartOpened', + { + shoppingCartId: string; + clientId: string; + openedAt: string; + } +>; + +export type ProductItemAddedToShoppingCart = Event< + 'ProductItemAddedToShoppingCart', + { + shoppingCartId: string; + productItem: PricedProductItem; + } +>; + +export type ProductItemRemovedFromShoppingCart = Event< + 'ProductItemRemovedFromShoppingCart', + { + shoppingCartId: string; + productItem: PricedProductItem; + } +>; + +export type ShoppingCartConfirmed = Event< + 'ShoppingCartConfirmed', + { + shoppingCartId: string; + confirmedAt: string; + } +>; + +export type ShoppingCartCanceled = Event< + 'ShoppingCartCanceled', + { + shoppingCartId: string; + canceledAt: string; + } +>; + +export type ShoppingCartEvent = + | ShoppingCartOpened + | ProductItemAddedToShoppingCart + | ProductItemRemovedFromShoppingCart + | ShoppingCartConfirmed + | ShoppingCartCanceled; + +export enum ShoppingCartStatus { + Pending = 'Pending', + Confirmed = 'Confirmed', + Canceled = 'Canceled', +} + +export type ShoppingCartDetails = { + id: string; + clientId: string; + status: ShoppingCartStatus; + productItems: PricedProductItem[]; + openedAt: string; + confirmedAt?: string; + canceledAt?: string; + totalAmount: number; + totalItemsCount: number; +}; + +export type ShoppingCartShortInfo = { + id: string; + clientId: string; + totalAmount: number; + totalItemsCount: number; +}; + +describe('Getting state from events', () => { + let eventStore: MongoDBEventStore; + let mongo: MongoClient; + + beforeAll(async () => { + mongo = await getMongoDBTestClient(); + + eventStore = getMongoDBEventStore({ + client: mongo, + projections: projections.inline([detailsProjection, shortInfoProjection]), + }); + }); + + afterAll(async () => { + await eventStore.close(); + await releaseMongoDBContainer(); + }); + + it('Should return the state from the sequence of events', async () => { + const openedAt = new Date().toISOString(); + const confirmedAt = new Date().toISOString(); + const canceledAt = new Date().toISOString(); + + const shoesId = uuid(); + + const twoPairsOfShoes: PricedProductItem = { + productId: shoesId, + quantity: 2, + unitPrice: 200, + }; + const pairOfShoes: PricedProductItem = { + productId: shoesId, + quantity: 1, + unitPrice: 200, + }; + + const tShirtId = uuid(); + const tShirt: PricedProductItem = { + productId: tShirtId, + quantity: 1, + unitPrice: 50, + }; + + const dressId = uuid(); + const dress: PricedProductItem = { + productId: dressId, + quantity: 3, + unitPrice: 150, + }; + + const trousersId = uuid(); + const trousers: PricedProductItem = { + productId: trousersId, + quantity: 1, + unitPrice: 300, + }; + + const streamType = 'shopping_cart'; + + const shoppingCartId = toStreamName(streamType, uuid()); + const cancelledShoppingCartId = toStreamName(streamType, uuid()); + const otherClientShoppingCartId = toStreamName(streamType, uuid()); + const otherConfirmedShoppingCartId = toStreamName(streamType, uuid()); + const otherPendingShoppingCartId = toStreamName(streamType, uuid()); + + const clientId = uuid(); + const otherClientId = uuid(); + + const database = mongo.db(); + + const shoppingCartStreams = database.collection< + EventStream + >(toStreamCollectionName(streamType)); + + // TODO: + // 1. Register here your event handlers using `eventStore.subscribe`. + // 2. Store results in database. + + // first confirmed + await eventStore.appendToStream(shoppingCartId, [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem: twoPairsOfShoes, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem: tShirt, + }, + }, + { + type: 'ProductItemRemovedFromShoppingCart', + data: { + shoppingCartId, + productItem: pairOfShoes, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId, + confirmedAt, + }, + }, + ]); + + // cancelled + await eventStore.appendToStream( + cancelledShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: cancelledShoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: cancelledShoppingCartId, + productItem: dress, + }, + }, + { + type: 'ShoppingCartCanceled', + data: { + shoppingCartId: cancelledShoppingCartId, + canceledAt, + }, + }, + ], + ); + + // confirmed but other client + await eventStore.appendToStream( + otherClientShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherClientShoppingCartId, + clientId: otherClientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: otherClientShoppingCartId, + productItem: dress, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId: otherClientShoppingCartId, + confirmedAt, + }, + }, + ], + ); + + // second confirmed + await eventStore.appendToStream( + otherConfirmedShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + clientId, + openedAt, + }, + }, + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + productItem: trousers, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId: otherConfirmedShoppingCartId, + confirmedAt, + }, + }, + ], + ); + + // first pending + await eventStore.appendToStream( + otherPendingShoppingCartId, + [ + { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: otherPendingShoppingCartId, + clientId, + openedAt, + }, + }, + ], + ); + + // first confirmed + let shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: shoppingCartId, + }); + + expect(shoppingCartStream).not.toBeNull(); + + let shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: shoppingCartId, + clientId, + status: ShoppingCartStatus.Confirmed, + productItems: [pairOfShoes, tShirt], + openedAt, + confirmedAt, + totalAmount: + pairOfShoes.unitPrice * pairOfShoes.quantity + + tShirt.unitPrice * tShirt.quantity, + totalItemsCount: pairOfShoes.quantity + tShirt.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 5, + }, + }); + + let shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // cancelled + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: cancelledShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: cancelledShoppingCartId, + clientId, + status: ShoppingCartStatus.Canceled, + productItems: [dress], + openedAt, + canceledAt, + totalAmount: dress.unitPrice * dress.quantity, + totalItemsCount: dress.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // confirmed but other client + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherClientShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: otherClientShoppingCartId, + clientId: otherClientId, + status: ShoppingCartStatus.Confirmed, + productItems: [dress], + openedAt, + confirmedAt, + totalAmount: dress.unitPrice * dress.quantity, + totalItemsCount: dress.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // second confirmed + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherConfirmedShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + + expect(shoppingCart).toEqual({ + id: otherConfirmedShoppingCartId, + clientId, + status: ShoppingCartStatus.Confirmed, + productItems: [trousers], + openedAt, + confirmedAt, + totalAmount: trousers.unitPrice * trousers.quantity, + totalItemsCount: trousers.quantity, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 3, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + expect(shoppingCartShortInfo).toBeNull(); + + // first pending + shoppingCartStream = await shoppingCartStreams.findOne({ + streamName: otherPendingShoppingCartId, + }); + + shoppingCart = shoppingCartStream!.projections[ + '_default' + ] as MongoDBReadModel; + expect(shoppingCart).toEqual({ + id: otherPendingShoppingCartId, + clientId, + status: ShoppingCartStatus.Pending, + productItems: [], + openedAt, + totalAmount: 0, + totalItemsCount: 0, + _metadata: { + name: '_default', + schemaVersion: 1, + streamPosition: 1, + }, + }); + + shoppingCartShortInfo = shoppingCartStream!.projections[ + 'short_info' + ] as MongoDBReadModel; + + expect(shoppingCartShortInfo).toStrictEqual({ + id: otherPendingShoppingCartId, + clientId, + totalAmount: 0, + totalItemsCount: 0, + _metadata: { + name: 'short_info', + schemaVersion: 1, + streamPosition: 1, + }, + }); + }); +}); + +const detailsProjection = mongoDBInlineProjection({ + name: '_default', + canHandle: [ + 'ShoppingCartOpened', + 'ProductItemAddedToShoppingCart', + 'ProductItemRemovedFromShoppingCart', + 'ShoppingCartConfirmed', + 'ShoppingCartCanceled', + ], + initialState: () => ({}) as ShoppingCartDetails, + evolve: ( + document: ShoppingCartDetails, + { type, data: event }: ShoppingCartEvent, + ): ShoppingCartDetails => { + switch (type) { + case 'ShoppingCartOpened': + return { + id: event.shoppingCartId, + status: ShoppingCartStatus.Pending, + clientId: event.clientId, + productItems: [], + openedAt: event.openedAt, + totalAmount: 0, + totalItemsCount: 0, + }; + case 'ProductItemAddedToShoppingCart': { + const { productItem } = event; + const existingProductItem = document.productItems.find( + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + ); + + if (existingProductItem == null) { + document.productItems.push({ ...productItem }); + } else { + document.productItems[ + document.productItems.indexOf(existingProductItem) + ].quantity += productItem.quantity; + } + + document.totalAmount += productItem.quantity * productItem.unitPrice; + document.totalItemsCount += productItem.quantity; + + return document; + } + case 'ProductItemRemovedFromShoppingCart': { + const { productItem } = event; + const existingProductItem = document.productItems.find( + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + ); + + if (existingProductItem == null) { + // You may consider throwing exception here, depending on your strategy + return document; + } + + existingProductItem.quantity -= productItem.quantity; + + if (existingProductItem.quantity == 0) { + document.productItems.splice( + document.productItems.indexOf(existingProductItem), + 1, + ); + } + + document.totalAmount -= productItem.quantity * productItem.unitPrice; + document.totalItemsCount -= productItem.quantity; + + return document; + } + case 'ShoppingCartConfirmed': { + document.status = ShoppingCartStatus.Confirmed; + document.confirmedAt = event.confirmedAt; + + return document; + } + case 'ShoppingCartCanceled': { + document.status = ShoppingCartStatus.Canceled; + document.canceledAt = event.canceledAt; + + return document; + } + } + }, +}); + +const shortInfoProjection = mongoDBInlineProjection({ + name: 'short_info', + canHandle: [ + 'ShoppingCartOpened', + 'ProductItemAddedToShoppingCart', + 'ProductItemRemovedFromShoppingCart', + 'ShoppingCartConfirmed', + 'ShoppingCartCanceled', + ], + initialState: () => ({}) as ShoppingCartShortInfo, + evolve: ( + document: ShoppingCartShortInfo, + { type, data: event }: ShoppingCartEvent, + ): ShoppingCartShortInfo | null => { + switch (type) { + case 'ShoppingCartOpened': + return { + id: event.shoppingCartId, + clientId: event.clientId, + totalAmount: 0, + totalItemsCount: 0, + }; + case 'ProductItemAddedToShoppingCart': { + const { productItem } = event; + + document.totalAmount += productItem.quantity * productItem.unitPrice; + document.totalItemsCount += productItem.quantity; + + return document; + } + case 'ProductItemRemovedFromShoppingCart': { + const { productItem } = event; + + document.totalAmount -= productItem.quantity * productItem.unitPrice; + document.totalItemsCount -= productItem.quantity; + + return document; + } + case 'ShoppingCartConfirmed': + return null; + case 'ShoppingCartCanceled': { + return null; + } + } + }, +});