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/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/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/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 new file mode 100644 index 0000000..4c4dc12 --- /dev/null +++ b/src/modules/password-reset/domain/entities/password-reset.entity.ts @@ -0,0 +1,92 @@ +import { UserEntity } from '@modules/user/domain/entities/user/user.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'; + +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; + } + + public setAsUsed(): void { + this.props.used = true; + } +} 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..659b00e --- /dev/null +++ b/src/modules/password-reset/domain/errors/incorrect-old-password.error.ts @@ -0,0 +1,9 @@ +import { DomainError } from '@shared/domain/errors/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/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..4b6759e --- /dev/null +++ b/src/modules/password-reset/domain/errors/password-reset-not-found.error.ts @@ -0,0 +1,9 @@ +import { DomainError } from '@shared/domain/errors/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..37d517c --- /dev/null +++ b/src/modules/password-reset/domain/repositories/password-reset.repository.ts @@ -0,0 +1,9 @@ +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; + abstract getValidByCode(code: string): 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..32005c1 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.test.ts @@ -0,0 +1,140 @@ +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 { 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 { 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; + 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 result = await usecase.exec({ + code: 'AbC4dEf1', + oldPassword: '', + newPassword: '', + }); + + expect(result.isLeft()).toBeTruthy(); + expect(result.value).toBeInstanceOf(PasswordResetNotFoundError); + }); + + 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: user.id, + }, + }); + + userRepository.save(user); + repository.save(passwordReset); + + passwordEncryptionService.compare.mockResolvedValueOnce(false); + + const result = await usecase.exec({ + code: passwordReset.code, + oldPassword: 'user-wrong-password', + newPassword: 'user-new-password', + }); + + expect(result.isLeft()).toBeTruthy(); + 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 new file mode 100644 index 0000000..c321e5e --- /dev/null +++ b/src/modules/password-reset/domain/usecases/execute/execute-password-reset.usecase.ts @@ -0,0 +1,80 @@ +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 { 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; + oldPassword: string; + newPassword: string; +} + +export interface ExecutePasswordResetUseCaseOutput {} + +export type ExecutePasswordResetUseCaseErrors = + | PasswordResetNotFoundError + | IncorrectOldPasswordError + | UserNotFoundError; + +@Injectable() +export class ExecutePasswordResetUseCase + implements + UseCase< + ExecutePasswordResetUseCaseInput, + ExecutePasswordResetUseCaseOutput, + ExecutePasswordResetUseCaseErrors + > +{ + constructor( + private readonly repository: PasswordResetRepository, + private readonly userRepository: UserRepository, + private readonly passwordEncryptionService: PasswordEncryptionService, + ) {} + + async exec({ + code, + oldPassword, + newPassword, + }: ExecutePasswordResetUseCaseInput): Promise< + Either + > { + const passwordReset = await this.repository.getValidByCode(code); + + if (!passwordReset) { + return left(new PasswordResetNotFoundError()); + } + + 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({}); + } +} 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..ac66be9 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.test.ts @@ -0,0 +1,98 @@ +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 { + 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; + + let repository: InMemoryRepository< + PasswordResetRepository, + PasswordResetEntity + >; + let userRepository: InMemoryRepository; + let mailService: DeepMocked; + + beforeEach(() => { + repository = new InMemoryPasswordResetRepository(); + userRepository = new InMemoryUserRepository(); + mailService = createMock(); + usecase = new RequestPasswordResetUseCase( + repository, + userRepository, + mailService, + ); + }); + + 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({ + email, + }); + + expect(repository.items).toHaveLength(1); + expect(result.isRight()).toBeTruthy(); + expect( + (result.value as RequestPasswordResetUseCaseOutput).createdPasswordReset, + ).toBeInstanceOf(PasswordResetEntity); + }); + + 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(0); + 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 new file mode 100644 index 0000000..9646ec4 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/request/request-password-reset.usecase.ts @@ -0,0 +1,67 @@ +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 { 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; +} + +export interface RequestPasswordResetUseCaseOutput { + createdPasswordReset: PasswordResetEntity; +} + +export type RequestPasswordResetUseCaseErrors = UserNotFoundError | Error; + +@Injectable() +export class RequestPasswordResetUseCase + implements + UseCase< + RequestPasswordResetUseCaseInput, + RequestPasswordResetUseCaseOutput, + RequestPasswordResetUseCaseErrors + > +{ + constructor( + private readonly repository: PasswordResetRepository, + private readonly userRepository: UserRepository, + private readonly mailService: MailService, + ) {} + + async exec({ + email, + }: RequestPasswordResetUseCaseInput): Promise< + Either + > { + const user = await this.userRepository.getByEmail(email); + + if (!user) { + return new Left(new UserNotFoundError()); + } + + const passwordResetResult = PasswordResetEntity.create({ + userId: user.id, + }); + + if (passwordResetResult.isLeft()) { + return new Left(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 }); + } +} 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..cd52e73 --- /dev/null +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.test.ts @@ -0,0 +1,53 @@ +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({ + code: passwordReset.code, + }); + + 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 result = await usecase.exec({ + 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..6db59ff --- /dev/null +++ b/src/modules/password-reset/domain/usecases/validate/validate-password-reset.usecase.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +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 { + 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({ + code, + }: ValidatePasswordResetUseCaseInput): Promise< + Either< + ValidatePasswordResetUseCaseErrors, + ValidatePasswordResetUseCaseOutput + > + > { + const passwordReset = await this.repository.getValidByCode(code); + + if (!passwordReset) { + return left(new PasswordResetNotFoundError()); + } + + return right({ + matches: true, + }); + } +} 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..0b7ac60 --- /dev/null +++ b/src/modules/password-reset/infra/database/typeorm/repositories/password-reset-typeorm.repository.ts @@ -0,0 +1,66 @@ +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 { ILike, 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); + } + + 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/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..47faac0 --- /dev/null +++ b/src/modules/password-reset/password-reset.module.ts @@ -0,0 +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: [ + RequestPasswordResetUseCase, + ValidatePasswordResetUseCase, + ExecutePasswordResetUseCase, + ], +}) +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/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/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 9fc6c7b..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,9 +1,9 @@ -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 { - constructor(id: UUID) { - super(`User not found: ${id}`); + constructor(id?: UUID) { + super(`User not found${id ? ': ' + id : ''}`); this.name = 'UserNotFoundError'; } 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/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/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 new file mode 100644 index 0000000..286e33b --- /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/entities/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..41ba25a --- /dev/null +++ b/test/repositories/in-memory-password-reset-repository.ts @@ -0,0 +1,52 @@ +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; + } + + 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; + } +} 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