From 28c6ca0f63c35b0069a86d985d67f46a8f1068a7 Mon Sep 17 00:00:00 2001 From: sbriat Date: Fri, 7 Jul 2023 11:11:36 +0200 Subject: [PATCH] delete username usecase --- .../authentication/authentication.module.ts | 4 + .../delete-username.command.ts | 10 ++ .../delete-username.service.ts | 23 +++++ .../events/username-deleted.domain-event.ts | 7 ++ .../core/domain/username.entity.ts | 9 ++ .../delete-username.grpc.controller.ts | 45 +++++++++ .../dtos/delete-username.request.dto.ts | 7 ++ .../unit/core/authentication.entity.spec.ts | 2 + .../unit/core/delete-username.service.spec.ts | 54 ++++++++++ .../tests/unit/core/username.entity.spec.ts | 13 +++ ...ete-authentication.grpc.controller.spec.ts | 2 +- .../delete-username.grpc.controller.spec.ts | 99 +++++++++++++++++++ 12 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/modules/authentication/core/application/commands/delete-username/delete-username.command.ts create mode 100644 src/modules/authentication/core/application/commands/delete-username/delete-username.service.ts create mode 100644 src/modules/authentication/core/domain/events/username-deleted.domain-event.ts create mode 100644 src/modules/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts create mode 100644 src/modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto.ts create mode 100644 src/modules/authentication/tests/unit/core/delete-username.service.spec.ts create mode 100644 src/modules/authentication/tests/unit/interface/delete-username.grpc.controller.spec.ts diff --git a/src/modules/authentication/authentication.module.ts b/src/modules/authentication/authentication.module.ts index dc0c776..5084013 100644 --- a/src/modules/authentication/authentication.module.ts +++ b/src/modules/authentication/authentication.module.ts @@ -17,17 +17,21 @@ import { UsernameRepository } from './infrastructure/username.repository'; import { UsernameMapper } from './username.mapper'; import { AddUsernameGrpcController } from './interface/grpc-controllers/add-username.grpc.controller'; import { AddUsernameService } from './core/application/commands/add-username/add-username.service'; +import { DeleteUsernameGrpcController } from './interface/grpc-controllers/delete-username.grpc.controller'; +import { DeleteUsernameService } from './core/application/commands/delete-username/delete-username.service'; const grpcControllers = [ CreateAuthenticationGrpcController, DeleteAuthenticationGrpcController, AddUsernameGrpcController, + DeleteUsernameGrpcController, ]; const commandHandlers: Provider[] = [ CreateAuthenticationService, DeleteAuthenticationService, AddUsernameService, + DeleteUsernameService, ]; const mappers: Provider[] = [AuthenticationMapper, UsernameMapper]; diff --git a/src/modules/authentication/core/application/commands/delete-username/delete-username.command.ts b/src/modules/authentication/core/application/commands/delete-username/delete-username.command.ts new file mode 100644 index 0000000..241853f --- /dev/null +++ b/src/modules/authentication/core/application/commands/delete-username/delete-username.command.ts @@ -0,0 +1,10 @@ +import { Command, CommandProps } from '@mobicoop/ddd-library'; + +export class DeleteUsernameCommand extends Command { + readonly name: string; + + constructor(props: CommandProps) { + super(props); + this.name = props.name; + } +} diff --git a/src/modules/authentication/core/application/commands/delete-username/delete-username.service.ts b/src/modules/authentication/core/application/commands/delete-username/delete-username.service.ts new file mode 100644 index 0000000..5812739 --- /dev/null +++ b/src/modules/authentication/core/application/commands/delete-username/delete-username.service.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { DeleteUsernameCommand } from './delete-username.command'; +import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens'; +import { UsernameRepositoryPort } from '../../ports/username.repository.port'; +import { UsernameEntity } from '@modules/authentication/core/domain/username.entity'; + +@CommandHandler(DeleteUsernameCommand) +export class DeleteUsernameService implements ICommandHandler { + constructor( + @Inject(USERNAME_REPOSITORY) + private readonly usernameRepository: UsernameRepositoryPort, + ) {} + + async execute(command: DeleteUsernameCommand): Promise { + const username: UsernameEntity = await this.usernameRepository.findOneById( + command.name, + ); + username.delete(); + const isDeleted: boolean = await this.usernameRepository.delete(username); + return isDeleted; + } +} diff --git a/src/modules/authentication/core/domain/events/username-deleted.domain-event.ts b/src/modules/authentication/core/domain/events/username-deleted.domain-event.ts new file mode 100644 index 0000000..2dd539f --- /dev/null +++ b/src/modules/authentication/core/domain/events/username-deleted.domain-event.ts @@ -0,0 +1,7 @@ +import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library'; + +export class UsernameDeletedDomainEvent extends DomainEvent { + constructor(props: DomainEventProps) { + super(props); + } +} diff --git a/src/modules/authentication/core/domain/username.entity.ts b/src/modules/authentication/core/domain/username.entity.ts index d00806e..30d888b 100644 --- a/src/modules/authentication/core/domain/username.entity.ts +++ b/src/modules/authentication/core/domain/username.entity.ts @@ -1,6 +1,7 @@ import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library'; import { CreateUsernameProps, UsernameProps } from './username.types'; import { UsernameAddedDomainEvent } from './events/username-added.domain-event'; +import { UsernameDeletedDomainEvent } from './events/username-deleted.domain-event'; export class UsernameEntity extends AggregateRoot { protected readonly _id: AggregateID; @@ -23,6 +24,14 @@ export class UsernameEntity extends AggregateRoot { return username; }; + delete(): void { + this.addEvent( + new UsernameDeletedDomainEvent({ + 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/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts b/src/modules/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts new file mode 100644 index 0000000..389c78a --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts @@ -0,0 +1,45 @@ +import { + DatabaseErrorException, + NotFoundException, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { Controller, UsePipes } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { DeleteUsernameRequestDto } from './dtos/delete-username.request.dto'; +import { DeleteUsernameCommand } from '@modules/authentication/core/application/commands/delete-username/delete-username.command'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: true, + forbidUnknownValues: false, + }), +) +@Controller() +export class DeleteUsernameGrpcController { + constructor(private readonly commandBus: CommandBus) {} + + @GrpcMethod('AuthenticationService', 'DeleteUsername') + async delete(data: DeleteUsernameRequestDto): Promise { + try { + await this.commandBus.execute(new DeleteUsernameCommand(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/authentication/interface/grpc-controllers/dtos/delete-username.request.dto.ts b/src/modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto.ts new file mode 100644 index 0000000..bd3d95d --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteUsernameRequestDto { + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts b/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts index 5f3de63..9e34a2f 100644 --- a/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts +++ b/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts @@ -50,6 +50,8 @@ describe('Authentication entity create', () => { expect(authenticationEntity.getProps().usernames.length).toBe(2); expect(authenticationEntity.domainEvents.length).toBe(1); }); +}); +describe('Authentication entity delete', () => { it('should delete an authentication entity', async () => { const authenticationEntity: AuthenticationEntity = await AuthenticationEntity.create(createAuthenticationProps); diff --git a/src/modules/authentication/tests/unit/core/delete-username.service.spec.ts b/src/modules/authentication/tests/unit/core/delete-username.service.spec.ts new file mode 100644 index 0000000..57d22e7 --- /dev/null +++ b/src/modules/authentication/tests/unit/core/delete-username.service.spec.ts @@ -0,0 +1,54 @@ +import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens'; +import { DeleteUsernameCommand } from '@modules/authentication/core/application/commands/delete-username/delete-username.command'; +import { DeleteUsernameService } from '@modules/authentication/core/application/commands/delete-username/delete-username.service'; +import { DeleteUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto'; +import { Test, TestingModule } from '@nestjs/testing'; + +const deleteUsernameRequest: DeleteUsernameRequestDto = { + name: 'john.doe@email.com', +}; + +const mockUsernameEntity = { + delete: jest.fn(), +}; + +const mockUsernameRepository = { + findOneById: jest.fn().mockImplementation(() => mockUsernameEntity), + delete: jest.fn().mockImplementationOnce(() => true), +}; + +describe('Delete Username Service', () => { + let deleteUsernameService: DeleteUsernameService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: USERNAME_REPOSITORY, + useValue: mockUsernameRepository, + }, + DeleteUsernameService, + ], + }).compile(); + + deleteUsernameService = module.get( + DeleteUsernameService, + ); + }); + + it('should be defined', () => { + expect(deleteUsernameService).toBeDefined(); + }); + + describe('execution', () => { + const deleteUsernameCommand = new DeleteUsernameCommand( + deleteUsernameRequest, + ); + it('should delete a username', async () => { + const result: boolean = await deleteUsernameService.execute( + deleteUsernameCommand, + ); + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/src/modules/authentication/tests/unit/core/username.entity.spec.ts b/src/modules/authentication/tests/unit/core/username.entity.spec.ts index 2cb19c5..65b2560 100644 --- a/src/modules/authentication/tests/unit/core/username.entity.spec.ts +++ b/src/modules/authentication/tests/unit/core/username.entity.spec.ts @@ -1,3 +1,4 @@ +import { UsernameDeletedDomainEvent } from '@modules/authentication/core/domain/events/username-deleted.domain-event'; import { UsernameEntity } from '@modules/authentication/core/domain/username.entity'; import { CreateUsernameProps, @@ -19,3 +20,15 @@ describe('Username entity create', () => { expect(usernameEntity.domainEvents.length).toBe(1); }); }); +describe('Username entity delete', () => { + it('should delete a username entity', async () => { + const usernameEntity: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + usernameEntity.delete(); + expect(usernameEntity.domainEvents.length).toBe(2); + expect(usernameEntity.domainEvents[1]).toBeInstanceOf( + UsernameDeletedDomainEvent, + ); + }); +}); diff --git a/src/modules/authentication/tests/unit/interface/delete-authentication.grpc.controller.spec.ts b/src/modules/authentication/tests/unit/interface/delete-authentication.grpc.controller.spec.ts index 8f070aa..bd5ea9d 100644 --- a/src/modules/authentication/tests/unit/interface/delete-authentication.grpc.controller.spec.ts +++ b/src/modules/authentication/tests/unit/interface/delete-authentication.grpc.controller.spec.ts @@ -56,7 +56,7 @@ describe('Delete Authentication Grpc Controller', () => { expect(deleteAuthenticationGrpcController).toBeDefined(); }); - it('should create a new authentication', async () => { + it('should delete an authentication', async () => { jest.spyOn(mockCommandBus, 'execute'); await deleteAuthenticationGrpcController.delete( deleteAuthenticationRequest, diff --git a/src/modules/authentication/tests/unit/interface/delete-username.grpc.controller.spec.ts b/src/modules/authentication/tests/unit/interface/delete-username.grpc.controller.spec.ts new file mode 100644 index 0000000..c4e795c --- /dev/null +++ b/src/modules/authentication/tests/unit/interface/delete-username.grpc.controller.spec.ts @@ -0,0 +1,99 @@ +import { + DatabaseErrorException, + NotFoundException, +} from '@mobicoop/ddd-library'; +import { RpcExceptionCode } from '@mobicoop/ddd-library'; +import { DeleteUsernameGrpcController } from '@modules/authentication/interface/grpc-controllers/delete-username.grpc.controller'; +import { DeleteUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto'; +import { CommandBus } from '@nestjs/cqrs'; +import { RpcException } from '@nestjs/microservices'; +import { Test, TestingModule } from '@nestjs/testing'; + +const deleteUsernameRequest: DeleteUsernameRequestDto = { + name: 'john.doe@email.com', +}; + +const mockCommandBus = { + execute: jest + .fn() + .mockImplementationOnce(() => ({})) + .mockImplementationOnce(() => { + throw new NotFoundException(); + }) + .mockImplementationOnce(() => { + throw new DatabaseErrorException(); + }) + .mockImplementationOnce(() => { + throw new Error(); + }), +}; + +describe('Delete Username Grpc Controller', () => { + let deleteUsernameGrpcController: DeleteUsernameGrpcController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + DeleteUsernameGrpcController, + ], + }).compile(); + + deleteUsernameGrpcController = module.get( + DeleteUsernameGrpcController, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(deleteUsernameGrpcController).toBeDefined(); + }); + + it('should delete a username', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await deleteUsernameGrpcController.delete(deleteUsernameRequest); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a dedicated RpcException if username does not exist', async () => { + jest.spyOn(mockCommandBus, 'execute'); + expect.assertions(3); + try { + await deleteUsernameGrpcController.delete(deleteUsernameRequest); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a dedicated RpcException if a database error occurs', async () => { + jest.spyOn(mockCommandBus, 'execute'); + expect.assertions(3); + try { + await deleteUsernameGrpcController.delete(deleteUsernameRequest); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.INTERNAL); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a generic RpcException', async () => { + jest.spyOn(mockCommandBus, 'execute'); + expect.assertions(3); + try { + await deleteUsernameGrpcController.delete(deleteUsernameRequest); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); +});