diff --git a/.env.test b/.env.test index bfec28f..9a0151f 100644 --- a/.env.test +++ b/.env.test @@ -5,6 +5,10 @@ SERVICE_PORT=5002 # PRISMA DATABASE_URL="postgresql://mobicoop:mobicoop@localhost:5432/mobicoop-test?schema=auth" +# MESSAGE BROKER +MESSAGE_BROKER_URI=amqp://v3-broker:5672 +MESSAGE_BROKER_EXCHANGE=mobicoop + # OPA OPA_IMAGE=openpolicyagent/opa:0.54.0 OPA_URL=http://v3-auth-opa:8181/v1/data/ diff --git a/package.json b/package.json index 44a6478..4b0c818 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "npm run migrate:test && dotenv -e .env.test jest", "test:unit": "jest --testPathPattern 'tests/unit/' --verbose", "test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", - "test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose", + "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/'", "test:cov": "jest --testPathPattern 'tests/unit/' --coverage", "test:e2e": "jest --config ./test/jest-e2e.json", 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 index 5812739..de6439a 100644 --- 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 @@ -1,4 +1,4 @@ -import { Inject } from '@nestjs/common'; +import { Inject, UnauthorizedException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { DeleteUsernameCommand } from './delete-username.command'; import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens'; @@ -13,11 +13,20 @@ export class DeleteUsernameService implements ICommandHandler { ) {} async execute(command: DeleteUsernameCommand): Promise { - const username: UsernameEntity = await this.usernameRepository.findOneById( + const username: UsernameEntity = await this.usernameRepository.findByName( command.name, ); + const usernamesCount: number = await this.usernameRepository.countUsernames( + username.getProps().userId, + ); + if (usernamesCount <= 1) + throw new UnauthorizedException( + 'Authentication must have at least one username', + ); username.delete(); - const isDeleted: boolean = await this.usernameRepository.delete(username); + const isDeleted: boolean = await this.usernameRepository.deleteUsername( + username, + ); return isDeleted; } } diff --git a/src/modules/authentication/core/application/ports/username.repository.port.ts b/src/modules/authentication/core/application/ports/username.repository.port.ts index 9188472..4ce4084 100644 --- a/src/modules/authentication/core/application/ports/username.repository.port.ts +++ b/src/modules/authentication/core/application/ports/username.repository.port.ts @@ -6,4 +6,6 @@ export type UsernameRepositoryPort = RepositoryPort & { findByType(userId: string, type: Type): Promise; findByName(name: string): Promise; updateUsername(oldName: string, entity: UsernameEntity): Promise; + deleteUsername(entity: UsernameEntity): Promise; + countUsernames(userId: string): Promise; }; diff --git a/src/modules/authentication/infrastructure/username.repository.ts b/src/modules/authentication/infrastructure/username.repository.ts index 095962a..6b06d0d 100644 --- a/src/modules/authentication/infrastructure/username.repository.ts +++ b/src/modules/authentication/infrastructure/username.repository.ts @@ -71,11 +71,13 @@ export class UsernameRepository updateUsername = async ( oldName: string, entity: UsernameEntity, - ): Promise => - this.updateWhere( - { - username: oldName, - }, - entity, - ); + ): Promise => this.update(oldName, entity, 'username'); + + deleteUsername = async (entity: UsernameEntity): Promise => + this.delete(entity, 'username'); + + countUsernames = async (userId: string): Promise => + this.count({ + authUuid: userId, + }); } 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 index 389c78a..6010a62 100644 --- a/src/modules/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts +++ b/src/modules/authentication/interface/grpc-controllers/delete-username.grpc.controller.ts @@ -4,7 +4,7 @@ import { RpcExceptionCode, RpcValidationPipe, } from '@mobicoop/ddd-library'; -import { Controller, UsePipes } from '@nestjs/common'; +import { Controller, UnauthorizedException, UsePipes } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { DeleteUsernameRequestDto } from './dtos/delete-username.request.dto'; @@ -25,12 +25,16 @@ export class DeleteUsernameGrpcController { try { await this.commandBus.execute(new DeleteUsernameCommand(data)); } catch (error: any) { + if (error instanceof UnauthorizedException) + throw new RpcException({ + code: RpcExceptionCode.PERMISSION_DENIED, + message: error.message, + }); if (error instanceof NotFoundException) throw new RpcException({ code: RpcExceptionCode.NOT_FOUND, message: error.message, }); - if (error instanceof DatabaseErrorException) throw new RpcException({ code: RpcExceptionCode.INTERNAL, diff --git a/src/modules/authentication/tests/integration/authentication.repository.spec.ts b/src/modules/authentication/tests/integration/authentication.repository.spec.ts new file mode 100644 index 0000000..e435adf --- /dev/null +++ b/src/modules/authentication/tests/integration/authentication.repository.spec.ts @@ -0,0 +1,231 @@ +import { TestingModule, Test } from '@nestjs/testing'; +import { v4 } from 'uuid'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '@modules/authentication/infrastructure/prisma.service'; +import { AuthenticationRepository } from '@modules/authentication/infrastructure/authentication.repository'; +import { AuthenticationMapper } from '@modules/authentication/authentication.mapper'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { MESSAGE_PUBLISHER } from '@src/app.di-tokens'; +import { + DatabaseErrorException, + NotFoundException, + UniqueConstraintException, +} from '@mobicoop/ddd-library'; +import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity'; +import { Type } from '@modules/authentication/core/domain/username.types'; +import { CreateAuthenticationProps } from '@modules/authentication/core/domain/authentication.types'; + +const uuid = '165192d4-398a-4469-a16b-98c02cc6f531'; + +const createAuthenticationProps: CreateAuthenticationProps = { + userId: uuid, + password: 'somePassword', + usernames: [ + { + type: Type.EMAIL, + name: 'john.doe@email.com', + }, + ], +}; + +const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), +}; + +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +describe('AuthenticationRepository', () => { + let prismaService: PrismaService; + let authenticationRepository: AuthenticationRepository; + + // const createAuthentications = async (nbToCreate = 10) => { + // for (let i = 0; i < nbToCreate; i++) { + // await prismaService.auth.create({ + // data: { + // uuid: v4(), + // password: bcrypt.hashSync(`password-${i}`, 10), + // }, + // }); + // } + // }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [ + AuthenticationRepository, + PrismaService, + AuthenticationMapper, + { + provide: MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + ], + }) + // disable logging + .setLogger(mockLogger) + .compile(); + + prismaService = module.get(PrismaService); + authenticationRepository = module.get( + AuthenticationRepository, + ); + }); + + afterAll(async () => { + await prismaService.$disconnect(); + }); + + beforeEach(async () => { + await prismaService.auth.deleteMany(); + }); + + // describe('findAll', () => { + // it('should return an empty data array', async () => { + // const res = await authenticationRepository.findAll(); + // expect(res).toEqual({ + // data: [], + // total: 0, + // }); + // }); + + // it('should return a data array with 8 auths', async () => { + // await createAuthentications(8); + // const auths = await authenticationRepository.findAll(); + // expect(auths.data.length).toBe(8); + // expect(auths.total).toBe(8); + // }); + + // it('should return a data array limited to 10 authentications', async () => { + // await createAuthentications(20); + // const auths = await authenticationRepository.findAll(); + // expect(auths.data.length).toBe(10); + // expect(auths.total).toBe(20); + // }); + // }); + + describe('findOneById', () => { + it('should return an authentication', async () => { + const authToFind = await prismaService.auth.create({ + data: { + uuid: v4(), + password: bcrypt.hashSync(`password`, 10), + }, + }); + + const auth = await authenticationRepository.findOneById(authToFind.uuid); + expect(auth.id).toBe(authToFind.uuid); + }); + + it('should throw an exception if record is not found', async () => { + await expect( + authenticationRepository.findOneById( + '544572be-11fb-4244-8235-587221fc9104', + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('create', () => { + it('should create an authentication', async () => { + const beforeCount = await prismaService.auth.count(); + + const authenticationToCreate: AuthenticationEntity = + await AuthenticationEntity.create(createAuthenticationProps); + await authenticationRepository.insert(authenticationToCreate); + + const afterCount = await prismaService.auth.count(); + + expect(afterCount - beforeCount).toBe(1); + }); + + it('should throw a UniqueConstraintException if authentication already exists', async () => { + await prismaService.auth.create({ + data: { + uuid: uuid, + password: bcrypt.hashSync(`password`, 10), + }, + }); + + const authenticationToCreate: AuthenticationEntity = + await AuthenticationEntity.create(createAuthenticationProps); + await expect( + authenticationRepository.insert(authenticationToCreate), + ).rejects.toBeInstanceOf(UniqueConstraintException); + }); + }); + + describe('update', () => { + it('should update an authentication', async () => { + const authenticationToUpdate = await prismaService.auth.create({ + data: { + uuid: v4(), + password: bcrypt.hashSync(`password`, 10), + }, + }); + + const toUpdate: AuthenticationEntity = await AuthenticationEntity.create( + createAuthenticationProps, + ); + await authenticationRepository.update( + authenticationToUpdate.uuid, + toUpdate, + ); + + const updatedAuthentication = await prismaService.auth.findUnique({ + where: { + uuid: toUpdate.id, + }, + }); + + expect(updatedAuthentication.uuid).toBe(uuid); + expect(authenticationToUpdate.updatedAt.getTime()).toBeLessThan( + updatedAuthentication.updatedAt.getTime(), + ); + }); + + it('should throw a DatabaseException if id is unknown', async () => { + const toUpdate: AuthenticationEntity = await AuthenticationEntity.create( + createAuthenticationProps, + ); + + await expect( + authenticationRepository.update( + '544572be-11fb-4244-8235-587221fc9104', + toUpdate, + ), + ).rejects.toBeInstanceOf(DatabaseErrorException); + }); + }); + + describe('delete', () => { + it('should delete an authentication', async () => { + await prismaService.auth.create({ + data: { + uuid, + password: bcrypt.hashSync(`password`, 10), + }, + }); + const toDelete: AuthenticationEntity = await AuthenticationEntity.create( + createAuthenticationProps, + ); + await authenticationRepository.delete(toDelete); + + const count = await prismaService.auth.count(); + expect(count).toBe(0); + }); + + it('should throw a DatabaseException if authentication does not exist', async () => { + const toDelete: AuthenticationEntity = await AuthenticationEntity.create( + createAuthenticationProps, + ); + await expect( + authenticationRepository.delete(toDelete), + ).rejects.toBeInstanceOf(DatabaseErrorException); + }); + }); +}); diff --git a/src/modules/authentication/tests/integration/username.repository.spec.ts b/src/modules/authentication/tests/integration/username.repository.spec.ts new file mode 100644 index 0000000..55ea75a --- /dev/null +++ b/src/modules/authentication/tests/integration/username.repository.spec.ts @@ -0,0 +1,202 @@ +import { TestingModule, Test } from '@nestjs/testing'; +import { PrismaService } from '@modules/authentication/infrastructure/prisma.service'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { MESSAGE_PUBLISHER } from '@src/app.di-tokens'; +import { + DatabaseErrorException, + NotFoundException, + UniqueConstraintException, +} from '@mobicoop/ddd-library'; +import { + CreateUsernameProps, + Type, +} from '@modules/authentication/core/domain/username.types'; +import { UsernameRepository } from '@modules/authentication/infrastructure/username.repository'; +import { UsernameMapper } from '@modules/authentication/username.mapper'; +import * as bcrypt from 'bcrypt'; +import { UsernameEntity } from '@modules/authentication/core/domain/username.entity'; + +const authUuid = 'a4524d22-7be3-46cd-8444-3145470476dc'; + +const createUsernameProps: CreateUsernameProps = { + userId: authUuid, + type: Type.EMAIL, + name: 'john.doe@email.com', +}; + +const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), +}; + +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +describe('UsernameRepository', () => { + let prismaService: PrismaService; + let usernameRepository: UsernameRepository; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [ + UsernameRepository, + PrismaService, + UsernameMapper, + { + provide: MESSAGE_PUBLISHER, + useValue: mockMessagePublisher, + }, + ], + }) + // disable logging + .setLogger(mockLogger) + .compile(); + + prismaService = module.get(PrismaService); + usernameRepository = module.get(UsernameRepository); + }); + + afterAll(async () => { + await prismaService.$disconnect(); + }); + + beforeEach(async () => { + await prismaService.username.deleteMany(); + await prismaService.auth.deleteMany(); + await prismaService.auth.create({ + data: { + uuid: authUuid, + password: bcrypt.hashSync(`password`, 10), + }, + }); + }); + + describe('findOne', () => { + it('should return a Username', async () => { + await prismaService.username.create({ + data: { + authUuid, + username: 'john.doe@email.com', + type: Type.EMAIL, + }, + }); + + const username = await usernameRepository.findOne({ + username: 'john.doe@email.com', + }); + expect(username.id).toBe('john.doe@email.com'); + }); + + it('should throw an exception if record is not found', async () => { + await expect( + usernameRepository.findOne({ + username: 'jane.doe@email.com', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('create', () => { + it('should create a username', async () => { + const beforeCount = await prismaService.username.count(); + + const usernameToCreate: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + await usernameRepository.insert(usernameToCreate); + + const afterCount = await prismaService.username.count(); + + expect(afterCount - beforeCount).toBe(1); + }); + + it('should throw a UniqueConstraintException if username already exists', async () => { + await prismaService.username.create({ + data: { + authUuid, + type: Type.EMAIL, + username: 'john.doe@email.com', + }, + }); + + const usernameToCreate: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + await expect( + usernameRepository.insert(usernameToCreate), + ).rejects.toBeInstanceOf(UniqueConstraintException); + }); + }); + + describe('update username', () => { + it('should update the name of a username', async () => { + const usernameToUpdate = await prismaService.username.create({ + data: { + authUuid, + type: Type.EMAIL, + username: 'johnny.doe@email.com', + }, + }); + + const toUpdate: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + await usernameRepository.updateUsername( + usernameToUpdate.username, + toUpdate, + ); + + const updatedUsername = await prismaService.username.findUnique({ + where: { + username: toUpdate.id, + }, + }); + + expect(updatedUsername.username).toBe('john.doe@email.com'); + expect(usernameToUpdate.updatedAt.getTime()).toBeLessThan( + updatedUsername.updatedAt.getTime(), + ); + }); + + it('should throw a DatabaseException if id is unknown', async () => { + const toUpdate: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + + await expect( + usernameRepository.updateUsername('jane.doe@email.com', toUpdate), + ).rejects.toBeInstanceOf(DatabaseErrorException); + }); + }); + + describe('delete', () => { + it('should delete a username', async () => { + await prismaService.username.create({ + data: { + authUuid, + type: Type.EMAIL, + username: 'john.doe@email.com', + }, + }); + const toDelete: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + await usernameRepository.delete(toDelete); + + const count = await prismaService.username.count(); + expect(count).toBe(0); + }); + + it('should throw a DatabaseException if username does not exist', async () => { + const toDelete: UsernameEntity = await UsernameEntity.create( + createUsernameProps, + ); + await expect(usernameRepository.delete(toDelete)).rejects.toBeInstanceOf( + DatabaseErrorException, + ); + }); + }); +}); 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 index 57d22e7..12a5cec 100644 --- a/src/modules/authentication/tests/unit/core/delete-username.service.spec.ts +++ b/src/modules/authentication/tests/unit/core/delete-username.service.spec.ts @@ -2,6 +2,7 @@ import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-t 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 { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; const deleteUsernameRequest: DeleteUsernameRequestDto = { @@ -10,11 +11,18 @@ const deleteUsernameRequest: DeleteUsernameRequestDto = { const mockUsernameEntity = { delete: jest.fn(), + getProps: jest.fn().mockImplementation(() => ({ + userId: 'b1643072-757f-4e3b-87f2-63af3684afb8', + })), }; const mockUsernameRepository = { - findOneById: jest.fn().mockImplementation(() => mockUsernameEntity), - delete: jest.fn().mockImplementationOnce(() => true), + findByName: jest.fn().mockImplementation(() => mockUsernameEntity), + countUsernames: jest + .fn() + .mockImplementationOnce(() => 2) + .mockImplementationOnce(() => 1), + deleteUsername: jest.fn().mockImplementationOnce(() => true), }; describe('Delete Username Service', () => { @@ -50,5 +58,10 @@ describe('Delete Username Service', () => { ); expect(result).toBeTruthy(); }); + it('should throw an exception when trying to delete the last username', async () => { + await expect( + deleteUsernameService.execute(deleteUsernameCommand), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); }); }); diff --git a/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts b/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts index 7a58e72..88fe27b 100644 --- a/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts +++ b/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts @@ -22,8 +22,9 @@ const mockPrismaService = { }; return record; }), - update: jest.fn().mockImplementation(), + delete: jest.fn().mockImplementation(), + count: jest.fn().mockImplementation(), }, }; @@ -67,6 +68,7 @@ describe('Username repository', () => { it('should be defined', () => { expect(usernameRepository).toBeDefined(); }); + it('should find a username by its userId and Type', async () => { jest.spyOn(usernameRepository, 'findOne'); const username: UsernameEntity = await usernameRepository.findByType( @@ -80,6 +82,7 @@ describe('Username repository', () => { }); expect(username.getProps().name).toBe('john.doe@email.com'); }); + it('should find a username by its name', async () => { jest.spyOn(usernameRepository, 'findOne'); const username: UsernameEntity = await usernameRepository.findByName( @@ -91,8 +94,9 @@ describe('Username repository', () => { }); expect(username.getProps().name).toBe('john.doe@email.com'); }); + it('should update a username', async () => { - jest.spyOn(usernameRepository, 'updateWhere'); + jest.spyOn(usernameRepository, 'update'); const usernameToUpdate: UsernameEntity = await UsernameEntity.create({ userId: '165192d4-398a-4469-a16b-98c02cc6f531', type: Type.EMAIL, @@ -102,12 +106,35 @@ describe('Username repository', () => { 'john.doe@email.com', usernameToUpdate, ); - expect(usernameRepository.updateWhere).toHaveBeenCalledTimes(1); - expect(usernameRepository.updateWhere).toHaveBeenCalledWith( - { - username: 'john.doe@email.com', - }, + expect(usernameRepository.update).toHaveBeenCalledTimes(1); + expect(usernameRepository.update).toHaveBeenCalledWith( + 'john.doe@email.com', usernameToUpdate, + 'username', ); }); + + it('should delete a username', async () => { + jest.spyOn(usernameRepository, 'delete'); + const usernameToDelete: UsernameEntity = await UsernameEntity.create({ + userId: '165192d4-398a-4469-a16b-98c02cc6f531', + type: Type.EMAIL, + name: 'john.doe@new-email.com', + }); + await usernameRepository.deleteUsername(usernameToDelete); + expect(usernameRepository.delete).toHaveBeenCalledTimes(1); + expect(usernameRepository.delete).toHaveBeenCalledWith( + usernameToDelete, + 'username', + ); + }); + + it('should count usernames for a given userId', async () => { + jest.spyOn(usernameRepository, 'count'); + await usernameRepository.countUsernames('john.doe@email.com'); + expect(usernameRepository.count).toHaveBeenCalledTimes(1); + expect(usernameRepository.count).toHaveBeenCalledWith({ + authUuid: 'john.doe@email.com', + }); + }); }); 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 index c4e795c..3bb2ca8 100644 --- 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 @@ -5,6 +5,7 @@ import { 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 { UnauthorizedException } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { RpcException } from '@nestjs/microservices'; import { Test, TestingModule } from '@nestjs/testing'; @@ -17,6 +18,9 @@ const mockCommandBus = { execute: jest .fn() .mockImplementationOnce(() => ({})) + .mockImplementationOnce(() => { + throw new UnauthorizedException(); + }) .mockImplementationOnce(() => { throw new NotFoundException(); }) @@ -61,6 +65,18 @@ describe('Delete Username Grpc Controller', () => { expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); }); + it('should throw a dedicated RpcException when trying to delete the last username', 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.PERMISSION_DENIED); + } + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + it('should throw a dedicated RpcException if username does not exist', async () => { jest.spyOn(mockCommandBus, 'execute'); expect.assertions(3);