diff --git a/src/generated/i18n.generated.ts b/src/generated/i18n.generated.ts index 3dba83d..e7b3f50 100644 --- a/src/generated/i18n.generated.ts +++ b/src/generated/i18n.generated.ts @@ -39,6 +39,21 @@ export type I18nTranslations = { 'student-already-enrolled': string; }; }; + 'password-reset': { + validations: { + OLD_PASSWORD_IS_DEFINED: string; + OLD_PASSWORD_IS_STRING: string; + OLD_PASSWORD_IS_NOT_EMPTY: string; + NEW_PASSWORD_IS_DEFINED: string; + NEW_PASSWORD_IS_STRING: string; + NEW_PASSWORD_IS_NOT_EMPTY: string; + }; + errors: { + 'password-reset-not-found': string; + 'incorrect-old-password': string; + 'user-not-found': string; + }; + }; user: { validations: { EMAIL_IS_EMAIL: string; diff --git a/src/i18n/en/password-reset.json b/src/i18n/en/password-reset.json new file mode 100644 index 0000000..8264287 --- /dev/null +++ b/src/i18n/en/password-reset.json @@ -0,0 +1,15 @@ +{ + "validations": { + "OLD_PASSWORD_IS_DEFINED": "Your old password must be defined.", + "OLD_PASSWORD_IS_STRING": "Your old password must be a valid text.", + "OLD_PASSWORD_IS_NOT_EMPTY": "Your old password must be valid.", + "NEW_PASSWORD_IS_DEFINED": "Your new password must be defined.", + "NEW_PASSWORD_IS_STRING": "Your new password must be a valid text.", + "NEW_PASSWORD_IS_NOT_EMPTY": "Your new password must be valid." + }, + "errors": { + "password-reset-not-found": "Your password reset request was not found.", + "incorrect-old-password": "Your old password must match the current password.", + "user-not-found": "The user was not found." + } +} diff --git a/src/i18n/pt-br/password-reset.json b/src/i18n/pt-br/password-reset.json new file mode 100644 index 0000000..13c62a8 --- /dev/null +++ b/src/i18n/pt-br/password-reset.json @@ -0,0 +1,15 @@ +{ + "validations": { + "OLD_PASSWORD_IS_DEFINED": "A antiga senha do usuário deve ser definido.", + "OLD_PASSWORD_IS_STRING": "A antiga senha do usuário deve ser um texto válido.", + "OLD_PASSWORD_IS_NOT_EMPTY": "A antiga senha do usuário não pode estar vazia.", + "NEW_PASSWORD_IS_DEFINED": "A nova senha do usuário deve ser definido.", + "NEW_PASSWORD_IS_STRING": "A nova senha do usuário deve ser um texto válido.", + "NEW_PASSWORD_IS_NOT_EMPTY": "A nova senha do usuário não pode estar vazia." + }, + "errors": { + "password-reset-not-found": "A solicitação de alteração de senha não foi encontrada.", + "incorrect-old-password": "A senha atual não coincidiu com o registro.", + "user-not-found": "O usuário não foi encontrado." + } +} diff --git a/src/i18n/pt-br/user.json b/src/i18n/pt-br/user.json index d273f9c..2f9dfbc 100644 --- a/src/i18n/pt-br/user.json +++ b/src/i18n/pt-br/user.json @@ -6,7 +6,7 @@ "NAME_IS_NOT_EMPTY": "O nome de usuário não pode estar vazio.", "PASSWORD_IS_DEFINED": "A senha do usuário deve ser definido.", "PASSWORD_IS_STRING": "A senha do usuário deve ser um texto válido.", - "PASSWORD_IS_NOT_EMPTY": "A senha do usuário não pode estar vazio." + "PASSWORD_IS_NOT_EMPTY": "A senha do usuário não pode estar vazia." }, "errors": { "duplicated-email": "O e-mail enviado já existe.", diff --git a/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts index c321e5e..9ebbda3 100644 --- a/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts @@ -1,12 +1,12 @@ +import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { PasswordEncryptionService } from '@modules/user/domain/services/password-encryption.service'; import { Injectable } from '@nestjs/common'; +import { UseCase } from '@shared/domain/usecases/usecase'; import { Either, left, right } from '@shared/helpers/either'; import { IncorrectOldPasswordError } from '../../errors/incorrect-old-password.error'; import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; -import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; -import { UseCase } from '@shared/domain/usecases/usecase'; export interface ExecutePasswordResetUseCaseInput { code: string; diff --git a/src/modules/password-reset/presenter/controllers/password-reset.controller.test.ts b/src/modules/password-reset/presenter/controllers/password-reset.controller.test.ts index ad231fc..ef32468 100644 --- a/src/modules/password-reset/presenter/controllers/password-reset.controller.test.ts +++ b/src/modules/password-reset/presenter/controllers/password-reset.controller.test.ts @@ -1,14 +1,20 @@ -import { RequestPasswordResetUseCase } from '@modules/password-reset/domain/usecases/request/request-password-reset.usecase'; -import { PasswordResetController } from './password-reset.controller'; import { faker } from '@faker-js/faker'; +import { IncorrectOldPasswordError } from '@modules/password-reset/domain/errors/incorrect-old-password.error'; +import { PasswordResetNotFoundError } from '@modules/password-reset/domain/errors/password-reset-not-found.error'; import { ExecutePasswordResetUseCase } from '@modules/password-reset/domain/usecases/execute/execute-password-reset.usecase'; +import { RequestPasswordResetUseCase } from '@modules/password-reset/domain/usecases/request/request-password-reset.usecase'; import { ValidatePasswordResetUseCase } from '@modules/password-reset/domain/usecases/validate/validate-password-reset.usecase'; -import { right, left } from '@shared/helpers/either'; -import { DeepMocked, createMock } from 'test/utils/create-mock'; -import { MockPasswordReset } from 'test/factories/password-reset-mock'; import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; -import { InternalServerErrorException } from '@nestjs/common'; -import { PasswordResetNotFoundError } from '@modules/password-reset/domain/errors/password-reset-not-found.error'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { left, right } from '@shared/helpers/either'; +import { MockPasswordReset } from 'test/factories/password-reset-mock'; +import { createI18nMock } from 'test/utils/create-i18n-mock'; +import { DeepMocked, createMock } from 'test/utils/create-mock'; +import { PasswordResetController } from './password-reset.controller'; describe('PasswordResetController', () => { let controller: PasswordResetController; @@ -92,4 +98,91 @@ describe('PasswordResetController', () => { expect(call).rejects.toThrow(InternalServerErrorException); }); }); + + describe('execute', () => { + it('should return nothing when sucessful', async () => { + executePasswordResetUseCase.exec.mockResolvedValueOnce(right({})); + + const result = await controller.execute( + 'A'.repeat(8), + { + oldPassword: '', + newPassword: '', + }, + createI18nMock(), + ); + + expect(result).toBeUndefined(); + }); + + it('should throw a not found exception if the password reset was not found', async () => { + executePasswordResetUseCase.exec.mockResolvedValueOnce( + left(new PasswordResetNotFoundError()), + ); + + const call = async () => + await controller.execute( + 'A'.repeat(8), + { + oldPassword: '', + newPassword: '', + }, + createI18nMock(), + ); + + expect(call).rejects.toThrow(NotFoundException); + }); + + it('should throw a not found exception if the user was not found', async () => { + executePasswordResetUseCase.exec.mockResolvedValueOnce( + left(new UserNotFoundError()), + ); + + const call = async () => + await controller.execute( + 'A'.repeat(8), + { + oldPassword: '', + newPassword: '', + }, + createI18nMock(), + ); + + expect(call).rejects.toThrow(NotFoundException); + }); + + it('should throw a bad request exception if the old password is incorrect', async () => { + executePasswordResetUseCase.exec.mockResolvedValueOnce( + left(new IncorrectOldPasswordError()), + ); + + const call = async () => + await controller.execute( + 'A'.repeat(8), + { + oldPassword: '', + newPassword: '', + }, + createI18nMock(), + ); + + expect(call).rejects.toThrow(BadRequestException); + }); + + it('should throw an internal server exception when receiving an unknown error', async () => { + executePasswordResetUseCase.exec.mockResolvedValueOnce(left(new Error())); + + const call = async () => + await controller.execute( + 'A'.repeat(8), + { + oldPassword: '', + newPassword: '', + }, + createI18nMock(), + ); + + expect(call).rejects.toThrow(InternalServerErrorException); + }); + }); }); diff --git a/src/modules/password-reset/presenter/controllers/password-reset.controller.ts b/src/modules/password-reset/presenter/controllers/password-reset.controller.ts index 508331f..0c5ba7c 100644 --- a/src/modules/password-reset/presenter/controllers/password-reset.controller.ts +++ b/src/modules/password-reset/presenter/controllers/password-reset.controller.ts @@ -1,17 +1,30 @@ +import { IncorrectOldPasswordError } from '@modules/password-reset/domain/errors/incorrect-old-password.error'; +import { PasswordResetNotFoundError } from '@modules/password-reset/domain/errors/password-reset-not-found.error'; import { ExecutePasswordResetUseCase } from '@modules/password-reset/domain/usecases/execute/execute-password-reset.usecase'; import { RequestPasswordResetUseCase } from '@modules/password-reset/domain/usecases/request/request-password-reset.usecase'; import { ValidatePasswordResetUseCase } from '@modules/password-reset/domain/usecases/validate/validate-password-reset.usecase'; import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; import { + BadRequestException, + Body, Controller, Get, InternalServerErrorException, + NotFoundException, Param, + Patch, Post, } from '@nestjs/common'; -import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiHeader, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { I18n, I18nContext } from 'nestjs-i18n'; +import { I18nTranslations } from 'src/generated/i18n.generated'; +import { ExecutePasswordResetPayload } from '../models/payloads/execute-password-reset.payload'; import { PasswordResetCodeValidationViewModel } from '../models/view-models/password-reset-code-validation.view-model'; -import { PasswordResetNotFoundError } from '@modules/password-reset/domain/errors/password-reset-not-found.error'; @ApiTags('Password Resets') @Controller('password-resets') @@ -43,6 +56,10 @@ export class PasswordResetController { @ApiOperation({ summary: 'Validates a password reset code' }) @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) + @ApiOkResponse({ + description: 'The code was validated', + type: PasswordResetCodeValidationViewModel, + }) @Get('validate/:code') public async validateCode( @Param('code') code: string, @@ -63,4 +80,41 @@ export class PasswordResetController { throw new InternalServerErrorException(); } + + @ApiOperation({ summary: 'Reset user password' }) + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) + @Patch('reset/:code') + public async execute( + @Param('code') code: string, + @Body() payload: ExecutePasswordResetPayload, + @I18n() i18n: I18nContext, + ): Promise { + const result = await this.executePasswordResetUseCase.exec({ + code, + newPassword: payload.newPassword, + oldPassword: payload.oldPassword, + }); + + if (result.isLeft()) { + if (result.value instanceof PasswordResetNotFoundError) { + throw new NotFoundException( + i18n.t('password-reset.errors.password-reset-not-found'), + ); + } + + if (result.value instanceof UserNotFoundError) { + throw new NotFoundException( + i18n.t('password-reset.errors.user-not-found'), + ); + } + + if (result.value instanceof IncorrectOldPasswordError) { + throw new BadRequestException( + i18n.t('password-reset.errors.incorrect-old-password'), + ); + } + + throw new InternalServerErrorException(); + } + } } diff --git a/src/modules/password-reset/presenter/models/payloads/execute-password-reset.payload.ts b/src/modules/password-reset/presenter/models/payloads/execute-password-reset.payload.ts new file mode 100644 index 0000000..8207a7b --- /dev/null +++ b/src/modules/password-reset/presenter/models/payloads/execute-password-reset.payload.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsString, IsNotEmpty } from 'class-validator'; +import { i18nValidationMessage } from 'nestjs-i18n'; +import { I18nTranslations } from 'src/generated/i18n.generated'; + +export class ExecutePasswordResetPayload { + @ApiProperty({ example: 'J0hn.Doe@123' }) + @IsDefined({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_DEFINED', + ), + }) + @IsString({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_STRING', + ), + }) + @IsNotEmpty({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_NOT_EMPTY', + ), + }) + oldPassword: string; + + @ApiProperty({ example: 'J0hn.Doe@123' }) + @IsDefined({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_DEFINED', + ), + }) + @IsString({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_STRING', + ), + }) + @IsNotEmpty({ + message: i18nValidationMessage( + 'user.validations.PASSWORD_IS_NOT_EMPTY', + ), + }) + newPassword: string; +}