From 3be2d73c601c1f00efa57a0109a5eb15680713d8 Mon Sep 17 00:00:00 2001 From: Romain Thouvenin Date: Tue, 7 May 2024 10:13:28 +0200 Subject: [PATCH] Implement the UpdateAdCommand --- package.json | 2 +- src/app.constants.ts | 2 + src/modules/ad/ad.mapper.ts | 52 ++++++---- src/modules/ad/ad.module.ts | 7 +- .../commands/create-ad/create-ad.service.ts | 3 +- .../commands/update-ad/update-ad.command.ts | 3 + .../commands/update-ad/update-ad.service.ts | 48 ++++++++++ ...er-ad-creation-failed.integration-event.ts | 12 --- .../matcher-ad-failure.integration-event.ts | 15 +++ .../ad/infrastructure/ad.repository.ts | 47 +++++++--- .../tests/unit/core/update-ad.service.spec.ts | 94 +++++++++++++++++++ 11 files changed, 237 insertions(+), 48 deletions(-) create mode 100644 src/modules/ad/core/application/commands/update-ad/update-ad.command.ts create mode 100644 src/modules/ad/core/application/commands/update-ad/update-ad.service.ts delete mode 100644 src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts create mode 100644 src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts create mode 100644 src/modules/ad/tests/unit/core/update-ad.service.spec.ts diff --git a/package.json b/package.json index 6344905..ac22430 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", "test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand", "test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand", - "test:cov": "jest --testPathPattern 'tests/unit/' --coverage", + "test:watch": "jest --testPathPattern 'tests/unit/' --watch", "test:e2e": "jest --config ./test/jest-e2e.json", "migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate deploy'", "migrate:dev": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'", diff --git a/src/app.constants.ts b/src/app.constants.ts index c13df6c..4980881 100644 --- a/src/app.constants.ts +++ b/src/app.constants.ts @@ -10,6 +10,8 @@ export const GRPC_GEOROUTER_SERVICE_NAME = 'GeorouterService'; export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created'; export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY = 'matcher-ad.creation-failed'; +export const MATCHER_AD_UPDATED_ROUTING_KEY = 'matcher-ad.updated'; +export const MATCHER_AD_UPDATE_FAILED_ROUTING_KEY = 'matcher-ad.update-failed'; // messaging input export const AD_CREATED_MESSAGE_HANDLER = 'adCreated'; diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 929f72e..373ec5d 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -13,6 +13,7 @@ import { AdWriteExtraModel, AdWriteModel, ScheduleItemModel, + ScheduleWriteModel, } from './infrastructure/ad.repository'; /** @@ -38,9 +39,8 @@ export class AdMapper private readonly directionEncoder: DirectionEncoderPort, ) {} - toPersistence = (entity: AdEntity): AdWriteModel => { + toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => { const copy = entity.getProps(); - const now = new Date(); const record: AdWriteModel = { uuid: copy.id, driver: copy.driver, @@ -48,22 +48,7 @@ export class AdMapper frequency: copy.frequency, fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), - schedule: { - create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({ - uuid: v4(), - day: scheduleItem.day, - time: new Date( - 1970, - 0, - 1, - parseInt(scheduleItem.time.split(':')[0]), - parseInt(scheduleItem.time.split(':')[1]), - ), - margin: scheduleItem.margin, - createdAt: now, - updatedAt: now, - })), - }, + schedule: this.toScheduleItemWriteModel(copy.schedule, update), seatsProposed: copy.seatsProposed, seatsRequested: copy.seatsRequested, strict: copy.strict, @@ -73,12 +58,39 @@ export class AdMapper passengerDistance: copy.passengerDistance, fwdAzimuth: copy.fwdAzimuth, backAzimuth: copy.backAzimuth, - createdAt: copy.createdAt, - updatedAt: copy.updatedAt, }; return record; }; + toScheduleItemWriteModel = ( + schedule: ScheduleItemProps[], + update?: boolean, + ): ScheduleWriteModel => { + const now = new Date(); + const record: ScheduleWriteModel = { + create: schedule.map((scheduleItem: ScheduleItemProps) => ({ + uuid: v4(), + day: scheduleItem.day, + time: new Date( + 1970, + 0, + 1, + parseInt(scheduleItem.time.split(':')[0]), + parseInt(scheduleItem.time.split(':')[1]), + ), + margin: scheduleItem.margin, + createdAt: now, + updatedAt: now, + })), + }; + if (update) { + record.deleteMany = { + createdAt: { lt: now }, + }; + } + return record; + }; + toDomain = (record: AdReadModel): AdEntity => new AdEntity({ id: record.uuid, diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 9040de9..adb71c4 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -31,6 +31,7 @@ import { import { AdMapper } from './ad.mapper'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service'; +import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service'; import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler'; import { MatchQueryHandler } from './core/application/queries/match/match.query-handler'; import { AdRepository } from './infrastructure/ad.repository'; @@ -104,7 +105,11 @@ const eventHandlers: Provider[] = [ PublishMessageWhenMatcherAdIsCreatedDomainEventHandler, ]; -const commandHandlers: Provider[] = [CreateAdService, DeleteAdService]; +const commandHandlers: Provider[] = [ + CreateAdService, + UpdateAdService, + DeleteAdService, +]; const queryHandlers: Provider[] = [MatchQueryHandler]; diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts index 1f5b78f..e3d92c5 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts @@ -14,7 +14,7 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants'; import { GeorouterService } from '../../../domain/georouter.service'; -import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event'; +import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event'; import { AdRepositoryPort } from '../../ports/ad.repository.port'; import { CreateAdCommand } from './create-ad.command'; @@ -35,6 +35,7 @@ export class CreateAdService implements ICommandHandler { const ad = await adFactory.create(command); try { + //TODO it should not be this service's concern that Prisma does not support postgis types await this.repository.insertExtra(ad, 'ad'); return ad.id; } catch (error: any) { diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts new file mode 100644 index 0000000..898c968 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts @@ -0,0 +1,3 @@ +import { CreateAdCommand } from '../create-ad/create-ad.command'; + +export class UpdateAdCommand extends CreateAdCommand {} diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts new file mode 100644 index 0000000..99aafb9 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts @@ -0,0 +1,48 @@ +import { MessagePublisherPort } from '@mobicoop/ddd-library'; +import { + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, + AD_ROUTE_PROVIDER, +} from '@modules/ad/ad.di-tokens'; +import { AdFactory } from '@modules/ad/core/domain/ad.factory'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { MATCHER_AD_UPDATE_FAILED_ROUTING_KEY } from '@src/app.constants'; +import { GeorouterService } from '../../../domain/georouter.service'; +import { MatcherAdUpdateFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { UpdateAdCommand } from './update-ad.command'; + +@CommandHandler(UpdateAdCommand) +export class UpdateAdService implements ICommandHandler { + constructor( + @Inject(AD_MESSAGE_PUBLISHER) + private readonly messagePublisher: MessagePublisherPort, + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + @Inject(AD_ROUTE_PROVIDER) + private readonly routeProvider: GeorouterService, + ) {} + + async execute(command: UpdateAdCommand): Promise { + try { + const adFactory = new AdFactory(this.routeProvider); + const ad = await adFactory.create(command); + return this.repository.update(ad.id, ad); + } catch (error: any) { + const integrationEvent = new MatcherAdUpdateFailedIntegrationEvent({ + id: command.id, + metadata: { + correlationId: command.id, + timestamp: Date.now(), + }, + cause: error.message, + }); + this.messagePublisher.publish( + MATCHER_AD_UPDATE_FAILED_ROUTING_KEY, + JSON.stringify(integrationEvent), + ); + throw error; + } + } +} diff --git a/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts b/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts deleted file mode 100644 index c932c6f..0000000 --- a/src/modules/ad/core/application/events/matcher-ad-creation-failed.integration-event.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library'; - -export class MatcherAdCreationFailedIntegrationEvent extends IntegrationEvent { - readonly cause?: string; - - constructor( - props: IntegrationEventProps, - ) { - super(props); - this.cause = props.cause; - } -} diff --git a/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts b/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts new file mode 100644 index 0000000..ff177c3 --- /dev/null +++ b/src/modules/ad/core/application/events/matcher-ad-failure.integration-event.ts @@ -0,0 +1,15 @@ +import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library'; + +export class MatcherAdFailureIntegrationEvent extends IntegrationEvent { + readonly cause?: string; + + constructor( + props: IntegrationEventProps, + ) { + super(props); + this.cause = props.cause; + } +} + +export class MatcherAdCreationFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {} +export class MatcherAdUpdateFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {} diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index eda2c3c..42c2957 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -1,14 +1,14 @@ +import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library'; +import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; -import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library'; -import { PrismaService } from './prisma.service'; -import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; -import { AdEntity } from '../core/domain/ad.entity'; -import { AdMapper } from '../ad.mapper'; -import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base'; -import { Frequency } from '../core/domain/ad.types'; import { SERVICE_NAME } from '@src/app.constants'; +import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; +import { AdMapper } from '../ad.mapper'; +import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; +import { AdEntity } from '../core/domain/ad.entity'; +import { Frequency } from '../core/domain/ad.types'; +import { PrismaService } from './prisma.service'; export type AdModel = { uuid: string; @@ -26,8 +26,6 @@ export type AdModel = { passengerDistance?: number; fwdAzimuth: number; backAzimuth: number; - createdAt: Date; - updatedAt: Date; }; /** @@ -36,15 +34,26 @@ export type AdModel = { export type AdReadModel = AdModel & { waypoints: string; schedule: ScheduleItemModel[]; + createdAt: Date; + updatedAt: Date; }; /** * The record ready to be sent to the persistence system */ export type AdWriteModel = AdModel & { - schedule: { - create: ScheduleItemModel[]; - }; + schedule: ScheduleWriteModel; +}; + +export type ScheduleWriteModel = { + deleteMany?: PastCreatedFilter; + create: ScheduleItemModel[]; +}; + +// used to delete records created in the past, +// because the order of `create` and `deleteMany` is not guaranteed +export type PastCreatedFilter = { + createdAt: { lt: Date }; }; export type AdWriteExtraModel = { @@ -70,11 +79,15 @@ export type UngroupedAdModel = AdModel & scheduleItemCreatedAt: Date; scheduleItemUpdatedAt: Date; waypoints: string; + createdAt: Date; + updatedAt: Date; }; export type GroupedAdModel = AdModel & { schedule: ScheduleItemModel[]; waypoints: string; + createdAt: Date; + updatedAt: Date; }; /** @@ -169,4 +182,12 @@ export class AdRepository }); return adReadModels; }; + + async update( + id: string, + entity: AdEntity, + identifier?: string, + ): Promise { + this.updateExtra(id, entity, 'ad', identifier); + } } diff --git a/src/modules/ad/tests/unit/core/update-ad.service.spec.ts b/src/modules/ad/tests/unit/core/update-ad.service.spec.ts new file mode 100644 index 0000000..c5f347a --- /dev/null +++ b/src/modules/ad/tests/unit/core/update-ad.service.spec.ts @@ -0,0 +1,94 @@ +import { + AD_MESSAGE_PUBLISHER, + AD_REPOSITORY, + AD_ROUTE_PROVIDER, +} from '@modules/ad/ad.di-tokens'; +import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command'; +import { UpdateAdService } from '@modules/ad/core/application/commands/update-ad/update-ad.service'; +import { GeorouterService } from '@modules/ad/core/domain/georouter.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createAdProps } from './ad.fixtures'; + +const mockAdRepository = { + update: jest.fn().mockImplementation((id) => { + if (id === '42') { + throw 'Bad id!'; + } + }), +}; + +const mockRouteProvider: GeorouterService = { + getRoute: jest.fn().mockImplementation(() => ({ + distance: 350101, + duration: 14422, + fwdAzimuth: 273, + backAzimuth: 93, + distanceAzimuth: 336544, + points: [ + { + lon: 6.1765102, + lat: 48.689445, + }, + { + lon: 4.984578, + lat: 48.725687, + }, + { + lon: 2.3522, + lat: 48.8566, + }, + ], + })), +}; + +const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), +}; + +describe('update-ad.service', () => { + let updateAdService: UpdateAdService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + { + provide: AD_ROUTE_PROVIDER, + useValue: mockRouteProvider, + }, + { + provide: AD_MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + UpdateAdService, + ], + }).compile(); + + updateAdService = module.get(UpdateAdService); + }); + + it('should be defined', () => { + expect(updateAdService).toBeDefined(); + }); + + describe('execute', () => { + it('should call the repository update method', async () => { + const updateAdCommand = new UpdateAdCommand(createAdProps()); + await updateAdService.execute(updateAdCommand); + expect(mockAdRepository.update).toHaveBeenCalled(); + }); + + it('should emit an event when an error occurs', async () => { + const commandProps = createAdProps(); + commandProps.id = '42'; + const updateAdCommand = new UpdateAdCommand(commandProps); + await expect(updateAdService.execute(updateAdCommand)).rejects.toBe( + 'Bad id!', + ); + expect(mockMessagePublisher.publish).toHaveBeenCalled(); + }); + }); +});