diff --git a/README.md b/README.md index 079a439..b360896 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,9 @@ The app exposes the following [gRPC](https://grpc.io/) services : - waypoints: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives - comment: optional freetext comment / description about the ad +- **Update** : Replace the content of an ad + Accepts the same data as the `Create` function + an ad id, and replace the given ad with the given data. + - **Delete** : Delete permanently an ad ```json @@ -246,13 +249,16 @@ The app exposes the following [gRPC](https://grpc.io/) services : The service listens to these RabbitMQ messages: +- **matcher-ad.created** (to update the status of pending ads) +- **matcher-ad.creation-failed** (to update the status of pending ads) - **user.deleted** (to delete the associated ads) ### Emitted As mentionned earlier, RabbitMQ messages are sent after these events : -- **ad.created** (message: the created ad informations) +- **ad.created** (message: the created ad information) +- **ad.updated** (message: the updated ad information) - **ad.deleted** (message: the id of the deleted ad) ## Tests / ESLint / Prettier diff --git a/package-lock.json b/package-lock.json index 1ae01ed..6c60d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@mobicoop/ad", - "version": "2.4.5", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mobicoop/ad", - "version": "2.4.5", + "version": "2.6.0", "license": "AGPL", "dependencies": { "@grpc/grpc-js": "^1.9.14", "@grpc/proto-loader": "^0.7.10", - "@mobicoop/ddd-library": "^2.4.3", + "@mobicoop/ddd-library": "^2.5.0", "@mobicoop/health-module": "^2.3.2", "@mobicoop/message-broker-module": "^2.1.2", "@nestjs/common": "^10.3.0", @@ -1852,9 +1852,9 @@ } }, "node_modules/@mobicoop/ddd-library": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.4.3.tgz", - "integrity": "sha512-HxNtAfov8ne7XsFTSIDI811r3L1VDV9YUikgX7HPjrB8u2gQh6FQFnIz3Fjb/zWOGxrDEIy8HEM0AYmXkf8ULA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.5.0.tgz", + "integrity": "sha512-dTx7KTILs53HCqNx0BDVTzIZxfPW3pi0fZ4UMw/vDNm3oTqGA+jg7YBfNxn8yadM+j2dDIN5Kum43CmKGH8yYA==", "dependencies": { "@nestjs/event-emitter": "^2.0.3", "@nestjs/microservices": "^10.3.0", diff --git a/package.json b/package.json index 594c006..3e5a6df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mobicoop/ad", - "version": "2.5.0", + "version": "2.6.0", "description": "Mobicoop V3 Ad", "author": "sbriat", "private": true, @@ -34,7 +34,7 @@ "@grpc/grpc-js": "^1.9.14", "@grpc/proto-loader": "^0.7.10", "@songkeys/nestjs-redis": "^10.0.0", - "@mobicoop/ddd-library": "^2.4.3", + "@mobicoop/ddd-library": "^2.5.0", "@mobicoop/health-module": "^2.3.2", "@mobicoop/message-broker-module": "^2.1.2", "@nestjs/common": "^10.3.0", diff --git a/src/app.constants.ts b/src/app.constants.ts index 1f72c93..7f2ec77 100644 --- a/src/app.constants.ts +++ b/src/app.constants.ts @@ -7,7 +7,7 @@ export const GRPC_SERVICE_NAME = 'AdService'; // messaging output export const AD_CREATED_ROUTING_KEY = 'ad.created'; -// messaging output +export const AD_UPDATED_ROUTING_KEY = 'ad.updated'; export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; // messaging input diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index eb3bf11..839ced6 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -1,19 +1,21 @@ import { Mapper } from '@mobicoop/ddd-library'; -import { AdResponseDto } from './interface/dtos/ad.response.dto'; import { Inject, Injectable } from '@nestjs/common'; -import { AdEntity } from './core/domain/ad.entity'; -import { - AdWriteModel, - AdReadModel, - WaypointModel, - ScheduleItemModel, -} from './infrastructure/ad.repository'; -import { Frequency, Status } from './core/domain/ad.types'; -import { WaypointProps } from './core/domain/value-objects/waypoint.value-object'; import { v4 } from 'uuid'; -import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object'; import { OUTPUT_DATETIME_TRANSFORMER } from './ad.di-tokens'; import { DateTimeTransformerPort } from './core/application/ports/datetime-transformer.port'; +import { AdEntity } from './core/domain/ad.entity'; +import { Frequency, Status } from './core/domain/ad.types'; +import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object'; +import { WaypointProps } from './core/domain/value-objects/waypoint.value-object'; +import { + AdReadModel, + AdWriteModel, + ScheduleItemModel, + ScheduleWriteModel, + WaypointModel, + WaypointWriteModel, +} from './infrastructure/ad.repository'; +import { AdResponseDto } from './interface/dtos/ad.response.dto'; /** * Mapper constructs objects that are used in different layers: @@ -31,9 +33,8 @@ export class AdMapper private readonly outputDatetimeTransformer: DateTimeTransformerPort, ) {} - toPersistence = (entity: AdEntity): AdWriteModel => { + toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => { const copy = entity.getProps(); - const now = new Date(); const record: AdWriteModel = { uuid: copy.id, userUuid: copy.userId, @@ -43,50 +44,80 @@ export class AdMapper frequency: copy.frequency, fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), - schedule: copy.schedule - ? { - create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({ - uuid: v4(), - day: scheduleItem.day as number, - time: new Date( - 1970, - 0, - 1, - parseInt(scheduleItem.time.split(':')[0]), - parseInt(scheduleItem.time.split(':')[1]), - ), - margin: scheduleItem.margin as number, - createdAt: now, - updatedAt: now, - })), - } - : undefined, + schedule: this.toScheduleItemWriteModel(copy.schedule, update), seatsProposed: copy.seatsProposed as number, seatsRequested: copy.seatsRequested as number, strict: copy.strict as boolean, - waypoints: copy.waypoints - ? { - create: copy.waypoints.map((waypoint: WaypointProps) => ({ - uuid: v4(), - position: waypoint.position, - name: waypoint.address.name, - houseNumber: waypoint.address.houseNumber, - street: waypoint.address.street, - locality: waypoint.address.locality, - postalCode: waypoint.address.postalCode, - country: waypoint.address.country, - lon: waypoint.address.coordinates.lon, - lat: waypoint.address.coordinates.lat, - createdAt: now, - updatedAt: now, - })), - } - : undefined, + waypoints: this.toWaypointWriteModel(copy.waypoints, update), comment: copy.comment, }; return record; }; + toScheduleItemWriteModel = ( + schedule: ScheduleItemProps[], + update?: boolean, + ): ScheduleWriteModel | undefined => { + if (!schedule) { + return undefined; + } + 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; + }; + + toWaypointWriteModel = ( + waypoints: WaypointProps[], + update?: boolean, + ): WaypointWriteModel | undefined => { + if (!waypoints) { + return undefined; + } + const now = new Date(); + const record: WaypointWriteModel = { + create: waypoints.map((waypoint: WaypointProps) => ({ + uuid: v4(), + position: waypoint.position, + name: waypoint.address.name, + houseNumber: waypoint.address.houseNumber, + street: waypoint.address.street, + locality: waypoint.address.locality, + postalCode: waypoint.address.postalCode, + country: waypoint.address.country, + lon: waypoint.address.coordinates.lon, + lat: waypoint.address.coordinates.lat, + createdAt: now, + updatedAt: now, + })), + }; + if (update) { + record.deleteMany = { + createdAt: { lt: now }, + }; + } + return record; + }; + toDomain = (record: AdReadModel): AdEntity => { const entity = new AdEntity({ id: record.uuid, @@ -141,6 +172,7 @@ export class AdMapper response.userId = props.userId; response.driver = props.driver as boolean; response.passenger = props.passenger as boolean; + response.strict = props.strict; response.status = props.status; response.frequency = props.frequency; response.fromDate = this.outputDatetimeTransformer.fromDate( @@ -163,7 +195,7 @@ export class AdMapper response.schedule = props.schedule.map( (scheduleItem: ScheduleItemProps) => ({ day: this.outputDatetimeTransformer.day( - scheduleItem.day as number, + scheduleItem.day, { date: props.fromDate, time: scheduleItem.time, @@ -179,7 +211,7 @@ export class AdMapper }, props.frequency, ), - margin: scheduleItem.margin as number, + margin: scheduleItem.margin, }), ); response.seatsProposed = props.seatsProposed as number; diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 3d5dfe1..286670a 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -14,9 +14,11 @@ import { CreateAdService } from './core/application/commands/create-ad/create-ad import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service'; import { DeleteUserAdsService } from './core/application/commands/delete-user-ads/delete-user-ads.service'; import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-ad.service'; +import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service'; import { ValidateAdService } from './core/application/commands/validate-ad/validate-ad.service'; import { PublishMessageWhenAdIsDeletedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler'; import { PublishMessageWhenAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-created.domain-event-handler'; +import { PublishMessageWhenAdIsUpdatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler'; import { FindAdByIdQueryHandler } from './core/application/queries/find-ad-by-id/find-ad-by-id.query-handler'; import { FindAdsByIdsQueryHandler } from './core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler'; import { FindAdsByUserIdQueryHandler } from './core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler'; @@ -31,12 +33,14 @@ import { DeleteAdGrpcController } from './interface/grpc-controllers/delete-ad.g import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller'; import { FindAdsByIdsGrpcController } from './interface/grpc-controllers/find-ads-by-ids.grpc.controller'; import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller'; +import { UpdateAdGrpcController } from './interface/grpc-controllers/update-ad.grpc.controller'; import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler'; import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler'; import { UserDeletedMessageHandler } from './interface/message-handlers/user-deleted.message-handler'; const grpcControllers = [ CreateAdGrpcController, + UpdateAdGrpcController, DeleteAdGrpcController, FindAdByIdGrpcController, FindAdsByIdsGrpcController, @@ -51,11 +55,13 @@ const messageHandlers = [ const eventHandlers: Provider[] = [ PublishMessageWhenAdIsCreatedDomainEventHandler, + PublishMessageWhenAdIsUpdatedDomainEventHandler, PublishMessageWhenAdIsDeletedDomainEventHandler, ]; const commandHandlers: Provider[] = [ CreateAdService, + UpdateAdService, DeleteAdService, DeleteUserAdsService, ValidateAdService, diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts index 7a939c3..fefe004 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts @@ -1,7 +1,7 @@ -import { ScheduleItem } from '../../types/schedule-item'; -import { Waypoint } from '../../types/waypoint'; -import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Command, CommandProps } from '@mobicoop/ddd-library'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; +import { Waypoint } from '../../types/waypoint'; export class CreateAdCommand extends Command { readonly userId: string; @@ -10,7 +10,7 @@ export class CreateAdCommand extends Command { readonly frequency: Frequency; readonly fromDate: string; readonly toDate: string; - readonly schedule: ScheduleItem[]; + readonly schedule: ScheduleItemProps[]; readonly seatsProposed?: number; readonly seatsRequested?: number; readonly strict: boolean; 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 5d1d30c..98a95dc 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 @@ -1,17 +1,98 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { CreateAdCommand } from './create-ad.command'; -import { Inject } from '@nestjs/common'; +import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import { AD_REPOSITORY, INPUT_DATETIME_TRANSFORMER, } from '@modules/ad/ad.di-tokens'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { Waypoint } from '../../types/waypoint'; -import { AdRepositoryPort } from '../../ports/ad.repository.port'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; -import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; -import { ScheduleItem } from '../../types/schedule-item'; +import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; +import { Waypoint } from '../../types/waypoint'; +import { CreateAdCommand } from './create-ad.command'; + +export function createPropsFromCommand( + command: CreateAdCommand, + datetimeTransformer: DateTimeTransformerPort, +) { + return { + userId: command.userId, + driver: command.driver, + passenger: command.passenger, + frequency: command.frequency, + //TODO Shouldn't that kind of logic be in the domain layer? + fromDate: datetimeTransformer.fromDate( + { + date: command.fromDate, + time: command.schedule[0].time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + toDate: datetimeTransformer.toDate( + command.toDate, + { + date: command.fromDate, + time: command.schedule[0].time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + schedule: command.schedule.map((scheduleItem: ScheduleItemProps) => ({ + day: datetimeTransformer.day( + scheduleItem.day, + { + date: command.fromDate, + time: scheduleItem.time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + time: datetimeTransformer.time( + { + date: command.fromDate, + time: scheduleItem.time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + margin: scheduleItem.margin, + })), + seatsProposed: command.seatsProposed ?? 0, + seatsRequested: command.seatsRequested ?? 0, + strict: command.strict, + waypoints: command.waypoints.map((waypoint: Waypoint) => ({ + position: waypoint.position, + address: { + name: waypoint.name, + houseNumber: waypoint.houseNumber, + street: waypoint.street, + postalCode: waypoint.postalCode, + locality: waypoint.locality, + country: waypoint.country, + coordinates: { + lon: waypoint.lon, + lat: waypoint.lat, + }, + }, + })), + comment: command.comment, + }; +} @CommandHandler(CreateAdCommand) export class CreateAdService implements ICommandHandler { @@ -23,80 +104,9 @@ export class CreateAdService implements ICommandHandler { ) {} async execute(command: CreateAdCommand): Promise { - const ad = AdEntity.create({ - userId: command.userId, - driver: command.driver, - passenger: command.passenger, - frequency: command.frequency, - fromDate: this.datetimeTransformer.fromDate( - { - date: command.fromDate, - time: command.schedule[0].time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - toDate: this.datetimeTransformer.toDate( - command.toDate, - { - date: command.fromDate, - time: command.schedule[0].time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - schedule: command.schedule.map((scheduleItem: ScheduleItem) => ({ - day: this.datetimeTransformer.day( - scheduleItem.day, - { - date: command.fromDate, - time: scheduleItem.time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - time: this.datetimeTransformer.time( - { - date: command.fromDate, - time: scheduleItem.time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - margin: scheduleItem.margin, - })), - seatsProposed: command.seatsProposed ?? 0, - seatsRequested: command.seatsRequested ?? 0, - strict: command.strict, - waypoints: command.waypoints.map((waypoint: Waypoint) => ({ - position: waypoint.position, - address: { - name: waypoint.name, - houseNumber: waypoint.houseNumber, - street: waypoint.street, - postalCode: waypoint.postalCode, - locality: waypoint.locality, - country: waypoint.country, - coordinates: { - lon: waypoint.lon, - lat: waypoint.lat, - }, - }, - })), - comment: command.comment, - }); + const ad = AdEntity.create( + createPropsFromCommand(command, this.datetimeTransformer), + ); try { await this.repository.insert(ad); 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..db2dbc3 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts @@ -0,0 +1,16 @@ +import { CommandProps } from '@mobicoop/ddd-library'; +import { CreateAdCommand } from '../create-ad/create-ad.command'; + +/** + * Ad updates follow the PUT semantics: they replace the entire object. + * Therefore the update command extends the create command to inherit the same properties + * and re-use the data transformation logic. + */ +export class UpdateAdCommand extends CreateAdCommand { + public adId: string; + + constructor(props: CommandProps) { + super(props); + this.adId = props.adId; + } +} 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..cc5790a --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts @@ -0,0 +1,36 @@ +import { + AD_REPOSITORY, + INPUT_DATETIME_TRANSFORMER, +} from '@modules/ad/ad.di-tokens'; +import { AdUpdatedDomainEvent } from '@modules/ad/core/domain/events/ad.domain-event'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; +import { createPropsFromCommand } from '../create-ad/create-ad.service'; +import { UpdateAdCommand } from './update-ad.command'; + +@CommandHandler(UpdateAdCommand) +export class UpdateAdService implements ICommandHandler { + constructor( + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + @Inject(INPUT_DATETIME_TRANSFORMER) + private readonly datetimeTransformer: DateTimeTransformerPort, + private readonly eventEmitter: EventEmitter2, + ) {} + + async execute(command: UpdateAdCommand): Promise { + const ad = await this.repository.findOneById(command.adId, { + waypoints: true, + schedule: true, + }); + ad.update(createPropsFromCommand(command, this.datetimeTransformer)); + await this.repository.update(ad.id, ad); + this.eventEmitter.emitAsync( + AdUpdatedDomainEvent.name, + new AdUpdatedDomainEvent(ad), + ); + } +} diff --git a/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler.ts b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler.ts new file mode 100644 index 0000000..4715a14 --- /dev/null +++ b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler.ts @@ -0,0 +1,44 @@ +import { + IntegrationEvent, + IntegrationEventProps, + MessagePublisherPort, +} from '@mobicoop/ddd-library'; +import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens'; +import { AdMapper } from '@modules/ad/ad.mapper'; +import { AdResponseDto } from '@modules/ad/interface/dtos/ad.response.dto'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AD_UPDATED_ROUTING_KEY } from '@src/app.constants'; +import { v4 } from 'uuid'; +import { AdUpdatedDomainEvent } from '../../domain/events/ad.domain-event'; + +class AdIntegrationEvent extends IntegrationEvent { + readonly data: AdResponseDto; + + constructor(props: IntegrationEventProps, data: AdResponseDto) { + super(props); + this.data = data; + } +} + +@Injectable() +export class PublishMessageWhenAdIsUpdatedDomainEventHandler { + constructor( + @Inject(AD_MESSAGE_PUBLISHER) + private readonly messagePublisher: MessagePublisherPort, + private readonly mapper: AdMapper, + ) {} + + @OnEvent(AdUpdatedDomainEvent.name, { async: true, promisify: true }) + async handle(event: AdUpdatedDomainEvent): Promise { + this.messagePublisher.publish( + AD_UPDATED_ROUTING_KEY, + JSON.stringify( + new AdIntegrationEvent( + { id: v4(), metadata: event.metadata }, + this.mapper.toResponse(event.ad), + ), + ), + ); + } +} diff --git a/src/modules/ad/core/application/types/schedule-item.ts b/src/modules/ad/core/application/types/schedule-item.ts deleted file mode 100644 index 92dab99..0000000 --- a/src/modules/ad/core/application/types/schedule-item.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ScheduleItem = { - day: number; - time: string; - margin: number; -}; diff --git a/src/modules/ad/core/application/types/waypoint.ts b/src/modules/ad/core/application/types/waypoint.ts index 32c5204..57d792e 100644 --- a/src/modules/ad/core/application/types/waypoint.ts +++ b/src/modules/ad/core/application/types/waypoint.ts @@ -1,5 +1,6 @@ import { Address } from './address'; +//TODO Why not use the Waypoint value-object from the domain? export type Waypoint = { position: number; } & Address; diff --git a/src/modules/ad/core/domain/ad.entity.ts b/src/modules/ad/core/domain/ad.entity.ts index ffbeb0b..00ea1eb 100644 --- a/src/modules/ad/core/domain/ad.entity.ts +++ b/src/modules/ad/core/domain/ad.entity.ts @@ -30,9 +30,9 @@ export class AdEntity extends AggregateRoot { fromDate: props.fromDate, toDate: props.toDate, schedule: props.schedule.map((day: ScheduleItemProps) => ({ - day: day.day as number, + day: day.day, time: day.time, - margin: day.margin as number, + margin: day.margin, })), seatsProposed: props.seatsProposed, seatsRequested: props.seatsRequested, @@ -96,6 +96,24 @@ export class AdEntity extends AggregateRoot { return this; }; + update = (newProps: CreateAdProps): AdEntity => { + this.props.driver = newProps.driver; + this.props.passenger = newProps.passenger; + this.props.frequency = newProps.frequency; + this.props.fromDate = newProps.fromDate; + this.props.toDate = newProps.toDate; + this.props.seatsProposed = newProps.seatsProposed; + this.props.seatsRequested = newProps.seatsRequested; + this.props.strict = newProps.strict; + this.props.comment = newProps.comment; + this.props.schedule = newProps.schedule.map((item) => ({ ...item })); + this.props.waypoints = newProps.waypoints.map((wp) => ({ ...wp })); + //The ad goes back to pending status until it is validated again + this.props.status = Status.PENDING; + this.validate(); + return this; + }; + delete(): void { this.addEvent( new AdDeletedDomainEvent({ diff --git a/src/modules/ad/core/domain/ad.types.ts b/src/modules/ad/core/domain/ad.types.ts index 85bb2bc..d42115a 100644 --- a/src/modules/ad/core/domain/ad.types.ts +++ b/src/modules/ad/core/domain/ad.types.ts @@ -1,23 +1,6 @@ import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; import { WaypointProps } from './value-objects/waypoint.value-object'; -// All properties that an Ad has -export interface AdProps { - userId: string; - driver: boolean; - status: Status; - passenger: boolean; - frequency: Frequency; - fromDate: string; - toDate: string; - schedule: ScheduleItemProps[]; - seatsProposed: number; - seatsRequested: number; - strict: boolean; - waypoints: WaypointProps[]; - comment?: string; -} - // Properties that are needed for an Ad creation export interface CreateAdProps { userId: string; @@ -34,6 +17,11 @@ export interface CreateAdProps { comment?: string; } +// All properties that an Ad has +export interface AdProps extends CreateAdProps { + status: Status; +} + export enum Frequency { PUNCTUAL = 'PUNCTUAL', RECURRENT = 'RECURRENT', diff --git a/src/modules/ad/core/domain/events/ad-created.domain-event.ts b/src/modules/ad/core/domain/events/ad-created.domain-event.ts index 50cd191..12c45cf 100644 --- a/src/modules/ad/core/domain/events/ad-created.domain-event.ts +++ b/src/modules/ad/core/domain/events/ad-created.domain-event.ts @@ -1,4 +1,5 @@ import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library'; +import { ScheduleItemProps } from '../value-objects/schedule-item.value-object'; export class AdCreatedDomainEvent extends DomainEvent { readonly userId: string; @@ -7,7 +8,7 @@ export class AdCreatedDomainEvent extends DomainEvent { readonly frequency: string; readonly fromDate: string; readonly toDate: string; - readonly schedule: ScheduleItem[]; + readonly schedule: ScheduleItemProps[]; readonly seatsProposed: number; readonly seatsRequested: number; readonly strict: boolean; @@ -31,12 +32,6 @@ export class AdCreatedDomainEvent extends DomainEvent { } } -export class ScheduleItem { - day: number; - time: string; - margin: number; -} - export class Waypoint { position: number; name?: string; diff --git a/src/modules/ad/core/domain/events/ad.domain-event.ts b/src/modules/ad/core/domain/events/ad.domain-event.ts new file mode 100644 index 0000000..d67b8f5 --- /dev/null +++ b/src/modules/ad/core/domain/events/ad.domain-event.ts @@ -0,0 +1,23 @@ +import { DomainEvent } from '@mobicoop/ddd-library'; +import { AdEntity } from '../ad.entity'; + +export abstract class AdDomainEvent extends DomainEvent { + readonly ad: AdEntity; + + constructor(ad: AdEntity) { + super({ + metadata: { + correlationId: ad.id, + timestamp: Date.now(), + }, + aggregateId: ad.id, + }); + this.ad = ad; + } +} + +export class AdUpdatedDomainEvent extends AdDomainEvent { + constructor(ad: AdEntity) { + super(ad); + } +} diff --git a/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts index 8303eeb..c5f6cc2 100644 --- a/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts @@ -6,13 +6,13 @@ import { ValueObject } from '@mobicoop/ddd-library'; * */ export interface ScheduleItemProps { - day?: number; + day: number; time: string; - margin?: number; + margin: number; } export class ScheduleItem extends ValueObject { - get day(): number | undefined { + get day(): number { return this.props.day; } @@ -20,7 +20,7 @@ export class ScheduleItem extends ValueObject { return this.props.time; } - get margin(): number | undefined { + get margin(): number { return this.props.margin; } diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index b5df96f..fa06d0a 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -1,16 +1,16 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { AdEntity } from '../core/domain/ad.entity'; -import { AdMapper } from '../ad.mapper'; -import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; import { LoggerBase, MessagePublisherPort, PrismaRepositoryBase, } from '@mobicoop/ddd-library'; -import { PrismaService } from './prisma.service'; -import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { PrismaService } from './prisma.service'; export type AdBaseModel = { uuid: string; @@ -40,13 +40,21 @@ export type AdWriteModel = AdBaseModel & { }; export type ScheduleWriteModel = { + deleteMany?: PastCreatedFilter; create: ScheduleItemModel[]; }; export type WaypointWriteModel = { + deleteMany?: PastCreatedFilter; create: WaypointModel[]; }; +// 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 ScheduleItemModel = { uuid: string; day: number; diff --git a/src/modules/ad/interface/grpc-controllers/ad.proto b/src/modules/ad/interface/grpc-controllers/ad.proto index 581aa08..da93584 100644 --- a/src/modules/ad/interface/grpc-controllers/ad.proto +++ b/src/modules/ad/interface/grpc-controllers/ad.proto @@ -7,7 +7,7 @@ service AdService { rpc FindAllByIds(AdsById) returns (Ads); rpc FindAllByUserId(UserById) returns (Ads); rpc Create(Ad) returns (AdById); - rpc Update(Ad) returns (Ad); + rpc Update(Ad) returns (Empty); rpc Delete(AdById) returns (Empty); } @@ -58,6 +58,7 @@ message Waypoint { } enum Frequency { + UNSPECIFIED = 0; PUNCTUAL = 1; RECURRENT = 2; } diff --git a/src/modules/ad/interface/grpc-controllers/dtos/update-ad.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/update-ad.request.dto.ts new file mode 100644 index 0000000..2fffdb0 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/update-ad.request.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; +import { CreateAdRequestDto } from './create-ad.request.dto'; + +export class UpdateAdRequestDto extends CreateAdRequestDto { + @IsUUID(4) + id: string; +} diff --git a/src/modules/ad/interface/grpc-controllers/update-ad.grpc.controller.ts b/src/modules/ad/interface/grpc-controllers/update-ad.grpc.controller.ts new file mode 100644 index 0000000..326a3df --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/update-ad.grpc.controller.ts @@ -0,0 +1,43 @@ +import { + NotFoundException, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command'; +import { Controller, UsePipes } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { GRPC_SERVICE_NAME } from '@src/app.constants'; +import { UpdateAdRequestDto } from './dtos/update-ad.request.dto'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class UpdateAdGrpcController { + constructor(private readonly commandBus: CommandBus) {} + + @GrpcMethod(GRPC_SERVICE_NAME, 'Update') + async update(data: UpdateAdRequestDto): Promise { + try { + const cmdProps = { + adId: data.id, + ...data, + }; + delete (cmdProps as { id?: string }).id; + + await this.commandBus.execute(new UpdateAdCommand(cmdProps)); + } catch (error) { + if (error instanceof NotFoundException) { + throw new RpcException({ + code: RpcExceptionCode.NOT_FOUND, + message: error.message, + }); + } + throw error; + } + } +} diff --git a/tests/unit/ad/ad.mocks.ts b/tests/unit/ad/ad.mocks.ts new file mode 100644 index 0000000..2b97881 --- /dev/null +++ b/tests/unit/ad/ad.mocks.ts @@ -0,0 +1,10 @@ +import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; + +export function mockInputDateTimeTransformer(): DateTimeTransformerPort { + return { + fromDate: jest.fn(), + toDate: jest.fn(), + day: jest.fn(), + time: jest.fn(), + }; +} diff --git a/tests/unit/ad/core/ad.fixtures.ts b/tests/unit/ad/core/ad.fixtures.ts index 2ebf55a..3e2663c 100644 --- a/tests/unit/ad/core/ad.fixtures.ts +++ b/tests/unit/ad/core/ad.fixtures.ts @@ -39,7 +39,9 @@ const punctualCreateAdProps = { toDate: '2023-06-22', schedule: [ { + day: 4, time: '08:30', + margin: 900, }, ], frequency: Frequency.PUNCTUAL, diff --git a/tests/unit/ad/core/create-ad.service.spec.ts b/tests/unit/ad/core/create-ad.service.spec.ts index 18ed15d..89ac262 100644 --- a/tests/unit/ad/core/create-ad.service.spec.ts +++ b/tests/unit/ad/core/create-ad.service.spec.ts @@ -1,18 +1,17 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import { AD_REPOSITORY, INPUT_DATETIME_TRANSFORMER, } from '@modules/ad/ad.di-tokens'; -import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; -import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; -import { AggregateID } from '@mobicoop/ddd-library'; -import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { ConflictException } from '@mobicoop/ddd-library'; -import { Frequency } from '@modules/ad/core/domain/ad.types'; -import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command'; +import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; -import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mockInputDateTimeTransformer } from '../ad.mocks'; const originWaypoint: WaypointDto = { position: 0, @@ -64,13 +63,6 @@ const mockAdRepository = { }), }; -const mockInputDateTimeTransformer: DateTimeTransformerPort = { - fromDate: jest.fn(), - toDate: jest.fn(), - day: jest.fn(), - time: jest.fn(), -}; - describe('create-ad.service', () => { let createAdService: CreateAdService; @@ -83,7 +75,7 @@ describe('create-ad.service', () => { }, { provide: INPUT_DATETIME_TRANSFORMER, - useValue: mockInputDateTimeTransformer, + useValue: mockInputDateTimeTransformer(), }, CreateAdService, ], diff --git a/tests/unit/ad/core/find-ads-by-ids.query-handler.spec.ts b/tests/unit/ad/core/find-ads-by-ids.query-handler.spec.ts index c9aef33..0f7e05d 100644 --- a/tests/unit/ad/core/find-ads-by-ids.query-handler.spec.ts +++ b/tests/unit/ad/core/find-ads-by-ids.query-handler.spec.ts @@ -1,10 +1,10 @@ import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query'; +import { FindAdsByIdsQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; import { Test, TestingModule } from '@nestjs/testing'; -import { FindAdsByIdsQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler'; -import { FindAdsByIdsQuery } from '@modules/ad/core/application/queries/find-ads-by-ids/find-ads-by-ids.query'; const originWaypointProps: WaypointProps = { position: 0, @@ -44,7 +44,9 @@ const punctualCreateAdProps = { toDate: '2023-06-22', schedule: [ { + day: 4, time: '08:30', + margin: 900, }, ], frequency: Frequency.PUNCTUAL, diff --git a/tests/unit/ad/core/find-ads-by-user-id.query-handler.spec.ts b/tests/unit/ad/core/find-ads-by-user-id.query-handler.spec.ts index 300c84c..4f7751a 100644 --- a/tests/unit/ad/core/find-ads-by-user-id.query-handler.spec.ts +++ b/tests/unit/ad/core/find-ads-by-user-id.query-handler.spec.ts @@ -1,10 +1,10 @@ import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query'; +import { FindAdsByUserIdQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; import { Test, TestingModule } from '@nestjs/testing'; -import { FindAdsByUserIdQueryHandler } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler'; -import { FindAdsByUserIdQuery } from '@modules/ad/core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query'; const originWaypointProps: WaypointProps = { position: 0, @@ -44,7 +44,9 @@ const punctualCreateAdProps = { toDate: '2023-06-22', schedule: [ { + day: 4, time: '08:30', + margin: 900, }, ], frequency: Frequency.PUNCTUAL, diff --git a/tests/unit/ad/core/invalidate-ad.service.spec.ts b/tests/unit/ad/core/invalidate-ad.service.spec.ts index db72a24..8bd1568 100644 --- a/tests/unit/ad/core/invalidate-ad.service.spec.ts +++ b/tests/unit/ad/core/invalidate-ad.service.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; import { AggregateID } from '@mobicoop/ddd-library'; +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command'; +import { InvalidateAdService } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.service'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; -import { InvalidateAdService } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.service'; -import { InvalidateAdCommand } from '@modules/ad/core/application/commands/invalidate-ad/invalidate-ad.command'; +import { Test, TestingModule } from '@nestjs/testing'; const originWaypointProps: WaypointProps = { position: 0, @@ -45,7 +45,9 @@ const punctualCreateAdProps = { toDate: '2023-06-22', schedule: [ { + day: 4, time: '08:30', + margin: 900, }, ], frequency: Frequency.PUNCTUAL, diff --git a/tests/unit/ad/core/publish-message-when-ad-is-updated.domain-event-handler.spec.ts b/tests/unit/ad/core/publish-message-when-ad-is-updated.domain-event-handler.spec.ts new file mode 100644 index 0000000..c6aa48d --- /dev/null +++ b/tests/unit/ad/core/publish-message-when-ad-is-updated.domain-event-handler.spec.ts @@ -0,0 +1,61 @@ +import { + AD_MESSAGE_PUBLISHER, + OUTPUT_DATETIME_TRANSFORMER, + TIMEZONE_FINDER, + TIME_CONVERTER, +} from '@modules/ad/ad.di-tokens'; +import { AdMapper } from '@modules/ad/ad.mapper'; +import { PublishMessageWhenAdIsUpdatedDomainEventHandler } from '@modules/ad/core/application/event-handlers/publish-message-when-ad-is-updated.domain-event-handler'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { AdUpdatedDomainEvent } from '@modules/ad/core/domain/events/ad.domain-event'; +import { OutputDateTimeTransformer } from '@modules/ad/infrastructure/output-datetime-transformer'; +import { TimeConverter } from '@modules/ad/infrastructure/time-converter'; +import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder'; +import { Test, TestingModule } from '@nestjs/testing'; +import { punctualPassengerCreateAdProps } from './ad.fixtures'; + +const mockMessagePublisher = { + publish: jest.fn(), +}; + +describe('Publish message when ad is updated domain event handler', () => { + let updatedDomainEventHandler: PublishMessageWhenAdIsUpdatedDomainEventHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + { + provide: OUTPUT_DATETIME_TRANSFORMER, + useClass: OutputDateTimeTransformer, + }, + { + provide: TIMEZONE_FINDER, + useClass: TimezoneFinder, + }, + { + provide: TIME_CONVERTER, + useClass: TimeConverter, + }, + AdMapper, + PublishMessageWhenAdIsUpdatedDomainEventHandler, + ], + }).compile(); + + updatedDomainEventHandler = + module.get( + PublishMessageWhenAdIsUpdatedDomainEventHandler, + ); + }); + + it('should publish a message', () => { + expect(updatedDomainEventHandler).toBeDefined(); + const ad = AdEntity.create(punctualPassengerCreateAdProps()); + const adUpdatedDomainEvent = new AdUpdatedDomainEvent(ad); + updatedDomainEventHandler.handle(adUpdatedDomainEvent); + expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ad/core/update-ad.service.spec.ts b/tests/unit/ad/core/update-ad.service.spec.ts new file mode 100644 index 0000000..9d56fcc --- /dev/null +++ b/tests/unit/ad/core/update-ad.service.spec.ts @@ -0,0 +1,71 @@ +import { + AD_REPOSITORY, + INPUT_DATETIME_TRANSFORMER, +} 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 { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { Status } from '@modules/ad/core/domain/ad.types'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mockInputDateTimeTransformer } from '../ad.mocks'; +import { punctualCreateAdRequest } from '../interface/ad.fixtures'; +import { punctualPassengerCreateAdProps } from './ad.fixtures'; + +const mockAdRepository = { + findOneById: jest.fn().mockImplementation( + async (id) => + new AdEntity({ + id, + props: { ...punctualPassengerCreateAdProps(), status: Status.VALID }, + }), + ), + update: jest.fn(), +}; + +const mockEventEmitter = { + emitAsync: jest.fn(), +}; + +describe('create-ad.service', () => { + let updateAdService: UpdateAdService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + { + provide: INPUT_DATETIME_TRANSFORMER, + useValue: mockInputDateTimeTransformer(), + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + UpdateAdService, + ], + }).compile(); + + updateAdService = module.get(UpdateAdService); + }); + + it('should be defined', () => { + expect(updateAdService).toBeDefined(); + }); + + describe('execute', () => { + it('should update the ad in the repository and emit an event', async () => { + const command = new UpdateAdCommand({ + adId: '200d61a8-d878-4378-a609-c19ea71633d2', + ...punctualCreateAdRequest(), + }); + + await updateAdService.execute(command); + expect(mockAdRepository.update).toHaveBeenCalled(); + expect(mockEventEmitter.emitAsync).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/ad/core/validate-ad.service.spec.ts b/tests/unit/ad/core/validate-ad.service.spec.ts index 0a30c89..1ccd147 100644 --- a/tests/unit/ad/core/validate-ad.service.spec.ts +++ b/tests/unit/ad/core/validate-ad.service.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; import { AggregateID } from '@mobicoop/ddd-library'; +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command'; +import { ValidateAdService } from '@modules/ad/core/application/commands/validate-ad/validate-ad.service'; import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; -import { ValidateAdService } from '@modules/ad/core/application/commands/validate-ad/validate-ad.service'; -import { ValidateAdCommand } from '@modules/ad/core/application/commands/validate-ad/validate-ad.command'; import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; +import { Test, TestingModule } from '@nestjs/testing'; const originWaypointProps: WaypointProps = { position: 0, @@ -45,7 +45,9 @@ const punctualCreateAdProps = { toDate: '2023-06-22', schedule: [ { + day: 4, time: '08:30', + margin: 900, }, ], frequency: Frequency.PUNCTUAL, diff --git a/tests/unit/ad/interface/ad.fixtures.ts b/tests/unit/ad/interface/ad.fixtures.ts new file mode 100644 index 0000000..6e19747 --- /dev/null +++ b/tests/unit/ad/interface/ad.fixtures.ts @@ -0,0 +1,43 @@ +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; + +const originWaypoint: WaypointDto = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: WaypointDto = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; +export function punctualCreateAdRequest(): CreateAdRequestDto { + return { + userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4', + fromDate: '2023-12-21', + toDate: '2023-12-21', + schedule: [ + { + time: '08:15', + day: 4, + margin: 600, + }, + ], + driver: false, + passenger: true, + seatsRequested: 1, + seatsProposed: 3, + strict: false, + frequency: Frequency.PUNCTUAL, + waypoints: [originWaypoint, destinationWaypoint], + }; +} diff --git a/tests/unit/ad/interface/create-ad.grpc.controller.spec.ts b/tests/unit/ad/interface/create-ad.grpc.controller.spec.ts index b66b4b7..48feaeb 100644 --- a/tests/unit/ad/interface/create-ad.grpc.controller.spec.ts +++ b/tests/unit/ad/interface/create-ad.grpc.controller.spec.ts @@ -1,51 +1,10 @@ -import { IdResponse } from '@mobicoop/ddd-library'; -import { RpcExceptionCode } from '@mobicoop/ddd-library'; +import { IdResponse, RpcExceptionCode } from '@mobicoop/ddd-library'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; -import { Frequency } from '@modules/ad/core/domain/ad.types'; import { CreateAdGrpcController } from '@modules/ad/interface/grpc-controllers/create-ad.grpc.controller'; -import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; -import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; import { CommandBus } from '@nestjs/cqrs'; import { RpcException } from '@nestjs/microservices'; import { Test, TestingModule } from '@nestjs/testing'; - -const originWaypoint: WaypointDto = { - position: 0, - lat: 48.689445, - lon: 6.17651, - houseNumber: '5', - street: 'Avenue Foch', - locality: 'Nancy', - postalCode: '54000', - country: 'France', -}; -const destinationWaypoint: WaypointDto = { - position: 1, - lat: 48.8566, - lon: 2.3522, - locality: 'Paris', - postalCode: '75000', - country: 'France', -}; -const punctualCreateAdRequest: CreateAdRequestDto = { - userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4', - fromDate: '2023-12-21', - toDate: '2023-12-21', - schedule: [ - { - time: '08:15', - day: 4, - margin: 600, - }, - ], - driver: false, - passenger: true, - seatsRequested: 1, - seatsProposed: 3, - strict: false, - frequency: Frequency.PUNCTUAL, - waypoints: [originWaypoint, destinationWaypoint], -}; +import { punctualCreateAdRequest } from './ad.fixtures'; const mockCommandBus = { execute: jest @@ -89,7 +48,7 @@ describe('Create Ad Grpc Controller', () => { it('should create a new ad', async () => { jest.spyOn(mockCommandBus, 'execute'); const result: IdResponse = await createAdGrpcController.create( - punctualCreateAdRequest, + punctualCreateAdRequest(), ); expect(result).toBeInstanceOf(IdResponse); expect(result.id).toBe('200d61a8-d878-4378-a609-c19ea71633d2'); @@ -100,7 +59,7 @@ describe('Create Ad Grpc Controller', () => { jest.spyOn(mockCommandBus, 'execute'); expect.assertions(3); try { - await createAdGrpcController.create(punctualCreateAdRequest); + await createAdGrpcController.create(punctualCreateAdRequest()); } catch (e: any) { expect(e).toBeInstanceOf(RpcException); expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS); @@ -112,7 +71,7 @@ describe('Create Ad Grpc Controller', () => { jest.spyOn(mockCommandBus, 'execute'); expect.assertions(3); try { - await createAdGrpcController.create(punctualCreateAdRequest); + await createAdGrpcController.create(punctualCreateAdRequest()); } catch (e: any) { expect(e).toBeInstanceOf(RpcException); expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN); diff --git a/tests/unit/ad/interface/update-ad.grpc.controller.spec.ts b/tests/unit/ad/interface/update-ad.grpc.controller.spec.ts new file mode 100644 index 0000000..998e64f --- /dev/null +++ b/tests/unit/ad/interface/update-ad.grpc.controller.spec.ts @@ -0,0 +1,76 @@ +import { NotFoundException, RpcExceptionCode } from '@mobicoop/ddd-library'; +import { UpdateAdGrpcController } from '@modules/ad/interface/grpc-controllers/update-ad.grpc.controller'; +import { CommandBus } from '@nestjs/cqrs'; +import { RpcException } from '@nestjs/microservices'; +import { Test, TestingModule } from '@nestjs/testing'; +import { punctualCreateAdRequest } from './ad.fixtures'; + +const validAdId = '200d61a8-d878-4378-a609-c19ea71633d2'; +const mockCommandBus = { + execute: jest.fn().mockImplementation(async (command) => { + if (command.adId === '') throw 'Ad id is empty'; + if (command.adId != validAdId) throw new NotFoundException(); + }), +}; + +describe('Update Ad GRPC Controller', () => { + let updateAdGrpcController: UpdateAdGrpcController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + UpdateAdGrpcController, + ], + }).compile(); + updateAdGrpcController = module.get( + UpdateAdGrpcController, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(updateAdGrpcController).toBeDefined(); + }); + + it('should execute the update ad command', async () => { + await updateAdGrpcController.update({ + id: validAdId, + ...punctualCreateAdRequest(), + }); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a dedicated RpcException if ad is not found', async () => { + expect.assertions(3); + try { + await updateAdGrpcController.update({ + id: 'ac85f5f4-41cd-4c5d-9aee-0a1acb176fb8', + ...punctualCreateAdRequest(), + }); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should rethrow any other exceptions', async () => { + expect.assertions(2); + try { + await updateAdGrpcController.update({ + id: '', + ...punctualCreateAdRequest(), + }); + } catch (e: any) { + expect(e).toBe('Ad id is empty'); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); +});