From ac6ace4577d935998687337bbac96a39aef7c81a Mon Sep 17 00:00:00 2001 From: leonardo dimarchi <62081192+leonardodimarchi@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:52:00 -0300 Subject: [PATCH] feat(auth && user): implementing get profile endpoint --- .gitignore | 1 - .vscode/typescript.code-snippets | 62 +++++++++++++++++++ README.md | 2 +- package.json | 4 +- src/main.ts | 15 ++--- src/modules/auth/auth.module.ts | 6 +- .../entities/request-user.entity.test.ts | 20 ++++++ .../domain/entities/request-user.entity.ts | 33 ++++++++++ .../auth/infra/services/jwt.service.ts | 0 .../strategies/auth-jwt.strategy.test.ts | 23 +++++++ .../infra/strategies/auth-jwt.strategy.ts | 14 +++-- .../controllers/auth.controller.test.ts | 62 +++++++++++++++++-- .../presenter/controllers/auth.controller.ts | 52 +++++++++++++--- .../controllers/course.controller.ts | 33 ++++++---- .../domain/errors/user-not-found.error.ts | 10 +++ .../usecases/get-user-by-id.usecase.test.ts | 48 ++++++++++++++ .../domain/usecases/get-user-by-id.usecase.ts | 41 ++++++++++++ .../presenter/controllers/user.controller.ts | 15 ++--- src/modules/user/user.module.ts | 20 +++++- src/setup-swagger.ts | 13 ++++ test/factories/mock-request-user.ts | 30 +++++++++ 21 files changed, 439 insertions(+), 65 deletions(-) create mode 100644 .vscode/typescript.code-snippets create mode 100644 src/modules/auth/domain/entities/request-user.entity.test.ts create mode 100644 src/modules/auth/domain/entities/request-user.entity.ts delete mode 100644 src/modules/auth/infra/services/jwt.service.ts create mode 100644 src/modules/auth/infra/strategies/auth-jwt.strategy.test.ts create mode 100644 src/modules/user/domain/errors/user-not-found.error.ts create mode 100644 src/modules/user/domain/usecases/get-user-by-id.usecase.test.ts create mode 100644 src/modules/user/domain/usecases/get-user-by-id.usecase.ts create mode 100644 src/setup-swagger.ts create mode 100644 test/factories/mock-request-user.ts diff --git a/.gitignore b/.gitignore index 0b8ec4e..3ef23cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,5 @@ coverage/ .env postgres-data -.vscode .idea .eslintcache \ No newline at end of file diff --git a/.vscode/typescript.code-snippets b/.vscode/typescript.code-snippets new file mode 100644 index 0000000..87456fe --- /dev/null +++ b/.vscode/typescript.code-snippets @@ -0,0 +1,62 @@ +{ + // Place your backend-template workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + + "region": { + "prefix": "region", + "body": ["//#region $1", "", "$0", "", "//#endregion"] + }, + "entity": { + "prefix": "entity", + "body": [ + "export interface $1EntityProps {", + "}", + "", + "export type $1EntityCreateProps = Replace<", + " $1EntityProps,", + " {", + " }", + ">;", + "", + "export class $1Entity extends BaseEntity<$1EntityProps> {", + " private constructor(", + " props: $1EntityProps,", + " baseEntityProps?: BaseEntityProps,", + " ) {", + " super(props, baseEntityProps);", + " Object.freeze(this);", + " }", + "", + " static create(", + " {}: $1EntityCreateProps,", + " baseEntityProps?: BaseEntityProps,", + " ): Either {", + "", + "", + " return new Right(", + " new $1Entity(", + " {", + " },", + " baseEntityProps,", + " ),", + " );", + " }", + "", + "}" + ] + } +} diff --git a/README.md b/README.md index 0ef1c39..0df8ff5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This is where i'm going to place my ideas and things that i want to do or use in - [ ] Serverless adapter example - [ ] Add user reset password module - [x] Add some module to interact with user (such as course or quizzes) -- [ ] Auth module with passport +- [x] Auth module with passport - [ ] Validate request user permissions when calling a method (for example, an admin user cannot enroll at a course) - [x] Add base user roles such as Admin, Student and Instructor - [ ] Environment service diff --git a/package.json b/package.json index d523176..8114061 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "test": "jest --config ./test/jest.config.ts --maxWorkers=1", - "test:watch": "jest --watch --config ./test/jest.config.ts --maxWorkers=1", + "test": "jest --config ./test/jest.config.ts", + "test:watch": "jest --watch --config ./test/jest.config.ts", "test:cov": "jest --coverage --config ./test/jest.cov.config.ts", "test:e2e": "jest --config test/jest-e2e.ts", "test:clear-cache": "jest --clearCache", diff --git a/src/main.ts b/src/main.ts index 52776c7..84eed36 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ -import { AppModule } from './app.module'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'; import { NestFactory } from '@nestjs/core'; +import { I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'; +import { AppModule } from './app.module'; +import { setupSwagger } from './setup-swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -9,14 +9,7 @@ async function bootstrap() { app.useGlobalPipes(new I18nValidationPipe()); app.useGlobalFilters(new I18nValidationExceptionFilter()); - const config = new DocumentBuilder() - .setTitle('Backend Template') - .setDescription('This is a backend template for REST APIs') - .addBearerAuth() - .build(); - - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + setupSwagger(app); await app.listen(3000); } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index af7d97e..eeccd58 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,7 +1,6 @@ import { UserRepository } from '@modules/user/domain/repositories/user.repository'; import { PasswordEncryptionService } from '@modules/user/domain/services/password-encryption.service'; -import { UserDatabaseModule } from '@modules/user/infra/database/user-database.module'; -import { UserServiceModule } from '@modules/user/infra/services/user-services.module'; +import { UserModule } from '@modules/user/user.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { LoginUseCase } from './domain/usecases/login.usecase'; @@ -11,8 +10,7 @@ import { AuthController } from './presenter/controllers/auth.controller'; @Module({ imports: [ - UserDatabaseModule, - UserServiceModule, + UserModule, JwtModule.register({ secret: 'SECRET', signOptions: { expiresIn: '60s' }, diff --git a/src/modules/auth/domain/entities/request-user.entity.test.ts b/src/modules/auth/domain/entities/request-user.entity.test.ts new file mode 100644 index 0000000..a427d6d --- /dev/null +++ b/src/modules/auth/domain/entities/request-user.entity.test.ts @@ -0,0 +1,20 @@ +import { faker } from '@faker-js/faker'; +import { Right } from '@shared/helpers/either'; +import { RequestUserEntity } from './request-user.entity'; + +describe('RequestUserEntity', () => { + it('should be able to instantiate', () => { + const id = faker.string.uuid(); + const email = faker.internet.email(); + + const entity = RequestUserEntity.create({ + id, + email, + }); + + expect(entity).toBeInstanceOf(Right); + + expect((entity.value as RequestUserEntity).id).toBe(id); + expect((entity.value as RequestUserEntity).email).toBe(email); + }); +}); diff --git a/src/modules/auth/domain/entities/request-user.entity.ts b/src/modules/auth/domain/entities/request-user.entity.ts new file mode 100644 index 0000000..d29b376 --- /dev/null +++ b/src/modules/auth/domain/entities/request-user.entity.ts @@ -0,0 +1,33 @@ +import { Either, Right } from '@shared/helpers/either'; +import { UUID } from 'crypto'; + +export interface RequestUserEntityProps { + id: UUID; + email: string; +} + +export type RequestUserEntityCreateProps = RequestUserEntityProps; + +export class RequestUserEntity { + private constructor(props: RequestUserEntityProps) { + this.props = props; + Object.freeze(this); + } + + static create({ + id, + email, + }: RequestUserEntityCreateProps): Either { + return new Right(new RequestUserEntity({ id, email })); + } + + private props: RequestUserEntityProps; + + get id(): UUID { + return this.props.id; + } + + get email(): string { + return this.props.email; + } +} diff --git a/src/modules/auth/infra/services/jwt.service.ts b/src/modules/auth/infra/services/jwt.service.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/auth/infra/strategies/auth-jwt.strategy.test.ts b/src/modules/auth/infra/strategies/auth-jwt.strategy.test.ts new file mode 100644 index 0000000..7df1db9 --- /dev/null +++ b/src/modules/auth/infra/strategies/auth-jwt.strategy.test.ts @@ -0,0 +1,23 @@ +import { faker } from '@faker-js/faker'; +import { JwtStrategy } from './auth-jwt.strategy'; + +describe('AuthJwtStrategy', () => { + let strategy: JwtStrategy; + + beforeEach(() => { + strategy = new JwtStrategy(); + }); + + it('should return a request user entity', () => { + const id = faker.number.int(); + const email = faker.internet.email(); + + const result = strategy.validate({ + sub: id, + email, + }); + + expect(result.id).toBe(id); + expect(result.email).toBe(email); + }); +}); diff --git a/src/modules/auth/infra/strategies/auth-jwt.strategy.ts b/src/modules/auth/infra/strategies/auth-jwt.strategy.ts index 4d7a061..aabe92c 100644 --- a/src/modules/auth/infra/strategies/auth-jwt.strategy.ts +++ b/src/modules/auth/infra/strategies/auth-jwt.strategy.ts @@ -1,5 +1,7 @@ +import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import { UUID } from 'crypto'; import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() @@ -12,10 +14,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - public validate(payload: { sub: number; email: string }): { - id: number; - email: string; - } { - return { id: payload.sub, email: payload.email }; + public validate(payload: { sub: UUID; email: string }): RequestUserEntity { + const entityResult = RequestUserEntity.create({ + id: payload.sub, + email: payload.email, + }); + + return entityResult.value as RequestUserEntity; } } diff --git a/src/modules/auth/presenter/controllers/auth.controller.test.ts b/src/modules/auth/presenter/controllers/auth.controller.test.ts index 06efdf5..6407c10 100644 --- a/src/modules/auth/presenter/controllers/auth.controller.test.ts +++ b/src/modules/auth/presenter/controllers/auth.controller.test.ts @@ -1,16 +1,27 @@ +import { UserNotFoundError } from '@modules/auth/domain/errors/user-not-found.error'; +import { GetUserByIdUseCase } from '@modules/user/domain/usecases/get-user-by-id.usecase'; import { UserViewModel } from '@modules/user/presenter/models/view-models/user.view-model'; +import { + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { Left, Right } from '@shared/helpers/either'; +import { MockRequestUser } from 'test/factories/mock-request-user'; import { MockUser } from 'test/factories/mock-user'; +import { createI18nMock } from 'test/utils/create-i18n-mock'; import { DeepMocked, createMock } from 'test/utils/create-mock'; import { AuthController } from './auth.controller'; describe('AuthController', () => { let controller: AuthController; let jwtService: DeepMocked; + let getUserByIdUseCase: DeepMocked; beforeEach(() => { jwtService = createMock(); - controller = new AuthController(jwtService); + getUserByIdUseCase = createMock(); + controller = new AuthController(jwtService, getUserByIdUseCase); }); describe('login', () => { @@ -36,13 +47,52 @@ describe('AuthController', () => { }); describe('getProfile', () => { - it('should return the request user view-model', () => { - const requestUser = MockUser.createEntity(); - const requestUserViewModel = new UserViewModel(requestUser); + it('should return the request user', async () => { + const requestUserEntity = MockRequestUser.createEntity(); + + const userEntity = MockUser.createEntity({ + override: { + email: requestUserEntity.email, + }, + basePropsOverride: { id: requestUserEntity.id }, + }); + + const userViewModel = new UserViewModel(userEntity); + + getUserByIdUseCase.exec.mockResolvedValueOnce( + new Right({ user: userEntity }), + ); + + const result = await controller.getProfile( + requestUserEntity, + createI18nMock(), + ); + + expect(result).toEqual(userViewModel); + }); + + it('should throw a not found exception if the user was not found', async () => { + const requestUserEntity = MockRequestUser.createEntity(); + + getUserByIdUseCase.exec.mockResolvedValueOnce( + new Left(new UserNotFoundError(requestUserEntity.id)), + ); + + const call = async () => + await controller.getProfile(requestUserEntity, createI18nMock()); + + expect(call).rejects.toThrow(NotFoundException); + }); + + it('should throw a internal server exception if there is some unknown error', async () => { + const requestUserEntity = MockRequestUser.createEntity(); + + getUserByIdUseCase.exec.mockResolvedValueOnce(new Left(new Error())); - const result = controller.getProfile(requestUser); + const call = async () => + await controller.getProfile(requestUserEntity, createI18nMock()); - expect(result).toEqual(requestUserViewModel); + expect(call).rejects.toThrow(InternalServerErrorException); }); }); }); diff --git a/src/modules/auth/presenter/controllers/auth.controller.ts b/src/modules/auth/presenter/controllers/auth.controller.ts index 8ff18ee..3d28073 100644 --- a/src/modules/auth/presenter/controllers/auth.controller.ts +++ b/src/modules/auth/presenter/controllers/auth.controller.ts @@ -1,24 +1,40 @@ +import { RequestUserEntity } from '@modules/auth/domain/entities/request-user.entity'; +import { UserNotFoundError } from '@modules/auth/domain/errors/user-not-found.error'; import { AuthJwtGuard } from '@modules/auth/infra/guards/auth-jwt.guard'; import { AuthLocalGuard } from '@modules/auth/infra/guards/auth-local.guard'; import { UserEntity } from '@modules/user/domain/entities/user/user.entity'; +import { GetUserByIdUseCase } from '@modules/user/domain/usecases/get-user-by-id.usecase'; import { UserViewModel } from '@modules/user/presenter/models/view-models/user.view-model'; -import { Controller, UseGuards, Post, Get } from '@nestjs/common'; +import { + Controller, + Get, + InternalServerErrorException, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { - ApiTags, + ApiBearerAuth, ApiBody, - ApiOperation, - ApiOkResponse, ApiHeader, + ApiOkResponse, + ApiOperation, + ApiTags, } from '@nestjs/swagger'; import { RequestUser } from '@shared/presenter/decorators/request-user.decorator'; +import { I18n, I18nContext } from 'nestjs-i18n'; +import { I18nTranslations } from 'src/generated/i18n.generated'; import { LoginPayload } from '../models/payloads/login.payload'; import { JwtViewModel } from '../models/view-models/jwt.view-model'; @ApiTags('Auth') @Controller('auth') export class AuthController { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private readonly getUserByIdUseCase: GetUserByIdUseCase, + ) {} @UseGuards(AuthLocalGuard) @Post('login') @@ -31,7 +47,7 @@ export class AuthController { type: JwtViewModel, }) @ApiOperation({ summary: 'Generate a JWT for authentication' }) - @ApiHeader({ name: 'Accept-Language', example: 'en', required: true }) + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) async login(@RequestUser() requestUser: UserEntity): Promise { const accessToken = await this.jwtService.signAsync({ email: requestUser.email.value, @@ -43,15 +59,31 @@ export class AuthController { }; } - @UseGuards(AuthJwtGuard) @Get('profile') + @UseGuards(AuthJwtGuard) + @ApiBearerAuth() @ApiOkResponse({ description: 'The logged user profile has returned with success', type: UserViewModel, }) @ApiOperation({ summary: 'Returns logged user profile' }) - @ApiHeader({ name: 'Accept-Language', example: 'en', required: true }) - getProfile(@RequestUser() requestUser: UserEntity): UserViewModel { - return new UserViewModel(requestUser); + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) + async getProfile( + @RequestUser() requestUser: RequestUserEntity, + @I18n() i18n: I18nContext, + ): Promise { + const userResult = await this.getUserByIdUseCase.exec({ + id: requestUser.id, + }); + + if (userResult.isRight()) { + return new UserViewModel(userResult.value.user); + } + + if (userResult.value instanceof UserNotFoundError) { + throw new NotFoundException(i18n.t('auth.errors.user-not-found')); + } + + throw new InternalServerErrorException(); } } diff --git a/src/modules/course/presenter/controllers/course.controller.ts b/src/modules/course/presenter/controllers/course.controller.ts index b7c0102..2bf7d0c 100644 --- a/src/modules/course/presenter/controllers/course.controller.ts +++ b/src/modules/course/presenter/controllers/course.controller.ts @@ -1,3 +1,11 @@ +import { AuthJwtGuard } from '@modules/auth/infra/guards/auth-jwt.guard'; +import { CourseNotFoundError } from '@modules/course/domain/errors/course-not-found.error'; +import { InstructorNotFoundError } from '@modules/course/domain/errors/instructor-not-found.error'; +import { InvalidMoneyError } from '@modules/course/domain/errors/invalid-money.error'; +import { StudentAlreadyEnrolledError } from '@modules/course/domain/errors/student-already-enrolled.error'; +import { StudentNotFoundError } from '@modules/course/domain/errors/student-not-found.error'; +import { CreateCourseUseCase } from '@modules/course/domain/usecases/create-course.usecase'; +import { EnrollStudentInCourseUseCase } from '@modules/course/domain/usecases/enroll-student-in-course.usecase'; import { BadRequestException, Body, @@ -7,8 +15,10 @@ import { InternalServerErrorException, NotFoundException, Post, + UseGuards, } from '@nestjs/common'; import { + ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, @@ -17,17 +27,10 @@ import { } from '@nestjs/swagger'; import { I18n, I18nContext } from 'nestjs-i18n'; import { I18nTranslations } from 'src/generated/i18n.generated'; -import { CreateCourseUseCase } from '@modules/course/domain/usecases/create-course.usecase'; -import { CourseViewModel } from '../models/view-models/course.view-model'; import { CreateCoursePayload } from '../models/payloads/create-course.payload'; -import { InvalidMoneyError } from '@modules/course/domain/errors/invalid-money.error'; -import { InstructorNotFoundError } from '@modules/course/domain/errors/instructor-not-found.error'; -import { EnrollmentViewModel } from '../models/view-models/enrollment.view-model'; import { EnrollStudentPayload } from '../models/payloads/enroll-student.payload'; -import { EnrollStudentInCourseUseCase } from '@modules/course/domain/usecases/enroll-student-in-course.usecase'; -import { CourseNotFoundError } from '@modules/course/domain/errors/course-not-found.error'; -import { StudentNotFoundError } from '@modules/course/domain/errors/student-not-found.error'; -import { StudentAlreadyEnrolledError } from '@modules/course/domain/errors/student-already-enrolled.error'; +import { CourseViewModel } from '../models/view-models/course.view-model'; +import { EnrollmentViewModel } from '../models/view-models/enrollment.view-model'; @ApiTags('Courses') @Controller('courses') @@ -37,15 +40,17 @@ export class CourseController { private readonly enrollStudentInCourseUseCase: EnrollStudentInCourseUseCase, ) {} + @Post() + @UseGuards(AuthJwtGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Creates a new course' }) - @ApiHeader({ name: 'Accept-Language', example: 'en', required: true }) + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) @ApiBody({ required: true, type: CreateCoursePayload, description: 'Course information', }) @ApiResponse({ status: HttpStatus.CREATED, type: CourseViewModel }) - @Post() async create( @Body() payload: CreateCoursePayload, @I18n() i18n: I18nContext, @@ -83,10 +88,12 @@ export class CourseController { throw new InternalServerErrorException(); } + @Post('enroll') + @UseGuards(AuthJwtGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Enroll a student at a course' }) - @ApiHeader({ name: 'Accept-Language', example: 'en', required: true }) + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) @ApiResponse({ status: HttpStatus.CREATED, type: EnrollmentViewModel }) - @Post('enroll') async enrollStudent( @Body() payload: EnrollStudentPayload, @I18n() i18n: I18nContext, diff --git a/src/modules/user/domain/errors/user-not-found.error.ts b/src/modules/user/domain/errors/user-not-found.error.ts new file mode 100644 index 0000000..9fc6c7b --- /dev/null +++ b/src/modules/user/domain/errors/user-not-found.error.ts @@ -0,0 +1,10 @@ +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}`); + + this.name = 'UserNotFoundError'; + } +} diff --git a/src/modules/user/domain/usecases/get-user-by-id.usecase.test.ts b/src/modules/user/domain/usecases/get-user-by-id.usecase.test.ts new file mode 100644 index 0000000..6004896 --- /dev/null +++ b/src/modules/user/domain/usecases/get-user-by-id.usecase.test.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker'; +import { Left, Right } from '@shared/helpers/either'; +import { UUID } from 'crypto'; +import { MockUser } from 'test/factories/mock-user'; +import { InMemoryRepository } from 'test/repositories/in-memory-repository'; +import { InMemoryUserRepository } from 'test/repositories/in-memory-user-repository'; +import { UserEntity } from '../entities/user/user.entity'; +import { UserNotFoundError } from '../errors/user-not-found.error'; +import { UserRepository } from '../repositories/user.repository'; +import { + GetUserByIdUseCase, + GetUserByIdUseCaseOutput, +} from './get-user-by-id.usecase'; + +describe('GetUserByIdUseCase', () => { + let usecase: GetUserByIdUseCase; + let repository: InMemoryRepository; + + beforeEach(() => { + repository = new InMemoryUserRepository(); + usecase = new GetUserByIdUseCase(repository); + }); + + it('should return the user', async () => { + const id = faker.string.uuid() as UUID; + + const existingUser = MockUser.createEntity({ basePropsOverride: { id } }); + + await repository.save(existingUser); + + const result = await usecase.exec({ id }); + + expect(result).toBeInstanceOf(Right); + expect((result.value as GetUserByIdUseCaseOutput).user.id).toBe(id); + expect((result.value as GetUserByIdUseCaseOutput).user.email).toBe( + existingUser.email, + ); + }); + + it('should throw an error if the user was not found', async () => { + const result = await usecase.exec({ + id: faker.string.uuid() as UUID, + }); + + expect(result).toBeInstanceOf(Left); + expect(result.value).toBeInstanceOf(UserNotFoundError); + }); +}); 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 new file mode 100644 index 0000000..8c62017 --- /dev/null +++ b/src/modules/user/domain/usecases/get-user-by-id.usecase.ts @@ -0,0 +1,41 @@ +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'; + +export interface GetUserByIdUseCaseInput { + id: UUID; +} + +export interface GetUserByIdUseCaseOutput { + user: UserEntity; +} + +export type GetUserByIdUseCaseErrors = Error; + +export class GetUserByIdUseCase + implements + UseCase< + GetUserByIdUseCaseInput, + GetUserByIdUseCaseOutput, + GetUserByIdUseCaseErrors + > +{ + constructor(private readonly repository: UserRepository) {} + + async exec({ + id, + }: GetUserByIdUseCaseInput): Promise< + Either + > { + const user = await this.repository.getById(id); + + if (!user) { + return new Left(new UserNotFoundError(id)); + } + + return new Right({ user }); + } +} diff --git a/src/modules/user/presenter/controllers/user.controller.ts b/src/modules/user/presenter/controllers/user.controller.ts index 4a441be..5d61dc1 100644 --- a/src/modules/user/presenter/controllers/user.controller.ts +++ b/src/modules/user/presenter/controllers/user.controller.ts @@ -1,3 +1,6 @@ +import { DuplicatedEmailError } from '@modules/user/domain/errors/duplicated-email.error'; +import { InvalidEmailError } from '@modules/user/domain/errors/invalid-email.error'; +import { InvalidNameError } from '@modules/user/domain/errors/invalid-name.error'; import { CreateUserUseCase } from '@modules/user/domain/usecases/create-user.usecase'; import { BadRequestException, @@ -7,26 +10,20 @@ import { HttpStatus, InternalServerErrorException, Post, - UseGuards, } from '@nestjs/common'; -import { UserViewModel } from '../models/view-models/user.view-model'; -import { CreateUserPayload } from '../models/payloads/create-user.payload'; import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { DuplicatedEmailError } from '@modules/user/domain/errors/duplicated-email.error'; -import { InvalidEmailError } from '@modules/user/domain/errors/invalid-email.error'; -import { InvalidNameError } from '@modules/user/domain/errors/invalid-name.error'; import { I18n, I18nContext } from 'nestjs-i18n'; import { I18nTranslations } from 'src/generated/i18n.generated'; -import { AuthJwtGuard } from '@modules/auth/infra/guards/auth-jwt.guard'; +import { CreateUserPayload } from '../models/payloads/create-user.payload'; +import { UserViewModel } from '../models/view-models/user.view-model'; @ApiTags('Users') @Controller('users') export class UserController { constructor(private readonly createUserUseCase: CreateUserUseCase) {} - @UseGuards(AuthJwtGuard) @ApiOperation({ summary: 'Creates a new user' }) - @ApiHeader({ name: 'Accept-Language', example: 'en', required: true }) + @ApiHeader({ name: 'Accept-Language', example: 'en', required: false }) @ApiResponse({ status: HttpStatus.CREATED, type: UserViewModel }) @Post() async create( diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 7d95505..d8b5efa 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; -import { UserDatabaseModule } from './infra/database/user-database.module'; -import { UserController } from './presenter/controllers/user.controller'; -import { CreateUserUseCase } from './domain/usecases/create-user.usecase'; import { UserRepository } from './domain/repositories/user.repository'; import { PasswordEncryptionService } from './domain/services/password-encryption.service'; +import { CreateUserUseCase } from './domain/usecases/create-user.usecase'; +import { GetUserByIdUseCase } from './domain/usecases/get-user-by-id.usecase'; +import { UserDatabaseModule } from './infra/database/user-database.module'; import { UserServiceModule } from './infra/services/user-services.module'; +import { UserController } from './presenter/controllers/user.controller'; @Module({ imports: [UserDatabaseModule, UserServiceModule], @@ -20,6 +21,19 @@ import { UserServiceModule } from './infra/services/user-services.module'; }, inject: [UserRepository, PasswordEncryptionService], }, + { + provide: GetUserByIdUseCase, + useFactory: (repository: UserRepository) => { + return new GetUserByIdUseCase(repository); + }, + inject: [UserRepository], + }, + ], + exports: [ + UserDatabaseModule, + UserServiceModule, + CreateUserUseCase, + GetUserByIdUseCase, ], }) export class UserModule {} diff --git a/src/setup-swagger.ts b/src/setup-swagger.ts new file mode 100644 index 0000000..9dc13bb --- /dev/null +++ b/src/setup-swagger.ts @@ -0,0 +1,13 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +export function setupSwagger(app: INestApplication) { + const config = new DocumentBuilder() + .setTitle('Backend Template') + .setDescription('This is a backend template for REST APIs') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); +} diff --git a/test/factories/mock-request-user.ts b/test/factories/mock-request-user.ts new file mode 100644 index 0000000..c4f121d --- /dev/null +++ b/test/factories/mock-request-user.ts @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker'; +import { + RequestUserEntity, + RequestUserEntityCreateProps, +} from '@modules/auth/domain/entities/request-user.entity'; +import { UUID } from 'crypto'; + +interface CreateMockRequestUserOverrideProps { + override?: Partial; +} + +export class MockRequestUser { + static createEntity({ + override, + }: CreateMockRequestUserOverrideProps = {}): RequestUserEntity { + const overrideProps = override ?? {}; + + const user = RequestUserEntity.create({ + id: faker.string.uuid() as UUID, + email: faker.internet.email(), + ...overrideProps, + }); + + if (user.isLeft()) { + throw new Error(`Mock request user error: ${user.value}`); + } + + return user.value; + } +}