From 46ad3426251179862cbb617bb95017ec4b14bbce Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Tue, 5 Dec 2023 16:24:44 -0300 Subject: [PATCH 1/6] feat(password-reset): adding password-reset module along with two of its usecases --- .../domain/entities/password-reset.entity.ts | 85 +++++++++++++++ .../errors/incorrect-old-password.error.ts | 9 ++ .../incorrect-password-reset-code.error.ts | 12 +++ .../errors/password-reset-not-found.error.ts | 9 ++ .../repositories/password-reset.repository.ts | 8 ++ .../execute-password-reset.usecase.test.ts | 71 +++++++++++++ .../execute/execute-password-reset.usecase.ts | 61 +++++++++++ .../request-password-reset.usecase.test.ts | 41 +++++++ .../request/request-password-reset.usecase.ts | 46 ++++++++ .../validate-password-reset.usecase.test.ts | 100 ++++++++++++++++++ .../validate-password-reset.usecase.ts | 51 +++++++++ .../password-reset-database.module.ts | 17 +++ .../mappers/password-reset-typeorm.mapper.ts | 40 +++++++ .../password-reset-typeorm.repository.ts | 50 +++++++++ .../typeorm/schemas/password-reset.schema.ts | 25 +++++ .../password-reset/password-reset.module.ts | 6 ++ .../controllers/password-reset.controller.ts | 8 ++ src/shared/helpers/either.ts | 8 ++ test/factories/password-reset-mock.ts | 41 +++++++ .../in-memory-password-reset-repository.ts | 36 +++++++ tsconfig.json | 10 +- 21 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 src/modules/password-reset/domain/entities/password-reset.entity.ts create mode 100644 src/modules/password-reset/domain/errors/incorrect-old-password.error.ts create mode 100644 src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts create mode 100644 src/modules/password-reset/domain/errors/password-reset-not-found.error.ts create mode 100644 src/modules/password-reset/domain/repositories/password-reset.repository.ts create mode 100644 src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts create mode 100644 src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts create mode 100644 src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts create mode 100644 src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts create mode 100644 src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts create mode 100644 src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts create mode 100644 src/modules/password-reset/infra/database/password-reset-database.module.ts create mode 100644 src/modules/password-reset/infra/database/typeorm/mappers/password-reset-typeorm.mapper.ts create mode 100644 src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts create mode 100644 src/modules/password-reset/infra/database/typeorm/schemas/password-reset.schema.ts create mode 100644 src/modules/password-reset/password-reset.module.ts create mode 100644 src/modules/password-reset/presenter/controllers/password-reset.controller.ts create mode 100644 test/factories/password-reset-mock.ts create mode 100644 test/repositories/in-memory-password-reset-repository.ts diff --git a/src/modules/password-reset/domain/entities/password-reset.entity.ts b/src/modules/password-reset/domain/entities/password-reset.entity.ts new file mode 100644 index 0000000..f823153 --- /dev/null +++ b/src/modules/password-reset/domain/entities/password-reset.entity.ts @@ -0,0 +1,85 @@ +import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; +import { BaseEntity, BaseEntityProps } from '@shared/domain/base.entity'; +import { Either, Right } from '@shared/helpers/either'; +import { Replace } from '@shared/helpers/replace'; +import { UUID } from 'node:crypto'; + +export interface PasswordResetEntityProps { + userId: UUID; + user?: UserEntity; + code: string; + validUntil: Date; + used: boolean; +} + +export type PasswordResetEntityCreateProps = Replace< + PasswordResetEntityProps, + { + code?: string; + validUntil?: Date; + used?: boolean; + } +>; + +export class PasswordResetEntity extends BaseEntity { + private constructor( + props: PasswordResetEntityProps, + baseEntityProps?: BaseEntityProps, + ) { + super(props, baseEntityProps); + Object.freeze(this); + } + + static create( + { userId, code, user, validUntil, used }: PasswordResetEntityCreateProps, + baseEntityProps?: BaseEntityProps, + ): Either { + const day = 24 * 60 * 60 * 1000; + const tomorrow = new Date(+new Date() + day); + + return new Right( + new PasswordResetEntity( + { + userId, + user, + code: code || this.generateCode(), + validUntil: validUntil || tomorrow, + used: used ?? false, + }, + baseEntityProps, + ), + ); + } + + static generateCode(): string { + const codeSize = 8; + + return [...Array(codeSize)] + .map(() => + Math.floor(Math.random() * 16) + .toString(16) + .toUpperCase(), + ) + .join(''); + } + + public get userId(): UUID { + return this.props.userId; + } + + public get user(): UserEntity | null { + return this.props.user || null; + } + + public get code(): string { + return this.props.code; + } + + public get validUntil(): Date { + return this.props.validUntil; + } + + public get used(): boolean { + return this.props.used; + } +} diff --git a/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts b/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts new file mode 100644 index 0000000..c5ce599 --- /dev/null +++ b/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts @@ -0,0 +1,9 @@ +import { DomainError } from '@shared/domain/domain.error'; + +export class IncorrectOldPasswordError extends Error implements DomainError { + constructor() { + super(`Old password confirmation failed`); + + this.name = 'IncorrectOldPasswordError'; + } +} diff --git a/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts b/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts new file mode 100644 index 0000000..98d626f --- /dev/null +++ b/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts @@ -0,0 +1,12 @@ +import { DomainError } from '@shared/domain/domain.error'; + +export class IncorrectPasswordResetCodeError + extends Error + implements DomainError +{ + constructor() { + super(`Password reset code confirmation failed`); + + this.name = 'IncorrectPasswordResetCodeError'; + } +} diff --git a/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts b/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts new file mode 100644 index 0000000..9091402 --- /dev/null +++ b/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts @@ -0,0 +1,9 @@ +import { DomainError } from '@shared/domain/domain.error'; + +export class PasswordResetNotFoundError extends Error implements DomainError { + constructor() { + super(`PasswordReset not found`); + + this.name = 'PasswordResetNotFoundError'; + } +} diff --git a/src/modules/password-reset/domain/repositories/password-reset.repository.ts b/src/modules/password-reset/domain/repositories/password-reset.repository.ts new file mode 100644 index 0000000..a440f81 --- /dev/null +++ b/src/modules/password-reset/domain/repositories/password-reset.repository.ts @@ -0,0 +1,8 @@ +import { UUID } from 'node:crypto'; +import { PasswordResetEntity } from '../entities/password-reset.entity'; + +export abstract class PasswordResetRepository { + abstract save(entity: PasswordResetEntity): Promise; + abstract getById(id: UUID): Promise; + abstract getValidByUserId(userId: UUID): Promise; +} diff --git a/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts new file mode 100644 index 0000000..8287d39 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts @@ -0,0 +1,71 @@ +import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; +import { UserRepository } from '@modules/user/domain/repositories/user.repository'; +import { PasswordEncryptionService } from '@modules/user/domain/services/password-encryption.service'; +import { MockRequestUser } from 'test/factories/mock-request-user'; +import { MockPasswordReset } from 'test/factories/password-reset-mock'; +import { InMemoryPasswordResetRepository } from 'test/repositories/in-memory-password-reset-repository'; +import { InMemoryRepository } from 'test/repositories/in-memory-repository'; +import { InMemoryUserRepository } from 'test/repositories/in-memory-user-repository'; +import { DeepMocked, createMock } from 'test/utils/create-mock'; +import { PasswordResetEntity } from '../../entities/password-reset.entity'; +import { IncorrectPasswordResetCodeError } from '../../errors/incorrect-password-reset-code.error'; +import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; +import { ExecutePasswordResetUseCase } from './execute-password-reset.usecase'; + +describe('ExecutePasswordResetUseCase', () => { + let usecase: ExecutePasswordResetUseCase; + let repository: InMemoryRepository< + PasswordResetRepository, + PasswordResetEntity + >; + let userRepository: InMemoryRepository; + let passwordEncryptionService: DeepMocked; + + beforeEach(() => { + repository = new InMemoryPasswordResetRepository(); + userRepository = new InMemoryUserRepository(); + passwordEncryptionService = createMock(); + + usecase = new ExecutePasswordResetUseCase( + repository, + userRepository, + passwordEncryptionService, + ); + }); + + it('should return a PasswordResetNotFoundError if a valid reset was not found', async () => { + const requestUser = MockRequestUser.createEntity(); + + const result = await usecase.exec({ + requestUser, + code: 'AbC4dEf1', + oldPassword: '', + newPassword: '', + }); + + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(PasswordResetNotFoundError); + }); + + it('should return an IncorrectPasswordResetCodeError if the provided code does not match', async () => { + const requestUser = MockRequestUser.createEntity(); + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: requestUser.id, + }, + }); + + repository.save(passwordReset); + + const result = await usecase.exec({ + requestUser, + code: passwordReset.code, + oldPassword: '', + newPassword: '', + }); + + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(IncorrectPasswordResetCodeError); + }); +}); 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 new file mode 100644 index 0000000..0695708 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts @@ -0,0 +1,61 @@ +import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; +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/usecase'; +import { Either, left, right } from '@shared/helpers/either'; +import { IncorrectOldPasswordError } from '../../errors/incorrect-old-password.error'; +import { IncorrectPasswordResetCodeError } from '../../errors/incorrect-password-reset-code.error'; +import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; + +export interface ExecutePasswordResetUseCaseInput { + requestUser: RequestUserEntity; + code: string; + oldPassword: string; + newPassword: string; +} + +export interface ExecutePasswordResetUseCaseOutput {} + +export type ExecutePasswordResetUseCaseErrors = + | PasswordResetNotFoundError + | IncorrectOldPasswordError + | IncorrectPasswordResetCodeError; + +@Injectable() +export class ExecutePasswordResetUseCase + implements + UseCase< + ExecutePasswordResetUseCaseInput, + ExecutePasswordResetUseCaseOutput, + ExecutePasswordResetUseCaseErrors + > +{ + constructor( + private readonly repository: PasswordResetRepository, + private readonly userRepository: UserRepository, + private readonly passwordEncryptionService: PasswordEncryptionService, + ) {} + + async exec({ + requestUser, + code, + }: ExecutePasswordResetUseCaseInput): Promise< + Either + > { + const passwordReset = await this.repository.getValidByUserId( + requestUser.id, + ); + + if (!passwordReset) { + return left(new PasswordResetNotFoundError()); + } + + if (code.toLowerCase() !== passwordReset.code) { + return left(new IncorrectPasswordResetCodeError()); + } + + return right({}); + } +} diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts new file mode 100644 index 0000000..fd9f6f0 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts @@ -0,0 +1,41 @@ +import { MockRequestUser } from 'test/factories/mock-request-user'; +import { InMemoryPasswordResetRepository } from 'test/repositories/in-memory-password-reset-repository'; +import { InMemoryRepository } from 'test/repositories/in-memory-repository'; +import { PasswordResetEntity } from '../../entities/password-reset.entity'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; +import { + RequestPasswordResetUseCase, + RequestPasswordResetUseCaseOutput, +} from './request-password-reset.usecase'; + +describe('RequestPasswordResetUseCase', () => { + let usecase: RequestPasswordResetUseCase; + let repository: InMemoryRepository< + PasswordResetRepository, + PasswordResetEntity + >; + + beforeEach(() => { + repository = new InMemoryPasswordResetRepository(); + usecase = new RequestPasswordResetUseCase(repository); + }); + + it('should return a PasswordReset', async () => { + const result = await usecase.exec({ + requestUser: MockRequestUser.createEntity(), + }); + + expect(result.isRight()).toBeTruthy(); + expect( + (result.value as RequestPasswordResetUseCaseOutput).createdPasswordReset, + ).toBeInstanceOf(PasswordResetEntity); + }); + + it('should persist the PasswordReset', async () => { + await usecase.exec({ + requestUser: MockRequestUser.createEntity(), + }); + + expect(repository.items).toHaveLength(1); + }); +}); diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts new file mode 100644 index 0000000..a4e3467 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts @@ -0,0 +1,46 @@ +import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; +import { Injectable } from '@nestjs/common'; +import { UseCase } from '@shared/domain/usecase'; +import { Either, Left, Right } from '@shared/helpers/either'; +import { PasswordResetEntity } from '../../entities/password-reset.entity'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; + +export interface RequestPasswordResetUseCaseInput { + requestUser: RequestUserEntity; +} + +export interface RequestPasswordResetUseCaseOutput { + createdPasswordReset: PasswordResetEntity; +} + +export type RequestPasswordResetUseCaseErrors = Error; + +@Injectable() +export class RequestPasswordResetUseCase + implements + UseCase< + RequestPasswordResetUseCaseInput, + RequestPasswordResetUseCaseOutput, + RequestPasswordResetUseCaseErrors + > +{ + constructor(private readonly repository: PasswordResetRepository) {} + + async exec({ + requestUser, + }: RequestPasswordResetUseCaseInput): Promise< + Either + > { + const PasswordResetResult = PasswordResetEntity.create({ + userId: requestUser.id, + }); + + if (PasswordResetResult.isLeft()) { + return new Left(PasswordResetResult.value); + } + + await this.repository.save(PasswordResetResult.value); + + return new Right({ createdPasswordReset: PasswordResetResult.value }); + } +} diff --git a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts new file mode 100644 index 0000000..1cb7e49 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts @@ -0,0 +1,100 @@ +import { MockRequestUser } from 'test/factories/mock-request-user'; +import { MockPasswordReset } from 'test/factories/password-reset-mock'; +import { InMemoryPasswordResetRepository } from 'test/repositories/in-memory-password-reset-repository'; +import { InMemoryRepository } from 'test/repositories/in-memory-repository'; +import { PasswordResetEntity } from '../../entities/password-reset.entity'; +import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; +import { + ValidatePasswordResetUseCase, + ValidatePasswordResetUseCaseOutput, +} from './validate-password-reset.usecase'; + +describe('ValidatePasswordResetUseCase', () => { + let usecase: ValidatePasswordResetUseCase; + let repository: InMemoryRepository< + PasswordResetRepository, + PasswordResetEntity + >; + + beforeEach(() => { + repository = new InMemoryPasswordResetRepository(); + usecase = new ValidatePasswordResetUseCase(repository); + }); + + it('should return true if the code is matching', async () => { + const requestUser = MockRequestUser.createEntity(); + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: requestUser.id, + }, + }); + + repository.save(passwordReset); + + const result = await usecase.exec({ + requestUser, + code: passwordReset.code, + }); + + expect(result.isRight()).toBeTruthy(); + expect( + (result.value as ValidatePasswordResetUseCaseOutput).matches, + ).toBeTruthy(); + }); + + it('should return false if the code is not matching', async () => { + const requestUser = MockRequestUser.createEntity(); + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: requestUser.id, + }, + }); + + repository.save(passwordReset); + + const result = await usecase.exec({ + requestUser, + code: 'RANDOM_CODE', + }); + + expect(result.isRight()).toBeTruthy(); + expect( + (result.value as ValidatePasswordResetUseCaseOutput).matches, + ).toBeFalsy(); + }); + + it('should consider the code as case insensitive', async () => { + const requestUser = MockRequestUser.createEntity(); + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: requestUser.id, + code: 'ABC4DEF1', + }, + }); + + repository.save(passwordReset); + + const result = await usecase.exec({ + requestUser, + code: 'AbC4dEf1', + }); + + expect(result.isRight()).toBeTruthy(); + expect( + (result.value as ValidatePasswordResetUseCaseOutput).matches, + ).toBeTruthy(); + }); + + it('should return a PasswordResetNotFoundError if a valid reset was not found', async () => { + const requestUser = MockRequestUser.createEntity(); + + const result = await usecase.exec({ + requestUser, + code: 'AbC4dEf1', + }); + + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(PasswordResetNotFoundError); + }); +}); diff --git a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts new file mode 100644 index 0000000..38bb6e1 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts @@ -0,0 +1,51 @@ +import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; +import { Injectable } from '@nestjs/common'; +import { UseCase } from '@shared/domain/usecase'; +import { Either, left, right } from '@shared/helpers/either'; +import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; +import { PasswordResetRepository } from '../../repositories/password-reset.repository'; + +export interface ValidatePasswordResetUseCaseInput { + requestUser: RequestUserEntity; + code: string; +} + +export interface ValidatePasswordResetUseCaseOutput { + matches: boolean; +} + +export type ValidatePasswordResetUseCaseErrors = PasswordResetNotFoundError; + +@Injectable() +export class ValidatePasswordResetUseCase + implements + UseCase< + ValidatePasswordResetUseCaseInput, + ValidatePasswordResetUseCaseOutput, + ValidatePasswordResetUseCaseErrors + > +{ + constructor(private readonly repository: PasswordResetRepository) {} + + async exec({ + requestUser, + code, + }: ValidatePasswordResetUseCaseInput): Promise< + Either< + ValidatePasswordResetUseCaseErrors, + ValidatePasswordResetUseCaseOutput + > + > { + const passwordReset = await this.repository.getValidByUserId( + requestUser.id, + ); + + if (!passwordReset) { + return left(new PasswordResetNotFoundError()); + } + + return right({ + matches: code.toLowerCase() === passwordReset.code.toLowerCase(), + }); + } +} diff --git a/src/modules/password-reset/infra/database/password-reset-database.module.ts b/src/modules/password-reset/infra/database/password-reset-database.module.ts new file mode 100644 index 0000000..f606de4 --- /dev/null +++ b/src/modules/password-reset/infra/database/password-reset-database.module.ts @@ -0,0 +1,17 @@ +import { PasswordResetRepository } from '@modules/password-reset/domain/repositories/password-reset.repository'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TypeOrmPasswordResetRepository } from './typeorm/repositories/password-reset-typeorm.repository'; +import { PasswordResetSchema } from './typeorm/schemas/password-reset.schema'; + +@Module({ + imports: [TypeOrmModule.forFeature([PasswordResetSchema])], + providers: [ + { + provide: PasswordResetRepository, + useClass: TypeOrmPasswordResetRepository, + }, + ], + exports: [PasswordResetRepository], +}) +export class PasswordResetDatabaseModule {} diff --git a/src/modules/password-reset/infra/database/typeorm/mappers/password-reset-typeorm.mapper.ts b/src/modules/password-reset/infra/database/typeorm/mappers/password-reset-typeorm.mapper.ts new file mode 100644 index 0000000..7c09e9a --- /dev/null +++ b/src/modules/password-reset/infra/database/typeorm/mappers/password-reset-typeorm.mapper.ts @@ -0,0 +1,40 @@ +import { PasswordResetEntity } from '@modules/password-reset/domain/entities/password-reset.entity'; +import { TypeOrmUserMapper } from '@modules/user/infra/database/typeorm/mappers/typeorm-user.mapper'; +import { PasswordResetSchema } from '../schemas/password-reset.schema'; + +export class TypeOrmPasswordResetMapper { + static toEntity(schema: PasswordResetSchema): PasswordResetEntity { + const entity = PasswordResetEntity.create( + { + userId: schema.userId, + code: schema.code, + validUntil: schema.validUntil, + used: schema.used, + ...(schema.user && { + user: TypeOrmUserMapper.toEntity(schema.user), + }), + }, + { + id: schema.id, + createdAt: schema.createdAt, + updatedAt: schema.updatedAt, + }, + ); + + if (entity.isLeft()) { + throw new Error(`Could not map entity schema to entity: ${entity.value}`); + } + + return entity.value; + } + + static toSchema(entity: PasswordResetEntity): PasswordResetSchema { + return PasswordResetSchema.create({ + id: entity.id, + code: entity.code, + validUntil: entity.validUntil, + used: entity.used, + userId: entity.userId, + }); + } +} diff --git a/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts b/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts new file mode 100644 index 0000000..08ee037 --- /dev/null +++ b/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts @@ -0,0 +1,50 @@ +import { PasswordResetEntity } from '@modules/password-reset/domain/entities/password-reset.entity'; +import { PasswordResetRepository } from '@modules/password-reset/domain/repositories/password-reset.repository'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UUID } from 'node:crypto'; +import { MoreThan, Repository } from 'typeorm'; +import { TypeOrmPasswordResetMapper } from '../mappers/password-reset-typeorm.mapper'; +import { PasswordResetSchema } from '../schemas/password-reset.schema'; + +export class TypeOrmPasswordResetRepository implements PasswordResetRepository { + constructor( + @InjectRepository(PasswordResetSchema) + private typeOrmRepository: Repository, + ) {} + + async save(entity: PasswordResetEntity): Promise { + await this.typeOrmRepository.save( + TypeOrmPasswordResetMapper.toSchema(entity), + ); + } + + async getById(id: UUID): Promise { + const schema = await this.typeOrmRepository.findOne({ + where: { + id, + }, + }); + + if (!schema) { + return null; + } + + return TypeOrmPasswordResetMapper.toEntity(schema); + } + + async getValidByUserId(userId: UUID): Promise { + const schema = await this.typeOrmRepository.findOne({ + where: { + userId, + validUntil: MoreThan(new Date()), + used: false, + }, + }); + + if (!schema) { + return null; + } + + return TypeOrmPasswordResetMapper.toEntity(schema); + } +} diff --git a/src/modules/password-reset/infra/database/typeorm/schemas/password-reset.schema.ts b/src/modules/password-reset/infra/database/typeorm/schemas/password-reset.schema.ts new file mode 100644 index 0000000..fd5c423 --- /dev/null +++ b/src/modules/password-reset/infra/database/typeorm/schemas/password-reset.schema.ts @@ -0,0 +1,25 @@ +import { UserSchema } from '@modules/user/infra/database/typeorm/schemas/user.schema'; +import { BaseSchema } from '@shared/infra/database/typeorm/base.schema'; +import { UUID } from 'node:crypto'; +import { Column, Entity, Index, ManyToOne, Relation } from 'typeorm'; + +@Entity('password_resets') +export class PasswordResetSchema extends BaseSchema { + @Index('idx_password_reset_code') + @Column() + userId: UUID; + + @Column() + code: string; + + @Column() + validUntil: Date; + + @Column({ default: false }) + used: boolean; + + @ManyToOne(() => UserSchema, { + onDelete: 'CASCADE', + }) + user?: Relation; +} diff --git a/src/modules/password-reset/password-reset.module.ts b/src/modules/password-reset/password-reset.module.ts new file mode 100644 index 0000000..4ec29f6 --- /dev/null +++ b/src/modules/password-reset/password-reset.module.ts @@ -0,0 +1,6 @@ +@Module({ + imports: [PasswordResetDatabaseModule], + controllers: [PasswordResetController], + providers: [GetAllPasswordResetsUseCase, CreatePasswordResetUseCase], +}) +export class PasswordResetModule {} diff --git a/src/modules/password-reset/presenter/controllers/password-reset.controller.ts b/src/modules/password-reset/presenter/controllers/password-reset.controller.ts new file mode 100644 index 0000000..2d79412 --- /dev/null +++ b/src/modules/password-reset/presenter/controllers/password-reset.controller.ts @@ -0,0 +1,8 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('Password Resets') +@Controller('password-resets') +export class PasswordResetController { + constructor() {} +} diff --git a/src/shared/helpers/either.ts b/src/shared/helpers/either.ts index fcaee1b..384be70 100644 --- a/src/shared/helpers/either.ts +++ b/src/shared/helpers/either.ts @@ -31,3 +31,11 @@ export class Right { } export type Either = Left | Right; + +export function left(value: T): Left { + return new Left(value); +} + +export function right(value: T): Right { + return new Right(value); +} diff --git a/test/factories/password-reset-mock.ts b/test/factories/password-reset-mock.ts new file mode 100644 index 0000000..ec991dd --- /dev/null +++ b/test/factories/password-reset-mock.ts @@ -0,0 +1,41 @@ +import { faker } from '@faker-js/faker'; +import { + PasswordResetEntity, + PasswordResetEntityCreateProps, +} from '@modules/password-reset/domain/entities/password-reset.entity'; +import { BaseEntityProps } from '@shared/domain/base.entity'; +import { UUID } from 'crypto'; + +interface CreateMockPasswordResetOverrideProps { + override?: Partial; + basePropsOverride?: Partial; +} + +export class MockPasswordReset { + static createEntity({ + override, + basePropsOverride, + }: CreateMockPasswordResetOverrideProps = {}): PasswordResetEntity { + const overrideProps = override ?? {}; + const entityPropsOverride = basePropsOverride ?? {}; + + const entity = PasswordResetEntity.create( + { + userId: faker.string.uuid() as UUID, + ...overrideProps, + }, + { + id: faker.string.uuid() as UUID, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...entityPropsOverride, + }, + ); + + if (entity.isLeft()) { + throw new Error(`Mock PasswordReset error: ${entity.value}`); + } + + return entity.value; + } +} diff --git a/test/repositories/in-memory-password-reset-repository.ts b/test/repositories/in-memory-password-reset-repository.ts new file mode 100644 index 0000000..6e411e0 --- /dev/null +++ b/test/repositories/in-memory-password-reset-repository.ts @@ -0,0 +1,36 @@ +import { PasswordResetEntity } from '@modules/password-reset/domain/entities/password-reset.entity'; +import { PasswordResetRepository } from '@modules/password-reset/domain/repositories/password-reset.repository'; +import { UUID } from 'crypto'; +import { InMemoryRepository } from './in-memory-repository'; + +export class InMemoryPasswordResetRepository + implements InMemoryRepository +{ + public items: PasswordResetEntity[] = []; + + async save(course: PasswordResetEntity): Promise { + this.items.push(course); + } + + async getById(id: UUID): Promise { + const entity = this.items.find((c) => c.id === id); + + if (!entity) { + return null; + } + + return entity; + } + + async getValidByUserId(userId: UUID): Promise { + const entity = this.items.find((i) => { + return i.userId === userId && !i.used && i.validUntil > new Date(); + }); + + if (!entity) { + return null; + } + + return entity; + } +} diff --git a/tsconfig.json b/tsconfig.json index 809b28b..d5519e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,8 +18,12 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "paths": { - "@modules/*": ["./src/modules/*"], - "@shared/*": ["./src/shared/*"] + "@modules/*": [ + "./src/modules/*" + ], + "@shared/*": [ + "./src/shared/*" + ] }, } -} +} \ No newline at end of file From 784ae93e9cf05598c9bff39b0287a1855bbc4aae Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Mon, 18 Dec 2023 11:08:27 -0300 Subject: [PATCH 2/6] feat(password-reset): creating password reset by email --- .../request-password-reset.usecase.test.ts | 38 +++++++++++++++---- .../request/request-password-reset.usecase.ts | 22 ++++++++--- .../password-reset/password-reset.module.ts | 13 ++++++- .../domain/errors/user-not-found.error.ts | 4 +- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts index fd9f6f0..7bd9160 100644 --- a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts @@ -1,6 +1,11 @@ -import { MockRequestUser } from 'test/factories/mock-request-user'; +import { faker } from '@faker-js/faker'; +import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; +import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; +import { UserRepository } from '@modules/user/domain/repositories/user.repository'; +import { MockUser } from 'test/factories/mock-user'; import { InMemoryPasswordResetRepository } from 'test/repositories/in-memory-password-reset-repository'; import { InMemoryRepository } from 'test/repositories/in-memory-repository'; +import { InMemoryUserRepository } from 'test/repositories/in-memory-user-repository'; import { PasswordResetEntity } from '../../entities/password-reset.entity'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; import { @@ -10,32 +15,49 @@ import { describe('RequestPasswordResetUseCase', () => { let usecase: RequestPasswordResetUseCase; + let repository: InMemoryRepository< PasswordResetRepository, PasswordResetEntity >; + let userRepository: InMemoryRepository; beforeEach(() => { repository = new InMemoryPasswordResetRepository(); - usecase = new RequestPasswordResetUseCase(repository); + userRepository = new InMemoryUserRepository(); + usecase = new RequestPasswordResetUseCase(repository, userRepository); }); - it('should return a PasswordReset', async () => { + it('should persist and return a PasswordReset', async () => { + const email = faker.internet.email(); + const user = MockUser.createEntity({ + override: { + email, + }, + }); + + userRepository.save(user); + const result = await usecase.exec({ - requestUser: MockRequestUser.createEntity(), + email, }); + expect(repository.items).toHaveLength(1); expect(result.isRight()).toBeTruthy(); expect( (result.value as RequestPasswordResetUseCaseOutput).createdPasswordReset, ).toBeInstanceOf(PasswordResetEntity); }); - it('should persist the PasswordReset', async () => { - await usecase.exec({ - requestUser: MockRequestUser.createEntity(), + it('should throw a UserNotFoundError if the user was not found', async () => { + const email = faker.internet.email(); + + const result = await usecase.exec({ + email, }); - expect(repository.items).toHaveLength(1); + expect(repository.items).toHaveLength(0); + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(UserNotFoundError); }); }); diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts index a4e3467..0d6b7b8 100644 --- a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts @@ -1,4 +1,5 @@ -import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; +import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; +import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { Injectable } from '@nestjs/common'; import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; @@ -6,14 +7,14 @@ import { PasswordResetEntity } from '../../entities/password-reset.entity'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; export interface RequestPasswordResetUseCaseInput { - requestUser: RequestUserEntity; + email: string; } export interface RequestPasswordResetUseCaseOutput { createdPasswordReset: PasswordResetEntity; } -export type RequestPasswordResetUseCaseErrors = Error; +export type RequestPasswordResetUseCaseErrors = UserNotFoundError | Error; @Injectable() export class RequestPasswordResetUseCase @@ -24,15 +25,24 @@ export class RequestPasswordResetUseCase RequestPasswordResetUseCaseErrors > { - constructor(private readonly repository: PasswordResetRepository) {} + constructor( + private readonly repository: PasswordResetRepository, + private readonly userRepository: UserRepository, + ) {} async exec({ - requestUser, + email, }: RequestPasswordResetUseCaseInput): Promise< Either > { + const user = await this.userRepository.getByEmail(email); + + if (!user) { + return new Left(new UserNotFoundError()); + } + const PasswordResetResult = PasswordResetEntity.create({ - userId: requestUser.id, + userId: user.id, }); if (PasswordResetResult.isLeft()) { diff --git a/src/modules/password-reset/password-reset.module.ts b/src/modules/password-reset/password-reset.module.ts index 4ec29f6..47faac0 100644 --- a/src/modules/password-reset/password-reset.module.ts +++ b/src/modules/password-reset/password-reset.module.ts @@ -1,6 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ExecutePasswordResetUseCase } from './domain/usecases/execute/execute-password-reset.usecase'; +import { RequestPasswordResetUseCase } from './domain/usecases/request/request-password-reset.usecase'; +import { ValidatePasswordResetUseCase } from './domain/usecases/validate/validate-password-reset.usecase'; +import { PasswordResetDatabaseModule } from './infra/database/password-reset-database.module'; +import { PasswordResetController } from './presenter/controllers/password-reset.controller'; + @Module({ imports: [PasswordResetDatabaseModule], controllers: [PasswordResetController], - providers: [GetAllPasswordResetsUseCase, CreatePasswordResetUseCase], + providers: [ + RequestPasswordResetUseCase, + ValidatePasswordResetUseCase, + ExecutePasswordResetUseCase, + ], }) export class PasswordResetModule {} diff --git a/src/modules/user/domain/errors/user-not-found.error.ts b/src/modules/user/domain/errors/user-not-found.error.ts index 9fc6c7b..0921ebf 100644 --- a/src/modules/user/domain/errors/user-not-found.error.ts +++ b/src/modules/user/domain/errors/user-not-found.error.ts @@ -2,8 +2,8 @@ import { DomainError } from '@shared/domain/domain.error'; import { UUID } from 'crypto'; export class UserNotFoundError extends Error implements DomainError { - constructor(id: UUID) { - super(`User not found: ${id}`); + constructor(id?: UUID) { + super(`User not found${id ? ': ' + id : ''}`); this.name = 'UserNotFoundError'; } From 5fe0e0ca7da89e60f47f8a645677542f3efa0406 Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Fri, 22 Dec 2023 12:04:29 -0300 Subject: [PATCH 3/6] feat(password-reset): sending the password reset code by email --- .../auth/domain/usecases/login.usecase.ts | 2 +- .../domain/entities/course/course.entity.ts | 5 ++- .../entities/enrollment/enrollment.entity.ts | 5 ++- .../domain/usecases/create-course.usecase.ts | 2 +- .../enroll-student-in-course.usecase.ts | 2 +- .../usecases/get-all-courses.usecase.ts | 2 +- .../domain/entities/password-reset.entity.ts | 5 ++- .../request-password-reset.usecase.test.ts | 37 ++++++++++++++++++- .../request/request-password-reset.usecase.ts | 23 +++++++++--- .../validate-password-reset.usecase.ts | 2 +- .../user/domain/entities/user/user.entity.ts | 11 ++++-- .../domain/usecases/create-user.usecase.ts | 2 +- .../domain/usecases/get-user-by-id.usecase.ts | 2 +- .../domain/{ => entities}/base.entity.test.ts | 8 ++-- .../domain/{ => entities}/base.entity.ts | 0 .../domain/{ => errors}/domain.error.ts | 2 +- src/shared/domain/services/mail.service.ts | 9 +++++ src/shared/domain/usecase.ts | 6 --- src/shared/domain/usecases/usecase.ts | 12 ++++++ .../models/base-entity.view-model.ts | 2 +- test/factories/mock-course.ts | 28 ++++++++------ test/factories/mock-enrollment.ts | 2 +- test/factories/mock-user.ts | 2 +- test/factories/password-reset-mock.ts | 2 +- 24 files changed, 125 insertions(+), 48 deletions(-) rename src/shared/domain/{ => entities}/base.entity.test.ts (86%) rename src/shared/domain/{ => entities}/base.entity.ts (100%) rename src/shared/domain/{ => errors}/domain.error.ts (96%) create mode 100644 src/shared/domain/services/mail.service.ts delete mode 100644 src/shared/domain/usecase.ts create mode 100644 src/shared/domain/usecases/usecase.ts diff --git a/src/modules/auth/domain/usecases/login.usecase.ts b/src/modules/auth/domain/usecases/login.usecase.ts index 850fcbe..200872a 100644 --- a/src/modules/auth/domain/usecases/login.usecase.ts +++ b/src/modules/auth/domain/usecases/login.usecase.ts @@ -2,10 +2,10 @@ import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; import { InvalidEmailError } from '@modules/user/domain/errors/invalid-email.error'; import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { PasswordEncryptionService } from '@modules/user/domain/services/password-encryption.service'; -import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; import { IncorrectPasswordError } from '../errors/incorrect-password.error'; import { UserNotFoundError } from '../errors/user-not-found.error'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface LoginUseCaseInput { email: string; diff --git a/src/modules/course/domain/entities/course/course.entity.ts b/src/modules/course/domain/entities/course/course.entity.ts index 7c23d5d..be7a118 100644 --- a/src/modules/course/domain/entities/course/course.entity.ts +++ b/src/modules/course/domain/entities/course/course.entity.ts @@ -1,5 +1,8 @@ import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; -import { BaseEntity, BaseEntityProps } from '@shared/domain/base.entity'; +import { + BaseEntity, + BaseEntityProps, +} from '@shared/domain/entities/base.entity'; import { Either, Left, Right } from '@shared/helpers/either'; import { Replace } from '@shared/helpers/replace'; import { UUID } from 'crypto'; diff --git a/src/modules/course/domain/entities/enrollment/enrollment.entity.ts b/src/modules/course/domain/entities/enrollment/enrollment.entity.ts index 35f121a..1335a07 100644 --- a/src/modules/course/domain/entities/enrollment/enrollment.entity.ts +++ b/src/modules/course/domain/entities/enrollment/enrollment.entity.ts @@ -1,5 +1,8 @@ import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; -import { BaseEntity, BaseEntityProps } from '@shared/domain/base.entity'; +import { + BaseEntity, + BaseEntityProps, +} from '@shared/domain/entities/base.entity'; import { Either, Right } from '@shared/helpers/either'; import { Replace } from '@shared/helpers/replace'; import { UUID } from 'crypto'; diff --git a/src/modules/course/domain/usecases/create-course.usecase.ts b/src/modules/course/domain/usecases/create-course.usecase.ts index 6177a0e..179847e 100644 --- a/src/modules/course/domain/usecases/create-course.usecase.ts +++ b/src/modules/course/domain/usecases/create-course.usecase.ts @@ -1,11 +1,11 @@ import { UserRepository } from '@modules/user/domain/repositories/user.repository'; -import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; import { UUID } from 'crypto'; import { CourseEntity } from '../entities/course/course.entity'; import { InstructorNotFoundError } from '../errors/instructor-not-found.error'; import { InvalidMoneyError } from '../errors/invalid-money.error'; import { CourseRepository } from '../repositories/course.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface CreateCourseUseCaseInput { title: string; diff --git a/src/modules/course/domain/usecases/enroll-student-in-course.usecase.ts b/src/modules/course/domain/usecases/enroll-student-in-course.usecase.ts index 95e1e85..60bd4a7 100644 --- a/src/modules/course/domain/usecases/enroll-student-in-course.usecase.ts +++ b/src/modules/course/domain/usecases/enroll-student-in-course.usecase.ts @@ -1,5 +1,4 @@ import { UserRepository } from '@modules/user/domain/repositories/user.repository'; -import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; import { UUID } from 'crypto'; import { EnrollmentEntity } from '../entities/enrollment/enrollment.entity'; @@ -8,6 +7,7 @@ import { StudentAlreadyEnrolledError } from '../errors/student-already-enrolled. import { StudentNotFoundError } from '../errors/student-not-found.error'; import { CourseRepository } from '../repositories/course.repository'; import { EnrollmentRepository } from '../repositories/enrollment.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface EnrollStudentInCourseUseCaseInput { studentId: UUID; diff --git a/src/modules/course/domain/usecases/get-all-courses.usecase.ts b/src/modules/course/domain/usecases/get-all-courses.usecase.ts index eb64c1e..7c048c0 100644 --- a/src/modules/course/domain/usecases/get-all-courses.usecase.ts +++ b/src/modules/course/domain/usecases/get-all-courses.usecase.ts @@ -1,9 +1,9 @@ -import { UseCase } from '@shared/domain/usecase'; import { Either, Right } from '@shared/helpers/either'; import { PaginatedEntitiesOptions } from '@shared/infra/database/interfaces/paginated-entities-options.interface.'; import { PaginatedEntities } from '@shared/infra/database/interfaces/paginated-entities.interface'; import { CourseEntity } from '../entities/course/course.entity'; import { CourseRepository } from '../repositories/course.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface GetAllCoursesUseCaseInput { paginationOptions: PaginatedEntitiesOptions; diff --git a/src/modules/password-reset/domain/entities/password-reset.entity.ts b/src/modules/password-reset/domain/entities/password-reset.entity.ts index f823153..b326141 100644 --- a/src/modules/password-reset/domain/entities/password-reset.entity.ts +++ b/src/modules/password-reset/domain/entities/password-reset.entity.ts @@ -1,5 +1,8 @@ import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; -import { BaseEntity, BaseEntityProps } from '@shared/domain/base.entity'; +import { + BaseEntity, + BaseEntityProps, +} from '@shared/domain/entities/base.entity'; import { Either, Right } from '@shared/helpers/either'; import { Replace } from '@shared/helpers/replace'; import { UUID } from 'node:crypto'; diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts index 7bd9160..ac66be9 100644 --- a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts @@ -12,6 +12,11 @@ import { RequestPasswordResetUseCase, RequestPasswordResetUseCaseOutput, } from './request-password-reset.usecase'; +import { DeepMocked, createMock } from 'test/utils/create-mock'; +import { + MailService, + SendMailOptions, +} from '@shared/domain/services/mail.service'; describe('RequestPasswordResetUseCase', () => { let usecase: RequestPasswordResetUseCase; @@ -21,11 +26,17 @@ describe('RequestPasswordResetUseCase', () => { PasswordResetEntity >; let userRepository: InMemoryRepository; + let mailService: DeepMocked; beforeEach(() => { repository = new InMemoryPasswordResetRepository(); userRepository = new InMemoryUserRepository(); - usecase = new RequestPasswordResetUseCase(repository, userRepository); + mailService = createMock(); + usecase = new RequestPasswordResetUseCase( + repository, + userRepository, + mailService, + ); }); it('should persist and return a PasswordReset', async () => { @@ -60,4 +71,28 @@ describe('RequestPasswordResetUseCase', () => { expect(result.isLeft()).toBeTruthy(); expect(result.value).toBeInstanceOf(UserNotFoundError); }); + + it('should send an email to the user containing the code', async () => { + const email = faker.internet.email(); + const user = MockUser.createEntity({ + override: { + email, + }, + }); + + userRepository.save(user); + + await usecase.exec({ + email, + }); + + expect(repository.items).toHaveLength(1); + expect(mailService.send).toHaveBeenCalledTimes(1); + expect(mailService.send).toHaveBeenCalledWith( + expect.objectContaining>({ + to: email, + bodyHtml: expect.stringContaining(repository.items[0].code), + }), + ); + }); }); diff --git a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts index 0d6b7b8..9646ec4 100644 --- a/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts @@ -1,10 +1,11 @@ import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { Injectable } from '@nestjs/common'; -import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; import { PasswordResetEntity } from '../../entities/password-reset.entity'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; +import { MailService } from '@shared/domain/services/mail.service'; export interface RequestPasswordResetUseCaseInput { email: string; @@ -28,6 +29,7 @@ export class RequestPasswordResetUseCase constructor( private readonly repository: PasswordResetRepository, private readonly userRepository: UserRepository, + private readonly mailService: MailService, ) {} async exec({ @@ -41,16 +43,25 @@ export class RequestPasswordResetUseCase return new Left(new UserNotFoundError()); } - const PasswordResetResult = PasswordResetEntity.create({ + const passwordResetResult = PasswordResetEntity.create({ userId: user.id, }); - if (PasswordResetResult.isLeft()) { - return new Left(PasswordResetResult.value); + if (passwordResetResult.isLeft()) { + return new Left(passwordResetResult.value); } - await this.repository.save(PasswordResetResult.value); + await this.mailService.send({ + to: email, + subject: 'Password Reset', + bodyHtml: ` +

Here is your password reset code

+

${passwordResetResult.value.code}

+ `, + }); + + await this.repository.save(passwordResetResult.value); - return new Right({ createdPasswordReset: PasswordResetResult.value }); + return new Right({ createdPasswordReset: passwordResetResult.value }); } } diff --git a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts index 38bb6e1..c2f05b0 100644 --- a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts @@ -1,9 +1,9 @@ import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; import { Injectable } from '@nestjs/common'; -import { UseCase } from '@shared/domain/usecase'; import { Either, left, right } from '@shared/helpers/either'; import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface ValidatePasswordResetUseCaseInput { requestUser: RequestUserEntity; diff --git a/src/modules/user/domain/entities/user/user.entity.ts b/src/modules/user/domain/entities/user/user.entity.ts index 9f43e42..8a036e3 100644 --- a/src/modules/user/domain/entities/user/user.entity.ts +++ b/src/modules/user/domain/entities/user/user.entity.ts @@ -1,11 +1,14 @@ -import { BaseEntity, BaseEntityProps } from '@shared/domain/base.entity'; -import { Replace } from '@shared/helpers/replace'; -import { Email } from './value-objects/email'; +import { + BaseEntity, + BaseEntityProps, +} from '@shared/domain/entities/base.entity'; import { Either, Left, Right } from '@shared/helpers/either'; +import { Replace } from '@shared/helpers/replace'; import { InvalidEmailError } from '../../errors/invalid-email.error'; -import { Name } from './value-objects/name'; import { InvalidNameError } from '../../errors/invalid-name.error'; import { UserRole } from './user-role.enum'; +import { Email } from './value-objects/email'; +import { Name } from './value-objects/name'; export interface UserEntityProps { name: Name; diff --git a/src/modules/user/domain/usecases/create-user.usecase.ts b/src/modules/user/domain/usecases/create-user.usecase.ts index 2962073..7d96d8f 100644 --- a/src/modules/user/domain/usecases/create-user.usecase.ts +++ b/src/modules/user/domain/usecases/create-user.usecase.ts @@ -1,4 +1,3 @@ -import { UseCase } from '@shared/domain/usecase'; import { UserEntity } from '../entities/user/user.entity'; import { UserRepository } from '../repositories/user.repository'; import { DuplicatedEmailError } from '../errors/duplicated-email.error'; @@ -7,6 +6,7 @@ import { InvalidEmailError } from '../errors/invalid-email.error'; import { Either, Left, Right } from '@shared/helpers/either'; import { InvalidNameError } from '../errors/invalid-name.error'; import { UserRole } from '../entities/user/user-role.enum'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface CreateUserUseCaseInput { name: string; diff --git a/src/modules/user/domain/usecases/get-user-by-id.usecase.ts b/src/modules/user/domain/usecases/get-user-by-id.usecase.ts index 8c62017..99b5a00 100644 --- a/src/modules/user/domain/usecases/get-user-by-id.usecase.ts +++ b/src/modules/user/domain/usecases/get-user-by-id.usecase.ts @@ -1,9 +1,9 @@ -import { UseCase } from '@shared/domain/usecase'; import { Either, Left, Right } from '@shared/helpers/either'; import { UUID } from 'crypto'; import { UserEntity } from '../entities/user/user.entity'; import { UserNotFoundError } from '../errors/user-not-found.error'; import { UserRepository } from '../repositories/user.repository'; +import { UseCase } from '@shared/domain/usecases/usecase'; export interface GetUserByIdUseCaseInput { id: UUID; diff --git a/src/shared/domain/base.entity.test.ts b/src/shared/domain/entities/base.entity.test.ts similarity index 86% rename from src/shared/domain/base.entity.test.ts rename to src/shared/domain/entities/base.entity.test.ts index 3cdb1d4..d9f4234 100644 --- a/src/shared/domain/base.entity.test.ts +++ b/src/shared/domain/entities/base.entity.test.ts @@ -11,7 +11,7 @@ describe('BaseEntity', () => { }); it('should be created with a random uuid', () => { - const entity = new BaseEntity<{}>({}); + const entity = new BaseEntity({}); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -19,13 +19,13 @@ describe('BaseEntity', () => { }); it('should be instantiated with a created date', () => { - const entity = new BaseEntity<{}>({}); + const entity = new BaseEntity({}); expect(entity.createdAt.getTime()).toEqual(new Date().getTime()); }); it('should be instantiated with a updated date', () => { - const entity = new BaseEntity<{}>({}); + const entity = new BaseEntity({}); expect(entity.updatedAt.getTime()).toEqual(new Date().getTime()); }); @@ -35,7 +35,7 @@ describe('BaseEntity', () => { const createdAt = new Date(); const updatedAt = new Date(); - const entity = new BaseEntity<{}>({}, { id, createdAt, updatedAt }); + const entity = new BaseEntity({}, { id, createdAt, updatedAt }); expect(entity.id).toEqual(id); expect(entity.createdAt).toEqual(createdAt); diff --git a/src/shared/domain/base.entity.ts b/src/shared/domain/entities/base.entity.ts similarity index 100% rename from src/shared/domain/base.entity.ts rename to src/shared/domain/entities/base.entity.ts diff --git a/src/shared/domain/domain.error.ts b/src/shared/domain/errors/domain.error.ts similarity index 96% rename from src/shared/domain/domain.error.ts rename to src/shared/domain/errors/domain.error.ts index fc7e519..af2a9f6 100644 --- a/src/shared/domain/domain.error.ts +++ b/src/shared/domain/errors/domain.error.ts @@ -1,3 +1,3 @@ export abstract class DomainError { message: string; -} \ No newline at end of file +} diff --git a/src/shared/domain/services/mail.service.ts b/src/shared/domain/services/mail.service.ts new file mode 100644 index 0000000..221d4b0 --- /dev/null +++ b/src/shared/domain/services/mail.service.ts @@ -0,0 +1,9 @@ +export interface SendMailOptions { + to: string; + subject: string; + bodyHtml: string; +} + +export abstract class MailService { + abstract send(options: SendMailOptions): Promise; +} diff --git a/src/shared/domain/usecase.ts b/src/shared/domain/usecase.ts deleted file mode 100644 index d928b94..0000000 --- a/src/shared/domain/usecase.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Either } from "@shared/helpers/either"; -import { DomainError } from "./domain.error"; - -export abstract class UseCase { - abstract exec(input: TInput): Either | Promise>; -} diff --git a/src/shared/domain/usecases/usecase.ts b/src/shared/domain/usecases/usecase.ts new file mode 100644 index 0000000..57305e6 --- /dev/null +++ b/src/shared/domain/usecases/usecase.ts @@ -0,0 +1,12 @@ +import { Either } from '@shared/helpers/either'; +import { DomainError } from '../errors/domain.error'; + +export abstract class UseCase< + TInput, + TOutput, + TError extends DomainError = Error, +> { + abstract exec( + input: TInput, + ): Either | Promise>; +} diff --git a/src/shared/presenter/models/base-entity.view-model.ts b/src/shared/presenter/models/base-entity.view-model.ts index b60fad0..cfafc79 100644 --- a/src/shared/presenter/models/base-entity.view-model.ts +++ b/src/shared/presenter/models/base-entity.view-model.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { BaseEntity } from '@shared/domain/base.entity'; +import { BaseEntity } from '@shared/domain/entities/base.entity'; import { UUID } from 'crypto'; export class BaseEntityViewModel { diff --git a/test/factories/mock-course.ts b/test/factories/mock-course.ts index 6870e63..c6fc294 100644 --- a/test/factories/mock-course.ts +++ b/test/factories/mock-course.ts @@ -1,10 +1,13 @@ -import { faker } from "@faker-js/faker"; -import { CourseEntity, CourseEntityCreateProps } from "@modules/course/domain/entities/course/course.entity"; -import { BaseEntityProps } from "@shared/domain/base.entity"; -import { MockUser } from "./mock-user"; -import { UUID } from "crypto"; -import { CourseViewModel } from "@modules/course/presenter/models/view-models/course.view-model"; -import { CreateCoursePayload } from "@modules/course/presenter/models/payloads/create-course.payload"; +import { faker } from '@faker-js/faker'; +import { + CourseEntity, + CourseEntityCreateProps, +} from '@modules/course/domain/entities/course/course.entity'; +import { CreateCoursePayload } from '@modules/course/presenter/models/payloads/create-course.payload'; +import { CourseViewModel } from '@modules/course/presenter/models/view-models/course.view-model'; +import { BaseEntityProps } from '@shared/domain/entities/base.entity'; +import { UUID } from 'crypto'; +import { MockUser } from './mock-user'; interface CreateMockCourseOverrideProps { override?: Partial; @@ -32,18 +35,19 @@ export class MockCourse { createdAt: faker.date.past(), updatedAt: faker.date.past(), ...entityPropsOverride, - } + }, ); if (course.isLeft()) { - throw new Error(`Mock Course error: ${ course.value }`) + throw new Error(`Mock Course error: ${course.value}`); } return course.value; } - - static createViewModel(override: CreateMockCourseOverrideProps = {}): CourseViewModel { + static createViewModel( + override: CreateMockCourseOverrideProps = {}, + ): CourseViewModel { const entity = MockCourse.createEntity(override); return new CourseViewModel(entity); @@ -55,6 +59,6 @@ export class MockCourse { description: faker.lorem.paragraph(), price: +faker.commerce.price(), instructorId: faker.string.uuid() as UUID, - } + }; } } diff --git a/test/factories/mock-enrollment.ts b/test/factories/mock-enrollment.ts index 9ae095c..d02c5dc 100644 --- a/test/factories/mock-enrollment.ts +++ b/test/factories/mock-enrollment.ts @@ -5,7 +5,7 @@ import { } from '@modules/course/domain/entities/enrollment/enrollment.entity'; import { EnrollStudentPayload } from '@modules/course/presenter/models/payloads/enroll-student.payload'; import { EnrollmentViewModel } from '@modules/course/presenter/models/view-models/enrollment.view-model'; -import { BaseEntityProps } from '@shared/domain/base.entity'; +import { BaseEntityProps } from '@shared/domain/entities/base.entity'; import { UUID } from 'crypto'; interface CreateMockEnrollmentOverrideProps { diff --git a/test/factories/mock-user.ts b/test/factories/mock-user.ts index 64066d5..98f793b 100644 --- a/test/factories/mock-user.ts +++ b/test/factories/mock-user.ts @@ -6,7 +6,7 @@ import { } from '@modules/user/domain/entities/user/user.entity'; import { CreateUserPayload } from '@modules/user/presenter/models/payloads/create-user.payload'; import { UserViewModel } from '@modules/user/presenter/models/view-models/user.view-model'; -import { BaseEntityProps } from '@shared/domain/base.entity'; +import { BaseEntityProps } from '@shared/domain/entities/base.entity'; import { UUID } from 'crypto'; interface CreateMockUserOverrideProps { diff --git a/test/factories/password-reset-mock.ts b/test/factories/password-reset-mock.ts index ec991dd..286e33b 100644 --- a/test/factories/password-reset-mock.ts +++ b/test/factories/password-reset-mock.ts @@ -3,7 +3,7 @@ import { PasswordResetEntity, PasswordResetEntityCreateProps, } from '@modules/password-reset/domain/entities/password-reset.entity'; -import { BaseEntityProps } from '@shared/domain/base.entity'; +import { BaseEntityProps } from '@shared/domain/entities/base.entity'; import { UUID } from 'crypto'; interface CreateMockPasswordResetOverrideProps { From 2f0230a5532140a2ffbebfdea9ace74872f054fc Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Sat, 23 Dec 2023 10:16:01 -0300 Subject: [PATCH 4/6] feat(password-reset): validating password reset by code --- .../repositories/password-reset.repository.ts | 1 + .../validate-password-reset.usecase.test.ts | 47 ------------------- .../validate-password-reset.usecase.ts | 9 +--- .../password-reset-typeorm.repository.ts | 18 ++++++- .../in-memory-password-reset-repository.ts | 16 +++++++ 5 files changed, 36 insertions(+), 55 deletions(-) diff --git a/src/modules/password-reset/domain/repositories/password-reset.repository.ts b/src/modules/password-reset/domain/repositories/password-reset.repository.ts index a440f81..37d517c 100644 --- a/src/modules/password-reset/domain/repositories/password-reset.repository.ts +++ b/src/modules/password-reset/domain/repositories/password-reset.repository.ts @@ -5,4 +5,5 @@ export abstract class PasswordResetRepository { abstract save(entity: PasswordResetEntity): Promise; abstract getById(id: UUID): Promise; abstract getValidByUserId(userId: UUID): Promise; + abstract getValidByCode(code: string): Promise; } diff --git a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts index 1cb7e49..cd52e73 100644 --- a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts @@ -33,7 +33,6 @@ describe('ValidatePasswordResetUseCase', () => { repository.save(passwordReset); const result = await usecase.exec({ - requestUser, code: passwordReset.code, }); @@ -43,54 +42,8 @@ describe('ValidatePasswordResetUseCase', () => { ).toBeTruthy(); }); - it('should return false if the code is not matching', async () => { - const requestUser = MockRequestUser.createEntity(); - const passwordReset = MockPasswordReset.createEntity({ - override: { - userId: requestUser.id, - }, - }); - - repository.save(passwordReset); - - const result = await usecase.exec({ - requestUser, - code: 'RANDOM_CODE', - }); - - expect(result.isRight()).toBeTruthy(); - expect( - (result.value as ValidatePasswordResetUseCaseOutput).matches, - ).toBeFalsy(); - }); - - it('should consider the code as case insensitive', async () => { - const requestUser = MockRequestUser.createEntity(); - const passwordReset = MockPasswordReset.createEntity({ - override: { - userId: requestUser.id, - code: 'ABC4DEF1', - }, - }); - - repository.save(passwordReset); - - const result = await usecase.exec({ - requestUser, - code: 'AbC4dEf1', - }); - - expect(result.isRight()).toBeTruthy(); - expect( - (result.value as ValidatePasswordResetUseCaseOutput).matches, - ).toBeTruthy(); - }); - it('should return a PasswordResetNotFoundError if a valid reset was not found', async () => { - const requestUser = MockRequestUser.createEntity(); - const result = await usecase.exec({ - requestUser, code: 'AbC4dEf1', }); diff --git a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts index c2f05b0..6db59ff 100644 --- a/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts @@ -1,4 +1,3 @@ -import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; import { Injectable } from '@nestjs/common'; import { Either, left, right } from '@shared/helpers/either'; import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; @@ -6,7 +5,6 @@ import { PasswordResetRepository } from '../../repositories/password-reset.repos import { UseCase } from '@shared/domain/usecases/usecase'; export interface ValidatePasswordResetUseCaseInput { - requestUser: RequestUserEntity; code: string; } @@ -28,7 +26,6 @@ export class ValidatePasswordResetUseCase constructor(private readonly repository: PasswordResetRepository) {} async exec({ - requestUser, code, }: ValidatePasswordResetUseCaseInput): Promise< Either< @@ -36,16 +33,14 @@ export class ValidatePasswordResetUseCase ValidatePasswordResetUseCaseOutput > > { - const passwordReset = await this.repository.getValidByUserId( - requestUser.id, - ); + const passwordReset = await this.repository.getValidByCode(code); if (!passwordReset) { return left(new PasswordResetNotFoundError()); } return right({ - matches: code.toLowerCase() === passwordReset.code.toLowerCase(), + matches: true, }); } } diff --git a/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts b/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts index 08ee037..0b7ac60 100644 --- a/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts +++ b/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts @@ -2,7 +2,7 @@ import { PasswordResetEntity } from '@modules/password-reset/domain/entities/pas import { PasswordResetRepository } from '@modules/password-reset/domain/repositories/password-reset.repository'; import { InjectRepository } from '@nestjs/typeorm'; import { UUID } from 'node:crypto'; -import { MoreThan, Repository } from 'typeorm'; +import { ILike, MoreThan, Repository } from 'typeorm'; import { TypeOrmPasswordResetMapper } from '../mappers/password-reset-typeorm.mapper'; import { PasswordResetSchema } from '../schemas/password-reset.schema'; @@ -47,4 +47,20 @@ export class TypeOrmPasswordResetRepository implements PasswordResetRepository { return TypeOrmPasswordResetMapper.toEntity(schema); } + + async getValidByCode(code: string): Promise { + const schema = await this.typeOrmRepository.findOne({ + where: { + code: ILike(code), + validUntil: MoreThan(new Date()), + used: false, + }, + }); + + if (!schema) { + return null; + } + + return TypeOrmPasswordResetMapper.toEntity(schema); + } } diff --git a/test/repositories/in-memory-password-reset-repository.ts b/test/repositories/in-memory-password-reset-repository.ts index 6e411e0..41ba25a 100644 --- a/test/repositories/in-memory-password-reset-repository.ts +++ b/test/repositories/in-memory-password-reset-repository.ts @@ -33,4 +33,20 @@ export class InMemoryPasswordResetRepository return entity; } + + async getValidByCode(code: string): Promise { + const entity = this.items.find((i) => { + return ( + i.code.toLowerCase() === code.toLowerCase() && + !i.used && + i.validUntil > new Date() + ); + }); + + if (!entity) { + return null; + } + + return entity; + } } From 743700db85d747a75e72fa0bbe723a649f66aca0 Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Sun, 24 Dec 2023 16:13:07 -0300 Subject: [PATCH 5/6] feat(password-reset): implementing password reset execute usecase --- .../domain/entities/password-reset.entity.ts | 4 + .../incorrect-password-reset-code.error.ts | 12 --- .../execute-password-reset.usecase.test.ts | 93 ++++++++++++++++--- .../execute/execute-password-reset.usecase.ts | 41 +++++--- 4 files changed, 115 insertions(+), 35 deletions(-) delete mode 100644 src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts diff --git a/src/modules/password-reset/domain/entities/password-reset.entity.ts b/src/modules/password-reset/domain/entities/password-reset.entity.ts index b326141..4c4dc12 100644 --- a/src/modules/password-reset/domain/entities/password-reset.entity.ts +++ b/src/modules/password-reset/domain/entities/password-reset.entity.ts @@ -85,4 +85,8 @@ export class PasswordResetEntity extends BaseEntity { public get used(): boolean { return this.props.used; } + + public setAsUsed(): void { + this.props.used = true; + } } diff --git a/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts b/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts deleted file mode 100644 index 98d626f..0000000 --- a/src/modules/password-reset/domain/errors/incorrect-password-reset-code.error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DomainError } from '@shared/domain/domain.error'; - -export class IncorrectPasswordResetCodeError - extends Error - implements DomainError -{ - constructor() { - super(`Password reset code confirmation failed`); - - this.name = 'IncorrectPasswordResetCodeError'; - } -} diff --git a/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts index 8287d39..32005c1 100644 --- a/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts @@ -1,17 +1,18 @@ import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { PasswordEncryptionService } from '@modules/user/domain/services/password-encryption.service'; -import { MockRequestUser } from 'test/factories/mock-request-user'; import { MockPasswordReset } from 'test/factories/password-reset-mock'; import { InMemoryPasswordResetRepository } from 'test/repositories/in-memory-password-reset-repository'; import { InMemoryRepository } from 'test/repositories/in-memory-repository'; import { InMemoryUserRepository } from 'test/repositories/in-memory-user-repository'; import { DeepMocked, createMock } from 'test/utils/create-mock'; import { PasswordResetEntity } from '../../entities/password-reset.entity'; -import { IncorrectPasswordResetCodeError } from '../../errors/incorrect-password-reset-code.error'; import { PasswordResetNotFoundError } from '../../errors/password-reset-not-found.error'; import { PasswordResetRepository } from '../../repositories/password-reset.repository'; import { ExecutePasswordResetUseCase } from './execute-password-reset.usecase'; +import { MockUser } from 'test/factories/mock-user'; +import { IncorrectOldPasswordError } from '../../errors/incorrect-old-password.error'; +import { UserNotFoundError } from '@modules/user/domain/errors/user-not-found.error'; describe('ExecutePasswordResetUseCase', () => { let usecase: ExecutePasswordResetUseCase; @@ -35,10 +36,7 @@ describe('ExecutePasswordResetUseCase', () => { }); it('should return a PasswordResetNotFoundError if a valid reset was not found', async () => { - const requestUser = MockRequestUser.createEntity(); - const result = await usecase.exec({ - requestUser, code: 'AbC4dEf1', oldPassword: '', newPassword: '', @@ -48,24 +46,95 @@ describe('ExecutePasswordResetUseCase', () => { expect(result.value).toBeInstanceOf(PasswordResetNotFoundError); }); - it('should return an IncorrectPasswordResetCodeError if the provided code does not match', async () => { - const requestUser = MockRequestUser.createEntity(); + it('should return an UserNotFoundError if the user does not exists', async () => { + const passwordReset = MockPasswordReset.createEntity(); + + repository.save(passwordReset); + + const result = await usecase.exec({ + code: passwordReset.code, + oldPassword: '', + newPassword: '', + }); + + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(UserNotFoundError); + }); + + it('should return an IncorrectOldPasswordError if the old password is incorrect', async () => { + const user = MockUser.createEntity(); + const passwordReset = MockPasswordReset.createEntity({ override: { - userId: requestUser.id, + userId: user.id, }, }); + userRepository.save(user); repository.save(passwordReset); + passwordEncryptionService.compare.mockResolvedValueOnce(false); + const result = await usecase.exec({ - requestUser, code: passwordReset.code, - oldPassword: '', - newPassword: '', + oldPassword: 'user-wrong-password', + newPassword: 'user-new-password', }); expect(result.isLeft()).toBeTruthy(); - expect(result.value).toBeInstanceOf(IncorrectPasswordResetCodeError); + expect(result.value).toBeInstanceOf(IncorrectOldPasswordError); + }); + + it('should invalidate password reset', async () => { + const user = MockUser.createEntity(); + + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: user.id, + }, + }); + + userRepository.save(user); + repository.save(passwordReset); + + passwordEncryptionService.compare.mockResolvedValueOnce(true); + + const result = await usecase.exec({ + code: passwordReset.code, + oldPassword: 'user-password', + newPassword: 'user-new-password', + }); + + expect(result.isRight()).toBeTruthy(); + expect(repository.items[0].used).toBeTruthy(); + }); + + it('should update user password', async () => { + const user = MockUser.createEntity(); + + const passwordReset = MockPasswordReset.createEntity({ + override: { + userId: user.id, + }, + }); + + userRepository.save(user); + repository.save(passwordReset); + + passwordEncryptionService.compare.mockResolvedValueOnce(true); + passwordEncryptionService.hash.mockResolvedValue( + 'user-hashed-new-password', + ); + + const result = await usecase.exec({ + code: passwordReset.code, + oldPassword: 'user-password', + newPassword: 'user-new-password', + }); + + expect(result.isRight()).toBeTruthy(); + expect(userRepository.items[0].password).toEqual( + 'user-hashed-new-password', + ); }); }); 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 0695708..c321e5e 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,16 +1,14 @@ -import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; 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/usecase'; import { Either, left, right } from '@shared/helpers/either'; import { IncorrectOldPasswordError } from '../../errors/incorrect-old-password.error'; -import { IncorrectPasswordResetCodeError } from '../../errors/incorrect-password-reset-code.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 { - requestUser: RequestUserEntity; code: string; oldPassword: string; newPassword: string; @@ -21,7 +19,7 @@ export interface ExecutePasswordResetUseCaseOutput {} export type ExecutePasswordResetUseCaseErrors = | PasswordResetNotFoundError | IncorrectOldPasswordError - | IncorrectPasswordResetCodeError; + | UserNotFoundError; @Injectable() export class ExecutePasswordResetUseCase @@ -39,23 +37,44 @@ export class ExecutePasswordResetUseCase ) {} async exec({ - requestUser, code, + oldPassword, + newPassword, }: ExecutePasswordResetUseCaseInput): Promise< Either > { - const passwordReset = await this.repository.getValidByUserId( - requestUser.id, - ); + const passwordReset = await this.repository.getValidByCode(code); if (!passwordReset) { return left(new PasswordResetNotFoundError()); } - if (code.toLowerCase() !== passwordReset.code) { - return left(new IncorrectPasswordResetCodeError()); + const user = await this.userRepository.getById(passwordReset.userId); + + if (!user) { + return left(new UserNotFoundError()); + } + + const isOldPasswordCorrect = await this.passwordEncryptionService.compare( + oldPassword, + user.password, + ); + + if (!isOldPasswordCorrect) { + return left(new IncorrectOldPasswordError()); } + passwordReset.setAsUsed(); + + await this.repository.save(passwordReset); + + const userNewPassword = + await this.passwordEncryptionService.hash(newPassword); + + user.password = userNewPassword; + + await this.userRepository.save(user); + return right({}); } } From 6d54fa60ff6b0e3a16fd51c3d1a10c98147769ce Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Sun, 24 Dec 2023 16:17:57 -0300 Subject: [PATCH 6/6] fix(errors): fixing imports at domain errors --- .../auth/domain/errors/incorrect-password.error.ts | 2 +- .../auth/domain/errors/user-not-found.error.ts | 2 +- .../course/domain/errors/course-not-found.error.ts | 10 +++++----- .../domain/errors/instructor-not-found.error.ts | 2 +- .../course/domain/errors/invalid-money.error.ts | 2 +- .../errors/student-already-enrolled.error.ts | 14 +++++++------- .../domain/errors/student-not-found.error.ts | 10 +++++----- .../domain/errors/incorrect-old-password.error.ts | 2 +- .../errors/password-reset-not-found.error.ts | 2 +- .../user/domain/errors/duplicated-email.error.ts | 2 +- .../user/domain/errors/invalid-email.error.ts | 2 +- .../user/domain/errors/invalid-name.error.ts | 2 +- .../user/domain/errors/user-not-found.error.ts | 2 +- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/modules/auth/domain/errors/incorrect-password.error.ts b/src/modules/auth/domain/errors/incorrect-password.error.ts index 48deaee..4a2a160 100644 --- a/src/modules/auth/domain/errors/incorrect-password.error.ts +++ b/src/modules/auth/domain/errors/incorrect-password.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class IncorrectPasswordError extends Error implements DomainError { constructor() { diff --git a/src/modules/auth/domain/errors/user-not-found.error.ts b/src/modules/auth/domain/errors/user-not-found.error.ts index deb7bd6..240bf51 100644 --- a/src/modules/auth/domain/errors/user-not-found.error.ts +++ b/src/modules/auth/domain/errors/user-not-found.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class UserNotFoundError extends Error implements DomainError { constructor(email: string) { diff --git a/src/modules/course/domain/errors/course-not-found.error.ts b/src/modules/course/domain/errors/course-not-found.error.ts index 1f03fea..9cb23f9 100644 --- a/src/modules/course/domain/errors/course-not-found.error.ts +++ b/src/modules/course/domain/errors/course-not-found.error.ts @@ -1,12 +1,12 @@ -import { DomainError } from '@shared/domain/domain.error' +import { DomainError } from '@shared/domain/errors/domain.error'; export class CourseNotFoundError extends Error implements DomainError { - public courseId: string + public courseId: string; constructor(courseId: string) { - super(`Course not found: ${courseId}`) + super(`Course not found: ${courseId}`); - this.courseId = courseId - this.name = 'CourseNotFoundError' + this.courseId = courseId; + this.name = 'CourseNotFoundError'; } } diff --git a/src/modules/course/domain/errors/instructor-not-found.error.ts b/src/modules/course/domain/errors/instructor-not-found.error.ts index 07c66e0..ceaf8f9 100644 --- a/src/modules/course/domain/errors/instructor-not-found.error.ts +++ b/src/modules/course/domain/errors/instructor-not-found.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class InstructorNotFoundError extends Error implements DomainError { public instructorId: string; diff --git a/src/modules/course/domain/errors/invalid-money.error.ts b/src/modules/course/domain/errors/invalid-money.error.ts index 0da279c..c0ddc73 100644 --- a/src/modules/course/domain/errors/invalid-money.error.ts +++ b/src/modules/course/domain/errors/invalid-money.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from "@shared/domain/domain.error"; +import { DomainError } from '@shared/domain/errors/domain.error'; export class InvalidMoneyError extends Error implements DomainError { constructor(money: number, currency: string) { diff --git a/src/modules/course/domain/errors/student-already-enrolled.error.ts b/src/modules/course/domain/errors/student-already-enrolled.error.ts index 0c40a5f..1d28af8 100644 --- a/src/modules/course/domain/errors/student-already-enrolled.error.ts +++ b/src/modules/course/domain/errors/student-already-enrolled.error.ts @@ -1,14 +1,14 @@ -import { DomainError } from '@shared/domain/domain.error' +import { DomainError } from '@shared/domain/errors/domain.error'; export class StudentAlreadyEnrolledError extends Error implements DomainError { - public studentId: string - public courseId: string + public studentId: string; + public courseId: string; constructor(studentId: string, courseId: string) { - super(`Enrollment already exists: ${studentId} at ${courseId}`) + super(`Enrollment already exists: ${studentId} at ${courseId}`); - this.studentId = studentId - this.courseId = courseId - this.name = 'StudentAlreadyEnrolledError' + this.studentId = studentId; + this.courseId = courseId; + this.name = 'StudentAlreadyEnrolledError'; } } diff --git a/src/modules/course/domain/errors/student-not-found.error.ts b/src/modules/course/domain/errors/student-not-found.error.ts index fed4d90..9b33996 100644 --- a/src/modules/course/domain/errors/student-not-found.error.ts +++ b/src/modules/course/domain/errors/student-not-found.error.ts @@ -1,12 +1,12 @@ -import { DomainError } from '@shared/domain/domain.error' +import { DomainError } from '@shared/domain/errors/domain.error'; export class StudentNotFoundError extends Error implements DomainError { - public studentId: string + public studentId: string; constructor(studentId: string) { - super(`Student not found: ${studentId}`) + super(`Student not found: ${studentId}`); - this.studentId = studentId - this.name = 'StudentNotFoundError' + this.studentId = studentId; + this.name = 'StudentNotFoundError'; } } diff --git a/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts b/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts index c5ce599..659b00e 100644 --- a/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts +++ b/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class IncorrectOldPasswordError extends Error implements DomainError { constructor() { diff --git a/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts b/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts index 9091402..4b6759e 100644 --- a/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts +++ b/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class PasswordResetNotFoundError extends Error implements DomainError { constructor() { diff --git a/src/modules/user/domain/errors/duplicated-email.error.ts b/src/modules/user/domain/errors/duplicated-email.error.ts index 1453c8c..8bebeb0 100644 --- a/src/modules/user/domain/errors/duplicated-email.error.ts +++ b/src/modules/user/domain/errors/duplicated-email.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; export class DuplicatedEmailError extends Error implements DomainError { constructor(email: string) { diff --git a/src/modules/user/domain/errors/invalid-email.error.ts b/src/modules/user/domain/errors/invalid-email.error.ts index 5184531..cb311b2 100644 --- a/src/modules/user/domain/errors/invalid-email.error.ts +++ b/src/modules/user/domain/errors/invalid-email.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from "@shared/domain/domain.error"; +import { DomainError } from '@shared/domain/errors/domain.error'; export class InvalidEmailError extends Error implements DomainError { constructor(email: string) { diff --git a/src/modules/user/domain/errors/invalid-name.error.ts b/src/modules/user/domain/errors/invalid-name.error.ts index 7724f22..0b61e31 100644 --- a/src/modules/user/domain/errors/invalid-name.error.ts +++ b/src/modules/user/domain/errors/invalid-name.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from "@shared/domain/domain.error"; +import { DomainError } from '@shared/domain/errors/domain.error'; export class InvalidNameError extends Error implements DomainError { constructor(name: string) { diff --git a/src/modules/user/domain/errors/user-not-found.error.ts b/src/modules/user/domain/errors/user-not-found.error.ts index 0921ebf..044fbbf 100644 --- a/src/modules/user/domain/errors/user-not-found.error.ts +++ b/src/modules/user/domain/errors/user-not-found.error.ts @@ -1,4 +1,4 @@ -import { DomainError } from '@shared/domain/domain.error'; +import { DomainError } from '@shared/domain/errors/domain.error'; import { UUID } from 'crypto'; export class UserNotFoundError extends Error implements DomainError {