diff --git a/src/app.module.ts b/src/app.module.ts index adaf99c..96860ca 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,16 @@ import { uri: configService.get('MESSAGE_BROKER_URI'), exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), name: 'auth', + handlers: { + userUpdated: { + routingKey: 'user.updated', + queue: 'auth-user-updated', + }, + userDeleted: { + routingKey: 'user.deleted', + queue: 'auth-user-deleted', + }, + }, }), }), // AutomapperModule.forRoot({ strategyInitializer: classes() }), diff --git a/src/modules/authentication/authentication.mapper.ts b/src/modules/authentication/authentication.mapper.ts index 7566f68..25c46bd 100644 --- a/src/modules/authentication/authentication.mapper.ts +++ b/src/modules/authentication/authentication.mapper.ts @@ -39,8 +39,6 @@ export class AuthenticationMapper })), } : undefined, - createdAt: copy.createdAt, - updatedAt: copy.updatedAt, }; return record; }; diff --git a/src/modules/authentication/authentication.module.ts b/src/modules/authentication/authentication.module.ts index 539f55b..71b9b4c 100644 --- a/src/modules/authentication/authentication.module.ts +++ b/src/modules/authentication/authentication.module.ts @@ -25,6 +25,7 @@ import { UpdatePasswordGrpcController } from './interface/grpc-controllers/updat import { UpdatePasswordService } from './core/application/commands/update-password/update-password.service'; import { ValidateAuthenticationGrpcController } from './interface/grpc-controllers/validate-authentication.grpc.controller'; import { ValidateAuthenticationQueryHandler } from './core/application/queries/validate-authentication/validate-authentication.query-handler'; +import { UserUpdatedMessageHandler } from './interface/message-handlers/user-updated.message-handler'; const grpcControllers = [ CreateAuthenticationGrpcController, @@ -36,6 +37,8 @@ const grpcControllers = [ ValidateAuthenticationGrpcController, ]; +const messageHandlers = [UserUpdatedMessageHandler]; + const commandHandlers: Provider[] = [ CreateAuthenticationService, DeleteAuthenticationService, @@ -73,6 +76,7 @@ const orms: Provider[] = [PrismaService]; imports: [CqrsModule], controllers: [...grpcControllers], providers: [ + ...messageHandlers, ...commandHandlers, ...queryHandlers, ...mappers, diff --git a/src/modules/authentication/infrastructure/authentication.repository.ts b/src/modules/authentication/infrastructure/authentication.repository.ts index f176613..fc150a1 100644 --- a/src/modules/authentication/infrastructure/authentication.repository.ts +++ b/src/modules/authentication/infrastructure/authentication.repository.ts @@ -11,15 +11,15 @@ import { PrismaService } from './prisma.service'; import { AuthenticationMapper } from '../authentication.mapper'; import { MESSAGE_PUBLISHER } from '@src/app.di-tokens'; -export type AuthenticationBaseModel = { +type AuthenticationBaseModel = { uuid: string; password: string; - createdAt: Date; - updatedAt: Date; }; export type AuthenticationReadModel = AuthenticationBaseModel & { usernames: UsernameModel[]; + createdAt: Date; + updatedAt: Date; }; export type AuthenticationWriteModel = AuthenticationBaseModel & { diff --git a/src/modules/authentication/infrastructure/username.repository.ts b/src/modules/authentication/infrastructure/username.repository.ts index 9342bdd..095962a 100644 --- a/src/modules/authentication/infrastructure/username.repository.ts +++ b/src/modules/authentication/infrastructure/username.repository.ts @@ -12,20 +12,29 @@ import { UsernameRepositoryPort } from '../core/application/ports/username.repos import { UsernameMapper } from '../username.mapper'; import { Type } from '../core/domain/username.types'; -export type UsernameModel = { +type UsernameBaseModel = { username: string; authUuid: string; type: string; - createdAt: Date; - updatedAt: Date; }; +export type UsernameReadModel = UsernameBaseModel & { + createdAt?: Date; + updatedAt?: Date; +}; + +export type UsernameWriteModel = UsernameBaseModel; + /** * Repository is used for retrieving/saving domain entities * */ @Injectable() export class UsernameRepository - extends PrismaRepositoryBase + extends PrismaRepositoryBase< + UsernameEntity, + UsernameReadModel, + UsernameWriteModel + > implements UsernameRepositoryPort { constructor( @@ -42,7 +51,7 @@ export class UsernameRepository eventEmitter, new LoggerBase({ logger: new Logger(UsernameRepository.name), - domain: 'auth', + domain: 'auth.username', messagePublisher, }), ); diff --git a/src/modules/authentication/interface/message-handlers/user-updated.message-handler.ts b/src/modules/authentication/interface/message-handlers/user-updated.message-handler.ts new file mode 100644 index 0000000..55a280b --- /dev/null +++ b/src/modules/authentication/interface/message-handlers/user-updated.message-handler.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { RabbitSubscribe } from '@mobicoop/message-broker-module'; +import { Type } from '@modules/authentication/core/domain/username.types'; +import { UpdateUsernameCommand } from '@modules/authentication/core/application/commands/update-username/update-username.command'; + +@Injectable() +export class UserUpdatedMessageHandler { + constructor(private readonly commandBus: CommandBus) {} + + @RabbitSubscribe({ + name: 'userUpdated', + }) + public async userUpdated(message: string) { + const updatedUser = JSON.parse(message); + try { + if (!updatedUser.hasOwnProperty('userId')) throw new Error(); + if (updatedUser.hasOwnProperty('email') && updatedUser.email) { + await this.commandBus.execute( + new UpdateUsernameCommand({ + userId: updatedUser.userId, + username: { + name: updatedUser.email, + type: Type.EMAIL, + }, + }), + ); + } + if (updatedUser.hasOwnProperty('phone') && updatedUser.phone) { + await this.commandBus.execute( + new UpdateUsernameCommand({ + userId: updatedUser.userId, + username: { + name: updatedUser.phone, + type: Type.PHONE, + }, + }), + ); + } + } catch (e: any) {} + } +} 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 a9b08b9..7a58e72 100644 --- a/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts +++ b/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts @@ -3,7 +3,7 @@ import { PrismaService } from '@modules/authentication/infrastructure/prisma.ser import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; import { - UsernameModel, + UsernameReadModel, UsernameRepository, } from '@modules/authentication/infrastructure/username.repository'; import { Type } from '@modules/authentication/core/domain/username.types'; @@ -13,7 +13,7 @@ const mockPrismaService = { username: { findFirst: jest.fn().mockImplementation(async () => { const now = new Date('2023-06-21 06:00:00'); - const record: UsernameModel = { + const record: UsernameReadModel = { authUuid: '330bd6de-1eb8-450b-8674-0e3c9209f048', type: Type.EMAIL, username: 'john.doe@email.com', diff --git a/src/modules/authentication/tests/unit/interface/user-updated.message-handler.spec.ts b/src/modules/authentication/tests/unit/interface/user-updated.message-handler.spec.ts new file mode 100644 index 0000000..5f490e7 --- /dev/null +++ b/src/modules/authentication/tests/unit/interface/user-updated.message-handler.spec.ts @@ -0,0 +1,100 @@ +import { UserUpdatedMessageHandler } from '@modules/authentication/interface/message-handlers/user-updated.message-handler'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +const userEmailUpdatedMessage = + '{"userId":"2436d413-b7c7-429e-9792-b78edc17b3ca","email":"new-john.doe@email.com"}'; + +const userPhoneUpdatedMessage = + '{"userId":"2436d413-b7c7-429e-9792-b78edc17b3ca","phone":"+33611224455"}'; + +const userBirthDateUpdatedMessage = + '{"userId":"2436d413-b7c7-429e-9792-b78edc17b3ca","birthDate":"1976-10-23"}'; + +const userIdNotProvidedUpdatedMessage = + '{"user":"2436d413-b7c7-429e-9792-b78edc17b300","email":"new-john.doe@email.com"}'; + +const mockCommandBus = { + execute: jest + .fn() + .mockImplementationOnce(() => 'new-john.doe@email.com') + .mockImplementationOnce(() => '+33611224455'), +}; + +describe('User Updated Message Handler', () => { + let userUpdatedMessageHandler: UserUpdatedMessageHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + UserUpdatedMessageHandler, + ], + }).compile(); + + userUpdatedMessageHandler = module.get( + UserUpdatedMessageHandler, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(userUpdatedMessageHandler).toBeDefined(); + }); + + it('should update an email username', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await userUpdatedMessageHandler.userUpdated(userEmailUpdatedMessage); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should update a phone username', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await userUpdatedMessageHandler.userUpdated(userPhoneUpdatedMessage); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should not update a username if message does not contain email nor phone', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await userUpdatedMessageHandler.userUpdated(userBirthDateUpdatedMessage); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(0); + }); + + it('should not update a username if userId is unknown', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await userUpdatedMessageHandler.userUpdated( + userIdNotProvidedUpdatedMessage, + ); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(0); + }); + + // it('should throw a dedicated RpcException if username already exists', async () => { + // jest.spyOn(mockCommandBus, 'execute'); + // expect.assertions(3); + // try { + // await updateUsernameGrpcController.updateUsername(updateUsernameRequest); + // } catch (e: any) { + // expect(e).toBeInstanceOf(RpcException); + // expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS); + // } + // expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + // }); + + // it('should throw a generic RpcException', async () => { + // jest.spyOn(mockCommandBus, 'execute'); + // expect.assertions(3); + // try { + // await updateUsernameGrpcController.updateUsername(updateUsernameRequest); + // } catch (e: any) { + // expect(e).toBeInstanceOf(RpcException); + // expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN); + // } + // expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + // }); +}); diff --git a/src/modules/authentication/tests/unit/username.mapper.spec.ts b/src/modules/authentication/tests/unit/username.mapper.spec.ts index c7250e3..d5ff644 100644 --- a/src/modules/authentication/tests/unit/username.mapper.spec.ts +++ b/src/modules/authentication/tests/unit/username.mapper.spec.ts @@ -1,6 +1,9 @@ import { UsernameEntity } from '@modules/authentication/core/domain/username.entity'; import { Type } from '@modules/authentication/core/domain/username.types'; -import { UsernameModel } from '@modules/authentication/infrastructure/username.repository'; +import { + UsernameReadModel, + UsernameWriteModel, +} from '@modules/authentication/infrastructure/username.repository'; import { UsernameResponseDto } from '@modules/authentication/interface/dtos/username.response.dto'; import { UsernameMapper } from '@modules/authentication/username.mapper'; import { Test } from '@nestjs/testing'; @@ -16,7 +19,7 @@ const usernameEntity: UsernameEntity = new UsernameEntity({ createdAt: now, updatedAt: now, }); -const usernameModel: UsernameModel = { +const usernameReadModel: UsernameReadModel = { authUuid: '7ca2490b-d04d-4ac5-8d6c-5c416fab922e', username: 'john.doe@email.com', type: Type.EMAIL, @@ -39,13 +42,14 @@ describe('Username Mapper', () => { }); it('should map domain entity to persistence data', async () => { - const mapped: UsernameModel = usernameMapper.toPersistence(usernameEntity); + const mapped: UsernameWriteModel = + usernameMapper.toPersistence(usernameEntity); expect(mapped.username).toBe('john.doe@email.com'); expect(mapped.type).toBe(Type.EMAIL); }); it('should map persisted data to domain entity', async () => { - const mapped: UsernameEntity = usernameMapper.toDomain(usernameModel); + const mapped: UsernameEntity = usernameMapper.toDomain(usernameReadModel); expect(mapped.getProps().name).toBe('john.doe@email.com'); }); diff --git a/src/modules/authentication/username.mapper.ts b/src/modules/authentication/username.mapper.ts index 33d399b..7d22fc8 100644 --- a/src/modules/authentication/username.mapper.ts +++ b/src/modules/authentication/username.mapper.ts @@ -2,7 +2,10 @@ import { Mapper } from '@mobicoop/ddd-library'; import { Injectable } from '@nestjs/common'; import { Type } from './core/domain/username.types'; import { UsernameEntity } from './core/domain/username.entity'; -import { UsernameModel } from './infrastructure/username.repository'; +import { + UsernameReadModel, + UsernameWriteModel, +} from './infrastructure/username.repository'; import { UsernameResponseDto } from './interface/dtos/username.response.dto'; /** @@ -15,21 +18,24 @@ import { UsernameResponseDto } from './interface/dtos/username.response.dto'; @Injectable() export class UsernameMapper implements - Mapper + Mapper< + UsernameEntity, + UsernameReadModel, + UsernameWriteModel, + UsernameResponseDto + > { - toPersistence = (entity: UsernameEntity): UsernameModel => { + toPersistence = (entity: UsernameEntity): UsernameWriteModel => { const copy = entity.getProps(); - const record: UsernameModel = { + const record: UsernameWriteModel = { authUuid: copy.userId, username: copy.name, type: copy.type, - createdAt: copy.createdAt, - updatedAt: copy.updatedAt, }; return record; }; - toDomain = (record: UsernameModel): UsernameEntity => { + toDomain = (record: UsernameReadModel): UsernameEntity => { const entity = new UsernameEntity({ id: record.username, createdAt: new Date(record.createdAt),