diff --git a/docs/getting-started.md b/docs/getting-started.md index 50887319..8d9f7a61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -260,7 +260,8 @@ We recommend providing different web app configurations for different endpoints' That's what we did in our case. We've set up our Shopping Carts API and injected external dependencies: - event store to store and retrieve events, -- The `getUnitPrice` method represents a call to an external service to get the price of a product added to the shopping cart. +- The `getUnitPrice` method represents a call to an external service to get the price of a product added to the shopping cart, +- We're also passing the current date generator. Embracing this non-deterministic dependency will be helpful for integration testing later on. That clearly explains what dependencies this API needs, and by reading the file, you can understand what your application technology needs. That should cut the onboarding time for new people grasping our system setup. diff --git a/docs/snippets/gettingStarted/businessLogic.unit.spec.ts b/docs/snippets/gettingStarted/businessLogic.unit.spec.ts index a0212e0e..90749571 100644 --- a/docs/snippets/gettingStarted/businessLogic.unit.spec.ts +++ b/docs/snippets/gettingStarted/businessLogic.unit.spec.ts @@ -50,20 +50,18 @@ describe('ShoppingCart', () => { }, }) .when({ - type: 'AddProductItemToShoppingCart', + type: 'ConfirmShoppingCart', data: { shoppingCartId, - productItem, }, metadata: { now }, }) .then([ { - type: 'ProductItemAddedToShoppingCart', + type: 'ShoppingCartConfirmed', data: { shoppingCartId, - productItem, - addedAt: now, + confirmedAt: now, }, }, ]); diff --git a/docs/snippets/gettingStarted/webApi/apiBDD.int.spec.ts b/docs/snippets/gettingStarted/webApi/apiBDD.int.spec.ts new file mode 100644 index 00000000..c0486e3d --- /dev/null +++ b/docs/snippets/gettingStarted/webApi/apiBDD.int.spec.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { + getInMemoryEventStore, + type EventStore, +} from '@event-driven-io/emmett'; +import { + ApiSpecification, + existingStream, + expectError, + expectNewEvents, + expectResponse, + getApplication, +} from '@event-driven-io/emmett-expressjs'; +import { beforeEach, describe, it } from 'node:test'; +import { v4 as uuid } from 'uuid'; +import type { PricedProductItem, ShoppingCartEvent } from '../events'; +import { shoppingCartApi } from './simpleApi'; + +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; + +describe('ShoppingCart', () => { + let clientId: string; + let shoppingCartId: string; + beforeEach(() => { + clientId = uuid(); + shoppingCartId = `shopping_cart:${clientId}:current`; + }); + + describe('When empty', () => { + it('should add product item', () => { + return given() + .when((request) => + request + .post(`/clients/${clientId}/shopping-carts/current/product-items`) + .send(productItem), + ) + .then([ + expectNewEvents(shoppingCartId, [ + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem, + addedAt: now, + }, + }, + ]), + ]); + }); + }); + + describe('When opened with product item', () => { + it('should confirm', () => { + return given( + existingStream(shoppingCartId, [ + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem, + addedAt: oldTime, + }, + }, + ]), + ) + .when((request) => + request.post(`/clients/${clientId}/shopping-carts/current/confirm`), + ) + .then([ + expectResponse(204), + expectNewEvents(shoppingCartId, [ + { + type: 'ShoppingCartConfirmed', + data: { + shoppingCartId, + confirmedAt: now, + }, + }, + ]), + ]); + }); + }); + + describe('When confirmed', () => { + it('should not add products', () => { + return given( + existingStream(shoppingCartId, [ + { + type: 'ProductItemAddedToShoppingCart', + data: { + shoppingCartId, + productItem, + addedAt: oldTime, + }, + }, + { + type: 'ShoppingCartConfirmed', + data: { shoppingCartId, confirmedAt: oldTime }, + }, + ]), + ) + .when((request) => + request + .post(`/clients/${clientId}/shopping-carts/current/product-items`) + .send(productItem), + ) + .then( + expectError(403, { + detail: 'Shopping Cart already closed', + status: 403, + title: 'Forbidden', + type: 'about:blank', + }), + ); + }); + }); + + const oldTime = new Date(); + const now = new Date(); + + const given = ApiSpecification.for( + (): EventStore => getInMemoryEventStore(), + (eventStore: EventStore) => + getApplication({ + apis: [shoppingCartApi(eventStore, getUnitPrice, () => now)], + }), + ); + + const getRandomProduct = (): PricedProductItem => { + return { + productId: uuid(), + unitPrice: 100, + quantity: Math.random() * 10, + }; + }; + + const productItem = getRandomProduct(); +}); diff --git a/docs/snippets/gettingStarted/webApi/apiSetup.ts b/docs/snippets/gettingStarted/webApi/apiSetup.ts index 272384cd..27a88551 100644 --- a/docs/snippets/gettingStarted/webApi/apiSetup.ts +++ b/docs/snippets/gettingStarted/webApi/apiSetup.ts @@ -11,5 +11,9 @@ import { getInMemoryEventStore } from '@event-driven-io/emmett'; const eventStore = getInMemoryEventStore(); -const shoppingCarts = shoppingCartApi(eventStore, getUnitPrice); +const shoppingCarts = shoppingCartApi( + eventStore, + getUnitPrice, + () => new Date(), +); // #endregion getting-started-api-setup diff --git a/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts b/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts index 9e4b6860..182622bd 100644 --- a/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts +++ b/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts @@ -24,7 +24,9 @@ describe('Simple Api from getting started', () => { beforeEach(() => { eventStore = getInMemoryEventStore(); - app = getApplication({ apis: [shoppingCartApi(eventStore, getUnitPrice)] }); + app = getApplication({ + apis: [shoppingCartApi(eventStore, getUnitPrice, () => new Date())], + }); }); it('Should handle requests correctly', async () => { diff --git a/docs/snippets/gettingStarted/webApi/simpleApi.ts b/docs/snippets/gettingStarted/webApi/simpleApi.ts index 986399c2..ad09a86f 100644 --- a/docs/snippets/gettingStarted/webApi/simpleApi.ts +++ b/docs/snippets/gettingStarted/webApi/simpleApi.ts @@ -33,6 +33,7 @@ export const shoppingCartApi = ( eventStore: EventStore, getUnitPrice: (_productId: string) => Promise, + getCurrentTime: () => Date, ): WebApiSetup => (router: Router) => { // #region complete-api @@ -55,6 +56,7 @@ export const shoppingCartApi = unitPrice: await getUnitPrice(productId), }, }, + metadata: { now: getCurrentTime() }, }; await handle(eventStore, shoppingCartId, (state) => @@ -83,6 +85,7 @@ export const shoppingCartApi = unitPrice: assertPositiveNumber(Number(request.query.unitPrice)), }, }, + metadata: { now: getCurrentTime() }, }; await handle(eventStore, shoppingCartId, (state) => @@ -104,6 +107,7 @@ export const shoppingCartApi = const command: ConfirmShoppingCart = { type: 'ConfirmShoppingCart', data: { shoppingCartId }, + metadata: { now: getCurrentTime() }, }; await handle(eventStore, shoppingCartId, (state) => @@ -125,6 +129,7 @@ export const shoppingCartApi = const command: CancelShoppingCart = { type: 'CancelShoppingCart', data: { shoppingCartId }, + metadata: { now: getCurrentTime() }, }; await handle(eventStore, shoppingCartId, (state) => diff --git a/docs/snippets/gettingStarted/webApi/start.ts b/docs/snippets/gettingStarted/webApi/start.ts index cdb767de..28b12072 100644 --- a/docs/snippets/gettingStarted/webApi/start.ts +++ b/docs/snippets/gettingStarted/webApi/start.ts @@ -13,7 +13,11 @@ import type { Server } from 'http'; const eventStore = getInMemoryEventStore(); -const shoppingCarts = shoppingCartApi(eventStore, getUnitPrice); +const shoppingCarts = shoppingCartApi( + eventStore, + getUnitPrice, + () => new Date(), +); const application: Application = getApplication({ apis: [shoppingCarts], diff --git a/packages/emmett-expressjs/src/index.ts b/packages/emmett-expressjs/src/index.ts index 04827868..50f5a2d4 100644 --- a/packages/emmett-expressjs/src/index.ts +++ b/packages/emmett-expressjs/src/index.ts @@ -12,6 +12,7 @@ import { problemDetailsMiddleware } from './middlewares/problemDetailsMiddleware export * from './etag'; export * from './handler'; +export * from './testing'; export type ErrorToProblemDetailsMapping = ( error: Error, diff --git a/packages/emmett-expressjs/src/testing/apiSpecification.ts b/packages/emmett-expressjs/src/testing/apiSpecification.ts new file mode 100644 index 00000000..5caf7961 --- /dev/null +++ b/packages/emmett-expressjs/src/testing/apiSpecification.ts @@ -0,0 +1,210 @@ +import { + assertMatches, + type AggregateStreamOptions, + type AggregateStreamResult, + type AppendToStreamOptions, + type AppendToStreamResult, + type DefaultStreamVersionType, + type Event, + type EventStore, + type ReadStreamOptions, + type ReadStreamResult, +} from '@event-driven-io/emmett'; +import { type Application } from 'express'; +import type { ProblemDocument } from 'http-problem-details'; +import assert from 'node:assert/strict'; +import type { Response, Test } from 'supertest'; +import supertest from 'supertest'; +import type TestAgent from 'supertest/lib/agent'; + +//////////////////////////////// +/////////// Setup +//////////////////////////////// + +export type TestEventStream = [ + string, + EventType[], +]; + +export const existingStream = ( + streamId: string, + events: EventType[], +): TestEventStream => { + return [streamId, events]; +}; + +//////////////////////////////// +/////////// Asserts +//////////////////////////////// + +export type ResponseAssert = (response: Response) => boolean | void; + +export type ApiSpecificationAssert = + | TestEventStream[] + | ResponseAssert + | [ResponseAssert, ...TestEventStream[]]; + +export const expect = ( + streamId: string, + events: EventType[], +): TestEventStream => { + return [streamId, events]; +}; + +export const expectNewEvents = ( + streamId: string, + events: EventType[], +): TestEventStream => { + return [streamId, events]; +}; + +export const expectResponse = + ( + statusCode: number, + options?: { body?: unknown; headers?: { [index: string]: string } }, + ) => + (response: Response): void => { + const { body, headers } = options ?? {}; + assert.equal(response.statusCode, statusCode); + if (body) assertMatches(response.body, body); + if (headers) assertMatches(response.headers, headers); + }; + +export const expectError = ( + errorCode: number, + problemDetails?: ProblemDocument, +) => + expectResponse( + errorCode, + problemDetails ? { body: problemDetails } : undefined, + ); + +//////////////////////////////// +/////////// Api Specification +//////////////////////////////// + +export type ApiSpecification = ( + ...givenStreams: TestEventStream[] +) => { + when: (setupRequest: (request: TestAgent) => Test) => { + then: (verify: ApiSpecificationAssert) => Promise; + }; +}; + +export const ApiSpecification = { + for: < + EventType extends Event = Event, + StreamVersion = DefaultStreamVersionType, + >( + getEventStore: () => EventStore, + getApplication: (eventStore: EventStore) => Application, + ): ApiSpecification => { + { + return (...givenStreams: TestEventStream[]) => { + const eventStore = WrapEventStore(getEventStore()); + const application = getApplication(eventStore); + + return { + when: ( + setupRequest: (request: TestAgent) => Test, + ) => { + const handle = async () => { + for (const [streamName, events] of givenStreams) { + await eventStore.setup(streamName, events); + } + + return setupRequest(supertest(application)); + }; + + return { + then: async ( + verify: ApiSpecificationAssert, + ): Promise => { + const response = await handle(); + + if (typeof verify === 'function') { + const succeded = verify(response); + + if (succeded === false) assert.fail(); + } else if (Array.isArray(verify)) { + const [first, ...rest] = verify; + + if (typeof first === 'function') { + const succeded = first(response); + + if (succeded === false) assert.fail(); + } + + const events = typeof first === 'function' ? rest : verify; + + assertMatches( + Array.from(eventStore.appendedEvents.values()), + events, + ); + } + }, + }; + }, + }; + }; + } + }, +}; + +const WrapEventStore = ( + eventStore: EventStore, +): EventStore & { + appendedEvents: Map; + setup( + streamName: string, + events: EventType[], + ): Promise>; +} => { + const appendedEvents = new Map(); + + return { + async aggregateStream( + streamName: string, + options: AggregateStreamOptions, + ): Promise | null> { + return eventStore.aggregateStream(streamName, options); + }, + + readStream( + streamName: string, + options?: ReadStreamOptions, + ): Promise> { + return eventStore.readStream(streamName, options); + }, + + appendToStream: async ( + streamName: string, + events: EventType[], + options?: AppendToStreamOptions, + ): Promise> => { + const result = await eventStore.appendToStream( + streamName, + events, + options, + ); + + const currentStream = appendedEvents.get(streamName) ?? [streamName, []]; + + appendedEvents.set(streamName, [ + streamName, + [...currentStream[1], ...events], + ]); + + return result; + }, + + appendedEvents, + + setup: async ( + streamName: string, + events: EventType[], + ): Promise> => { + return eventStore.appendToStream(streamName, events); + }, + }; +}; diff --git a/packages/emmett-expressjs/src/testing/index.ts b/packages/emmett-expressjs/src/testing/index.ts new file mode 100644 index 00000000..03ec44b8 --- /dev/null +++ b/packages/emmett-expressjs/src/testing/index.ts @@ -0,0 +1 @@ +export * from './apiSpecification'; diff --git a/packages/emmett/src/eventStore/eventStore.ts b/packages/emmett/src/eventStore/eventStore.ts index db586211..eb88d100 100644 --- a/packages/emmett/src/eventStore/eventStore.ts +++ b/packages/emmett/src/eventStore/eventStore.ts @@ -14,7 +14,7 @@ export interface EventStore { ): Promise>; appendToStream( - streamId: string, + streamName: string, events: EventType[], options?: AppendToStreamOptions, ): Promise>; diff --git a/packages/emmett/src/testing/assertions.ts b/packages/emmett/src/testing/assertions.ts index cc00db7e..5cef8024 100644 --- a/packages/emmett/src/testing/assertions.ts +++ b/packages/emmett/src/testing/assertions.ts @@ -13,9 +13,9 @@ export const isSubset = (superObj: unknown, subObj: unknown): boolean => { }); }; -export const assertMatches = (superObj: unknown, subObj: unknown) => { - if (!isSubset(superObj, subObj)) +export const assertMatches = (actual: unknown, expected: unknown) => { + if (!isSubset(actual, expected)) throw Error( - `subObj:\n${JSON.stringify(subObj)}\nis not subset of\n${JSON.stringify(superObj)}`, + `subObj:\n${JSON.stringify(expected)}\nis not subset of\n${JSON.stringify(actual)}`, ); };