diff --git a/src/app.constants.ts b/src/app.constants.ts index 0592c17..fcc7e0f 100644 --- a/src/app.constants.ts +++ b/src/app.constants.ts @@ -7,6 +7,8 @@ export const GRPC_SERVICE_NAME = 'AdService'; // messaging output export const AD_CREATED_ROUTING_KEY = 'ad.created'; +// messaging output +export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; // messaging input export const MATCHER_AD_CREATED_MESSAGE_HANDLER = 'matcherAdCreated'; diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index ae79748..08c66b8 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -1,5 +1,5 @@ +import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; import { Module, Provider } from '@nestjs/common'; -import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller'; import { CqrsModule } from '@nestjs/cqrs'; import { AD_MESSAGE_PUBLISHER, @@ -9,29 +9,33 @@ import { TIMEZONE_FINDER, TIME_CONVERTER, } from './ad.di-tokens'; -import { AdRepository } from './infrastructure/ad.repository'; import { AdMapper } from './ad.mapper'; import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; -import { TimezoneFinder } from './infrastructure/timezone-finder'; -import { TimeConverter } from './infrastructure/time-converter'; -import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller'; -import { FindAdByIdQueryHandler } from './core/application/queries/find-ad-by-id/find-ad-by-id.query-handler'; +import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service'; +import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-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 { PrismaService } from './infrastructure/prisma.service'; -import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; +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'; +import { AdRepository } from './infrastructure/ad.repository'; import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer'; import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer'; +import { PrismaService } from './infrastructure/prisma.service'; +import { TimeConverter } from './infrastructure/time-converter'; +import { TimezoneFinder } from './infrastructure/timezone-finder'; +import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller'; +import { DeleteAdGrpcController } from './interface/grpc-controllers/delete-ad.grpc.controller'; +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 { FindAdsByIdsQueryHandler } from './core/application/queries/find-ads-by-ids/find-ads-by-ids.query-handler'; -import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler'; -import { ValidateAdService } from './core/application/commands/validate-ad/validate-ad.service'; -import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler'; -import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-ad.service'; import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller'; -import { FindAdsByUserIdQueryHandler } from './core/application/queries/find-ads-by-user-id/find-ads-by-user-id.query-handler'; +import { MatcherAdCreatedMessageHandler } from './interface/message-handlers/matcher-ad-created.message-handler'; +import { MatcherAdCreationFailedMessageHandler } from './interface/message-handlers/matcher-ad-creation-failed.message-handler'; const grpcControllers = [ CreateAdGrpcController, + DeleteAdGrpcController, FindAdByIdGrpcController, FindAdsByIdsGrpcController, FindAdsByUserIdGrpcController, @@ -44,10 +48,12 @@ const messageHandlers = [ const eventHandlers: Provider[] = [ PublishMessageWhenAdIsCreatedDomainEventHandler, + PublishMessageWhenAdIsDeletedDomainEventHandler, ]; const commandHandlers: Provider[] = [ CreateAdService, + DeleteAdService, ValidateAdService, InvalidateAdService, ]; diff --git a/src/modules/ad/core/application/commands/delete-ad/delete-ad.command.ts b/src/modules/ad/core/application/commands/delete-ad/delete-ad.command.ts new file mode 100644 index 0000000..1db4234 --- /dev/null +++ b/src/modules/ad/core/application/commands/delete-ad/delete-ad.command.ts @@ -0,0 +1,7 @@ +import { Command, CommandProps } from '@mobicoop/ddd-library'; + +export class DeleteAdCommand extends Command { + constructor(props: CommandProps) { + super(props); + } +} diff --git a/src/modules/ad/core/application/commands/delete-ad/delete-ad.service.ts b/src/modules/ad/core/application/commands/delete-ad/delete-ad.service.ts new file mode 100644 index 0000000..a8d89ee --- /dev/null +++ b/src/modules/ad/core/application/commands/delete-ad/delete-ad.service.ts @@ -0,0 +1,18 @@ +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { DeleteAdCommand } from './delete-ad.command'; + +@CommandHandler(DeleteAdCommand) +export class DeleteAdService implements ICommandHandler { + constructor( + @Inject(AD_REPOSITORY) private readonly adRepository: AdRepositoryPort, + ) {} + + async execute(command: DeleteAdCommand): Promise { + const ad = await this.adRepository.findOneById(command.id); + ad.delete(); + return this.adRepository.delete(ad); + } +} diff --git a/src/modules/ad/core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler.ts b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler.ts new file mode 100644 index 0000000..270181a --- /dev/null +++ b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler.ts @@ -0,0 +1,22 @@ +import { MessagePublisherPort } from '@mobicoop/ddd-library'; +import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AD_DELETED_ROUTING_KEY } from '@src/app.constants'; +import { AdDeletedDomainEvent } from '../../domain/events/ad-delete.domain-event'; + +@Injectable() +export class PublishMessageWhenAdIsDeletedDomainEventHandler { + constructor( + @Inject(AD_MESSAGE_PUBLISHER) + private readonly messagePublisher: MessagePublisherPort, + ) {} + + @OnEvent(AdDeletedDomainEvent.name, { async: true, promisify: true }) + async handle(event: AdDeletedDomainEvent): Promise { + this.messagePublisher.publish( + AD_DELETED_ROUTING_KEY, + JSON.stringify(event), + ); + } +} diff --git a/src/modules/ad/core/domain/ad.entity.ts b/src/modules/ad/core/domain/ad.entity.ts index 7381430..ffbeb0b 100644 --- a/src/modules/ad/core/domain/ad.entity.ts +++ b/src/modules/ad/core/domain/ad.entity.ts @@ -1,12 +1,13 @@ -import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library'; import { v4 } from 'uuid'; -import { AdCreatedDomainEvent } from './events/ad-created.domain-event'; import { AdProps, CreateAdProps, Status } from './ad.types'; -import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; -import { WaypointProps } from './value-objects/waypoint.value-object'; -import { AdValidatedDomainEvent } from './events/ad-validated.domain-event'; +import { AdCreatedDomainEvent } from './events/ad-created.domain-event'; +import { AdDeletedDomainEvent } from './events/ad-delete.domain-event'; import { AdInvalidatedDomainEvent } from './events/ad-invalidated.domain-event'; import { AdSuspendedDomainEvent } from './events/ad-suspended.domain-event'; +import { AdValidatedDomainEvent } from './events/ad-validated.domain-event'; +import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; +import { WaypointProps } from './value-objects/waypoint.value-object'; export class AdEntity extends AggregateRoot { protected readonly _id: AggregateID; @@ -95,6 +96,14 @@ export class AdEntity extends AggregateRoot { return this; }; + delete(): void { + this.addEvent( + new AdDeletedDomainEvent({ + aggregateId: this.id, + }), + ); + } + validate(): void { // entity business rules validation to protect it's invariant before saving entity to a database } diff --git a/src/modules/ad/core/domain/events/ad-delete.domain-event.ts b/src/modules/ad/core/domain/events/ad-delete.domain-event.ts new file mode 100644 index 0000000..3c4930d --- /dev/null +++ b/src/modules/ad/core/domain/events/ad-delete.domain-event.ts @@ -0,0 +1,7 @@ +import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library'; + +export class AdDeletedDomainEvent extends DomainEvent { + constructor(props: DomainEventProps) { + super(props); + } +} diff --git a/src/modules/ad/interface/grpc-controllers/delete-ad.grpc.controller.ts b/src/modules/ad/interface/grpc-controllers/delete-ad.grpc.controller.ts new file mode 100644 index 0000000..6511a48 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/delete-ad.grpc.controller.ts @@ -0,0 +1,45 @@ +import { + DatabaseErrorException, + NotFoundException, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-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 { DeleteAdRequestDto } from './dtos/delete-ad.request.dto'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class DeleteAdGrpcController { + constructor(private readonly commandBus: CommandBus) {} + + @GrpcMethod(GRPC_SERVICE_NAME, 'Delete') + async delete(data: DeleteAdRequestDto): Promise { + try { + await this.commandBus.execute(new DeleteAdCommand(data)); + } catch (error: any) { + if (error instanceof NotFoundException) + throw new RpcException({ + code: RpcExceptionCode.NOT_FOUND, + message: error.message, + }); + if (error instanceof DatabaseErrorException) + throw new RpcException({ + code: RpcExceptionCode.INTERNAL, + message: error.message, + }); + throw new RpcException({ + code: RpcExceptionCode.UNKNOWN, + message: error.message, + }); + } + } +} diff --git a/src/modules/ad/interface/grpc-controllers/dtos/delete-ad.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/delete-ad.request.dto.ts new file mode 100644 index 0000000..f230269 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/delete-ad.request.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteAdRequestDto { + @IsString() + @IsNotEmpty() + id: string; +} diff --git a/tests/unit/ad/core/ad.fixtures.ts b/tests/unit/ad/core/ad.fixtures.ts new file mode 100644 index 0000000..2ebf55a --- /dev/null +++ b/tests/unit/ad/core/ad.fixtures.ts @@ -0,0 +1,55 @@ +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; + +const originWaypointProps: WaypointProps = { + position: 0, + address: { + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', + coordinates: { + lat: 48.689445, + lon: 6.17651, + }, + }, +}; +const destinationWaypointProps: WaypointProps = { + position: 1, + address: { + locality: 'Paris', + postalCode: '75000', + country: 'France', + coordinates: { + lat: 48.8566, + lon: 2.3522, + }, + }, +}; +const baseCreateAdProps = { + userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36', + seatsProposed: 3, + seatsRequested: 1, + strict: false, + waypoints: [originWaypointProps, destinationWaypointProps], +}; +const punctualCreateAdProps = { + fromDate: '2023-06-22', + toDate: '2023-06-22', + schedule: [ + { + time: '08:30', + }, + ], + frequency: Frequency.PUNCTUAL, +}; + +export function punctualPassengerCreateAdProps(): CreateAdProps { + return { + ...baseCreateAdProps, + ...punctualCreateAdProps, + driver: false, + passenger: true, + }; +} diff --git a/tests/unit/ad/core/delete-ad.service.spec.ts b/tests/unit/ad/core/delete-ad.service.spec.ts new file mode 100644 index 0000000..705c872 --- /dev/null +++ b/tests/unit/ad/core/delete-ad.service.spec.ts @@ -0,0 +1,42 @@ +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-ad.command'; +import { DeleteAdService } from '@modules/ad/core/application/commands/delete-ad/delete-ad.service'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { Test, TestingModule } from '@nestjs/testing'; +import { punctualPassengerCreateAdProps } from './ad.fixtures'; + +const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps()); +jest.spyOn(ad, 'delete'); + +const mockAdRepository = { + findOneById: jest.fn().mockImplementation(() => ad), + delete: jest.fn(), +}; + +describe('delete-ad.service', () => { + let deleteAdService: DeleteAdService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + DeleteAdService, + ], + }).compile(); + + deleteAdService = module.get(DeleteAdService); + }); + + it('should be defined', () => { + expect(deleteAdService).toBeDefined(); + }); + + it('should trigger the delete logic and delete the ad from the repository', async () => { + await deleteAdService.execute(new DeleteAdCommand({ id: ad.id })); + expect(ad.delete).toHaveBeenCalled(); + expect(mockAdRepository.delete).toHaveBeenCalledWith(ad); + }); +}); diff --git a/tests/unit/ad/core/find-ad-by-id.query-handler.spec.ts b/tests/unit/ad/core/find-ad-by-id.query-handler.spec.ts index 013bfcb..95fcaa6 100644 --- a/tests/unit/ad/core/find-ad-by-id.query-handler.spec.ts +++ b/tests/unit/ad/core/find-ad-by-id.query-handler.spec.ts @@ -1,62 +1,11 @@ import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; -import { AdEntity } from '@modules/ad/core/domain/ad.entity'; -import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { FindAdByIdQuery } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query'; import { FindAdByIdQueryHandler } from '@modules/ad/core/application/queries/find-ad-by-id/find-ad-by-id.query-handler'; -import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { Test, TestingModule } from '@nestjs/testing'; +import { punctualPassengerCreateAdProps } from './ad.fixtures'; -const originWaypointProps: WaypointProps = { - position: 0, - address: { - houseNumber: '5', - street: 'Avenue Foch', - locality: 'Nancy', - postalCode: '54000', - country: 'France', - coordinates: { - lat: 48.689445, - lon: 6.17651, - }, - }, -}; -const destinationWaypointProps: WaypointProps = { - position: 1, - address: { - locality: 'Paris', - postalCode: '75000', - country: 'France', - coordinates: { - lat: 48.8566, - lon: 2.3522, - }, - }, -}; -const baseCreateAdProps = { - userId: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36', - seatsProposed: 3, - seatsRequested: 1, - strict: false, - waypoints: [originWaypointProps, destinationWaypointProps], -}; -const punctualCreateAdProps = { - fromDate: '2023-06-22', - toDate: '2023-06-22', - schedule: [ - { - time: '08:30', - }, - ], - frequency: Frequency.PUNCTUAL, -}; -const punctualPassengerCreateAdProps: CreateAdProps = { - ...baseCreateAdProps, - ...punctualCreateAdProps, - driver: false, - passenger: true, -}; - -const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps); +const ad: AdEntity = AdEntity.create(punctualPassengerCreateAdProps()); const mockAdRepository = { findOneById: jest.fn().mockImplementation(() => ad), diff --git a/tests/unit/ad/interface/delete-ad.grpc.controller.spec.ts b/tests/unit/ad/interface/delete-ad.grpc.controller.spec.ts new file mode 100644 index 0000000..9a6cdd2 --- /dev/null +++ b/tests/unit/ad/interface/delete-ad.grpc.controller.spec.ts @@ -0,0 +1,42 @@ +import { DeleteAdGrpcController } from '@modules/ad/interface/grpc-controllers/delete-ad.grpc.controller'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockCommandBus = { + execute: jest.fn(), +}; + +describe('Delete Ad Grpc Controller', () => { + let deleteAdGrpcController: DeleteAdGrpcController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + DeleteAdGrpcController, + ], + }).compile(); + + deleteAdGrpcController = module.get( + DeleteAdGrpcController, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(deleteAdGrpcController).toBeDefined(); + }); + + it('should execute the delete ad command', async () => { + await deleteAdGrpcController.delete({ + id: '200d61a8-d878-4378-a609-c19ea71633d2', + }); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); +});