Implement the UpdateAdCommand

This commit is contained in:
Romain Thouvenin 2024-05-07 10:13:28 +02:00
parent 34ad357f47
commit 3be2d73c60
11 changed files with 237 additions and 48 deletions

View File

@ -22,7 +22,7 @@
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", "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": "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: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", "test:e2e": "jest --config ./test/jest-e2e.json",
"migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate deploy'", "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'", "migrate:dev": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'",

View File

@ -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_CREATED_ROUTING_KEY = 'matcher-ad.created';
export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY = export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
'matcher-ad.creation-failed'; '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 // messaging input
export const AD_CREATED_MESSAGE_HANDLER = 'adCreated'; export const AD_CREATED_MESSAGE_HANDLER = 'adCreated';

View File

@ -13,6 +13,7 @@ import {
AdWriteExtraModel, AdWriteExtraModel,
AdWriteModel, AdWriteModel,
ScheduleItemModel, ScheduleItemModel,
ScheduleWriteModel,
} from './infrastructure/ad.repository'; } from './infrastructure/ad.repository';
/** /**
@ -38,9 +39,8 @@ export class AdMapper
private readonly directionEncoder: DirectionEncoderPort, private readonly directionEncoder: DirectionEncoderPort,
) {} ) {}
toPersistence = (entity: AdEntity): AdWriteModel => { toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => {
const copy = entity.getProps(); const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = { const record: AdWriteModel = {
uuid: copy.id, uuid: copy.id,
driver: copy.driver, driver: copy.driver,
@ -48,22 +48,7 @@ export class AdMapper
frequency: copy.frequency, frequency: copy.frequency,
fromDate: new Date(copy.fromDate), fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate), toDate: new Date(copy.toDate),
schedule: { schedule: this.toScheduleItemWriteModel(copy.schedule, update),
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,
})),
},
seatsProposed: copy.seatsProposed, seatsProposed: copy.seatsProposed,
seatsRequested: copy.seatsRequested, seatsRequested: copy.seatsRequested,
strict: copy.strict, strict: copy.strict,
@ -73,12 +58,39 @@ export class AdMapper
passengerDistance: copy.passengerDistance, passengerDistance: copy.passengerDistance,
fwdAzimuth: copy.fwdAzimuth, fwdAzimuth: copy.fwdAzimuth,
backAzimuth: copy.backAzimuth, backAzimuth: copy.backAzimuth,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
}; };
return record; 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 => toDomain = (record: AdReadModel): AdEntity =>
new AdEntity({ new AdEntity({
id: record.uuid, id: record.uuid,

View File

@ -31,6 +31,7 @@ import {
import { AdMapper } from './ad.mapper'; import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { DeleteAdService } from './core/application/commands/delete-ad/delete-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 { 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 { MatchQueryHandler } from './core/application/queries/match/match.query-handler';
import { AdRepository } from './infrastructure/ad.repository'; import { AdRepository } from './infrastructure/ad.repository';
@ -104,7 +105,11 @@ const eventHandlers: Provider[] = [
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler, PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
]; ];
const commandHandlers: Provider[] = [CreateAdService, DeleteAdService]; const commandHandlers: Provider[] = [
CreateAdService,
UpdateAdService,
DeleteAdService,
];
const queryHandlers: Provider[] = [MatchQueryHandler]; const queryHandlers: Provider[] = [MatchQueryHandler];

View File

@ -14,7 +14,7 @@ import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants'; import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants';
import { GeorouterService } from '../../../domain/georouter.service'; 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 { AdRepositoryPort } from '../../ports/ad.repository.port';
import { CreateAdCommand } from './create-ad.command'; import { CreateAdCommand } from './create-ad.command';
@ -35,6 +35,7 @@ export class CreateAdService implements ICommandHandler {
const ad = await adFactory.create(command); const ad = await adFactory.create(command);
try { try {
//TODO it should not be this service's concern that Prisma does not support postgis types
await this.repository.insertExtra(ad, 'ad'); await this.repository.insertExtra(ad, 'ad');
return ad.id; return ad.id;
} catch (error: any) { } catch (error: any) {

View File

@ -0,0 +1,3 @@
import { CreateAdCommand } from '../create-ad/create-ad.command';
export class UpdateAdCommand extends CreateAdCommand {}

View File

@ -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<void> {
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;
}
}
}

View File

@ -1,12 +0,0 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class MatcherAdCreationFailedIntegrationEvent extends IntegrationEvent {
readonly cause?: string;
constructor(
props: IntegrationEventProps<MatcherAdCreationFailedIntegrationEvent>,
) {
super(props);
this.cause = props.cause;
}
}

View File

@ -0,0 +1,15 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class MatcherAdFailureIntegrationEvent extends IntegrationEvent {
readonly cause?: string;
constructor(
props: IntegrationEventProps<MatcherAdCreationFailedIntegrationEvent>,
) {
super(props);
this.cause = props.cause;
}
}
export class MatcherAdCreationFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {}
export class MatcherAdUpdateFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {}

View File

@ -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 { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { 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 = { export type AdModel = {
uuid: string; uuid: string;
@ -26,8 +26,6 @@ export type AdModel = {
passengerDistance?: number; passengerDistance?: number;
fwdAzimuth: number; fwdAzimuth: number;
backAzimuth: number; backAzimuth: number;
createdAt: Date;
updatedAt: Date;
}; };
/** /**
@ -36,15 +34,26 @@ export type AdModel = {
export type AdReadModel = AdModel & { export type AdReadModel = AdModel & {
waypoints: string; waypoints: string;
schedule: ScheduleItemModel[]; schedule: ScheduleItemModel[];
createdAt: Date;
updatedAt: Date;
}; };
/** /**
* The record ready to be sent to the persistence system * The record ready to be sent to the persistence system
*/ */
export type AdWriteModel = AdModel & { export type AdWriteModel = AdModel & {
schedule: { schedule: ScheduleWriteModel;
create: ScheduleItemModel[]; };
};
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 = { export type AdWriteExtraModel = {
@ -70,11 +79,15 @@ export type UngroupedAdModel = AdModel &
scheduleItemCreatedAt: Date; scheduleItemCreatedAt: Date;
scheduleItemUpdatedAt: Date; scheduleItemUpdatedAt: Date;
waypoints: string; waypoints: string;
createdAt: Date;
updatedAt: Date;
}; };
export type GroupedAdModel = AdModel & { export type GroupedAdModel = AdModel & {
schedule: ScheduleItemModel[]; schedule: ScheduleItemModel[];
waypoints: string; waypoints: string;
createdAt: Date;
updatedAt: Date;
}; };
/** /**
@ -169,4 +182,12 @@ export class AdRepository
}); });
return adReadModels; return adReadModels;
}; };
async update(
id: string,
entity: AdEntity,
identifier?: string,
): Promise<void> {
this.updateExtra(id, entity, 'ad', identifier);
}
} }

View File

@ -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>(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();
});
});
});