diff --git a/prisma/migrations/20221213134247_init/migration.sql b/prisma/migrations/20221213134247_init/migration.sql deleted file mode 100644 index bd8896f..0000000 --- a/prisma/migrations/20221213134247_init/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- CreateTable -CREATE TABLE "user" ( - "uuid" UUID NOT NULL, - "firstName" TEXT NOT NULL, - "lastName" TEXT NOT NULL, - "email" TEXT NOT NULL -); - --- CreateIndex -CREATE UNIQUE INDEX "user_uuid_key" ON "user"("uuid"); diff --git a/prisma/migrations/20221214110358_init/migration.sql b/prisma/migrations/20221214110358_init/migration.sql new file mode 100644 index 0000000..eea1424 --- /dev/null +++ b/prisma/migrations/20221214110358_init/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "user" ( + "uuid" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_pkey" PRIMARY KEY ("uuid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 990312b..5c56f48 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,10 +11,12 @@ datasource db { } model User { - uuid String @unique @default(uuid()) @db.Uuid + uuid String @id @default(uuid()) @db.Uuid firstName String lastName String - email String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("user") } diff --git a/src/app.module.ts b/src/app.module.ts index 3d6b766..b919317 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,15 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { UsersModule } from './modules/users/users.module'; @Module({ - imports: [ConfigModule.forRoot({ isGlobal: true }), UsersModule], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + AutomapperModule.forRoot({ strategyInitializer: classes() }), + UsersModule, + ], controllers: [], providers: [], }) diff --git a/src/modules/database/src/exceptions/DatabaseException.ts b/src/modules/database/src/exceptions/DatabaseException.ts index aa472bd..b0782a6 100644 --- a/src/modules/database/src/exceptions/DatabaseException.ts +++ b/src/modules/database/src/exceptions/DatabaseException.ts @@ -9,7 +9,6 @@ export class DatabaseException implements Error { ) { this.name = 'DatabaseException'; this.message = message ?? 'An error occured with the database.'; - if (this.message.includes('Unique constraint failed')) { this.message = 'Already exists.'; } diff --git a/src/modules/users/adapters/primaries/user.proto b/src/modules/users/adapters/primaries/user.proto index c894ef9..6955c19 100644 --- a/src/modules/users/adapters/primaries/user.proto +++ b/src/modules/users/adapters/primaries/user.proto @@ -5,7 +5,9 @@ package user; service UsersService { rpc FindOneByUuid(UserByUuid) returns (User); rpc FindAll(UserFilter) returns (Users); - rpc Create(CreateUser) returns (User); + rpc Create(User) returns (User); + rpc Update(User) returns (User); + rpc Delete(UserByUuid) returns (Empty); } message UserByUuid { @@ -19,15 +21,10 @@ message User { string email = 4; } -message CreateUser { - string uuid = 1; - string firstName = 2; - string lastName = 3; - string email = 4; -} - message UserFilter {} message Users { repeated User users = 1; } + +message Empty {} diff --git a/src/modules/users/adapters/primaries/users.controller.ts b/src/modules/users/adapters/primaries/users.controller.ts index 8885642..df5636f 100644 --- a/src/modules/users/adapters/primaries/users.controller.ts +++ b/src/modules/users/adapters/primaries/users.controller.ts @@ -1,25 +1,97 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; import { Controller } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { GrpcMethod, RpcException } from '@nestjs/microservices'; -import { FindUserByUuidRequest } from '../../domain/dto/findUserByUuidRequest'; +import { DatabaseException } from 'src/modules/database/src/exceptions/DatabaseException'; +import { CreateUserCommand } from '../../commands/create-user.command'; +import { DeleteUserCommand } from '../../commands/delete-user.command'; +import { UpdateUserCommand } from '../../commands/update-user.command'; +import { CreateUserRequest } from '../../domain/dto/create-user.request'; +import { FindUserByUuidRequest } from '../../domain/dto/find-user-by-uuid.request'; +import { UpdateUserRequest } from '../../domain/dto/update-user.request'; import { User } from '../../domain/entities/user'; import { FindUserByUuidQuery } from '../../queries/find-user-by-uuid.query'; +import { UserPresenter } from './user.presenter'; @Controller() export class UsersController { - constructor(private readonly _queryBus: QueryBus) {} + constructor( + private readonly _commandBus: CommandBus, + private readonly _queryBus: QueryBus, + @InjectMapper() private readonly _mapper: Mapper, + ) {} @GrpcMethod('UsersService', 'FindOneByUuid') - async findOneByUuid(data: FindUserByUuidRequest): Promise { + async findOneByUuid(data: FindUserByUuidRequest): Promise { const user = await this._queryBus.execute( new FindUserByUuidQuery(data.uuid), ); if (user) { - return user; + return this._mapper.map(user, User, UserPresenter); } throw new RpcException({ code: 5, message: 'User not found', }); } + + @GrpcMethod('UsersService', 'Create') + async createUser(data: CreateUserRequest): Promise { + try { + const user = await this._commandBus.execute(new CreateUserCommand(data)); + return this._mapper.map(user, User, UserPresenter); + } catch (e) { + if (e instanceof DatabaseException) { + if (e.message.includes('Already exists')) { + throw new RpcException({ + code: 6, + message: 'User already exists', + }); + } + } + throw new RpcException({}); + } + } + + @GrpcMethod('UsersService', 'Update') + async updateUser(data: UpdateUserRequest): Promise { + try { + const user = await this._commandBus.execute(new UpdateUserCommand(data)); + + return this._mapper.map(user, User, UserPresenter); + } catch (e) { + if (e instanceof DatabaseException) { + if (e.message.includes('not found')) { + throw new RpcException({ + code: 5, + message: 'User not found', + }); + } + } + throw new RpcException({}); + } + } + + @GrpcMethod('UsersService', 'Delete') + async deleteUser(data: FindUserByUuidRequest): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const user = await this._commandBus.execute( + new DeleteUserCommand(data.uuid), + ); + + return Promise.resolve(); + } catch (e) { + if (e instanceof DatabaseException) { + if (e.message.includes('not found')) { + throw new RpcException({ + code: 5, + message: 'User not found', + }); + } + } + throw new RpcException({}); + } + } } diff --git a/src/modules/users/commands/create-user.command.ts b/src/modules/users/commands/create-user.command.ts new file mode 100644 index 0000000..60c7160 --- /dev/null +++ b/src/modules/users/commands/create-user.command.ts @@ -0,0 +1,9 @@ +import { CreateUserRequest } from '../domain/dto/create-user.request'; + +export class CreateUserCommand { + readonly createUserRequest: CreateUserRequest; + + constructor(request: CreateUserRequest) { + this.createUserRequest = request; + } +} diff --git a/src/modules/users/commands/delete-user.command.ts b/src/modules/users/commands/delete-user.command.ts new file mode 100644 index 0000000..2e69a67 --- /dev/null +++ b/src/modules/users/commands/delete-user.command.ts @@ -0,0 +1,7 @@ +export class DeleteUserCommand { + readonly uuid: string; + + constructor(uuid: string) { + this.uuid = uuid; + } +} diff --git a/src/modules/users/commands/update-user.command.ts b/src/modules/users/commands/update-user.command.ts new file mode 100644 index 0000000..434e83b --- /dev/null +++ b/src/modules/users/commands/update-user.command.ts @@ -0,0 +1,9 @@ +import { UpdateUserRequest } from '../domain/dto/update-user.request'; + +export class UpdateUserCommand { + readonly updateUserRequest: UpdateUserRequest; + + constructor(request: UpdateUserRequest) { + this.updateUserRequest = request; + } +} diff --git a/src/modules/users/domain/dto/create-user.request.ts b/src/modules/users/domain/dto/create-user.request.ts new file mode 100644 index 0000000..f836448 --- /dev/null +++ b/src/modules/users/domain/dto/create-user.request.ts @@ -0,0 +1,23 @@ +import { AutoMap } from '@automapper/classes'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateUserRequest { + @IsString() + @AutoMap() + uuid: string; + + @IsString() + @IsNotEmpty() + @AutoMap() + firstName: string; + + @IsString() + @IsNotEmpty() + @AutoMap() + lastName: string; + + @IsString() + @IsNotEmpty() + @AutoMap() + email: string; +} diff --git a/src/modules/users/domain/dto/find-user-by-uuid.request.ts b/src/modules/users/domain/dto/find-user-by-uuid.request.ts new file mode 100644 index 0000000..2e2a70a --- /dev/null +++ b/src/modules/users/domain/dto/find-user-by-uuid.request.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class FindUserByUuidRequest { + @IsString() + @IsNotEmpty() + uuid: string; +} diff --git a/src/modules/users/domain/dto/findUserByUuidRequest.ts b/src/modules/users/domain/dto/findUserByUuidRequest.ts deleted file mode 100644 index 6b3ffb9..0000000 --- a/src/modules/users/domain/dto/findUserByUuidRequest.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from 'class-validator'; - -export class FindUserByUuidRequest { - @IsString() - uuid: string; -} diff --git a/src/modules/users/domain/dto/update-user.request.ts b/src/modules/users/domain/dto/update-user.request.ts new file mode 100644 index 0000000..96c57ef --- /dev/null +++ b/src/modules/users/domain/dto/update-user.request.ts @@ -0,0 +1,20 @@ +import { AutoMap } from '@automapper/classes'; +import { IsString } from 'class-validator'; + +export class UpdateUserRequest { + @IsString() + @AutoMap() + uuid: string; + + @IsString() + @AutoMap() + firstName?: string; + + @IsString() + @AutoMap() + lastName?: string; + + @IsString() + @AutoMap() + email?: string; +} diff --git a/src/modules/users/domain/usecases/create-user.usecase.ts b/src/modules/users/domain/usecases/create-user.usecase.ts new file mode 100644 index 0000000..b37c600 --- /dev/null +++ b/src/modules/users/domain/usecases/create-user.usecase.ts @@ -0,0 +1,25 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { CommandHandler } from '@nestjs/cqrs'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { CreateUserCommand } from '../../commands/create-user.command'; +import { CreateUserRequest } from '../dto/create-user.request'; +import { User } from '../entities/user'; + +@CommandHandler(CreateUserCommand) +export class CreateUserUseCase { + constructor( + private readonly _repository: UsersRepository, + @InjectMapper() private readonly _mapper: Mapper, + ) {} + + async execute(command: CreateUserCommand): Promise { + const entity = this._mapper.map( + command.createUserRequest, + CreateUserRequest, + User, + ); + + return this._repository.create(entity); + } +} diff --git a/src/modules/users/domain/usecases/delete-user.usecase.ts b/src/modules/users/domain/usecases/delete-user.usecase.ts new file mode 100644 index 0000000..4378b61 --- /dev/null +++ b/src/modules/users/domain/usecases/delete-user.usecase.ts @@ -0,0 +1,12 @@ +import { CommandHandler } from '@nestjs/cqrs'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { DeleteUserCommand } from '../../commands/delete-user.command'; + +@CommandHandler(DeleteUserCommand) +export class DeleteUserUseCase { + constructor(private readonly _repository: UsersRepository) {} + + async execute(command: DeleteUserCommand): Promise { + return this._repository.delete(command.uuid); + } +} diff --git a/src/modules/users/domain/usecases/update-user.usecase.ts b/src/modules/users/domain/usecases/update-user.usecase.ts new file mode 100644 index 0000000..3605913 --- /dev/null +++ b/src/modules/users/domain/usecases/update-user.usecase.ts @@ -0,0 +1,25 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { CommandHandler } from '@nestjs/cqrs'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { UpdateUserCommand } from '../../commands/update-user.command'; +import { UpdateUserRequest } from '../dto/update-user.request'; +import { User } from '../entities/user'; + +@CommandHandler(UpdateUserCommand) +export class UpdateUserUseCase { + constructor( + private readonly _repository: UsersRepository, + @InjectMapper() private readonly _mapper: Mapper, + ) {} + + async execute(command: UpdateUserCommand): Promise { + const entity = this._mapper.map( + command.updateUserRequest, + UpdateUserRequest, + User, + ); + + return this._repository.update(command.updateUserRequest.uuid, entity); + } +} diff --git a/src/modules/users/mappers/user.profile.ts b/src/modules/users/mappers/user.profile.ts index b2fc581..b681625 100644 --- a/src/modules/users/mappers/user.profile.ts +++ b/src/modules/users/mappers/user.profile.ts @@ -1,7 +1,9 @@ -import { createMap, Mapper } from '@automapper/core'; +import { createMap, forMember, ignore, Mapper } from '@automapper/core'; import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; import { Injectable } from '@nestjs/common'; import { UserPresenter } from '../adapters/primaries/user.presenter'; +import { CreateUserRequest } from '../domain/dto/create-user.request'; +import { UpdateUserRequest } from '../domain/dto/update-user.request'; import { User } from '../domain/entities/user'; @Injectable() @@ -13,6 +15,15 @@ export class UserProfile extends AutomapperProfile { override get profile() { return (mapper) => { createMap(mapper, User, UserPresenter); + + createMap(mapper, CreateUserRequest, User); + + createMap( + mapper, + UpdateUserRequest, + User, + forMember((dest) => dest.uuid, ignore()), + ); }; } } diff --git a/src/modules/users/tests/unit/create-user.usecase.spec.ts b/src/modules/users/tests/unit/create-user.usecase.spec.ts new file mode 100644 index 0000000..749a703 --- /dev/null +++ b/src/modules/users/tests/unit/create-user.usecase.spec.ts @@ -0,0 +1,57 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { CreateUserCommand } from '../../commands/create-user.command'; +import { CreateUserRequest } from '../../domain/dto/create-user.request'; +import { User } from '../../domain/entities/user'; +import { CreateUserUseCase } from '../../domain/usecases/create-user.usecase'; +import { UserProfile } from '../../mappers/user.profile'; + +const newUserRequest: CreateUserRequest = { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@email.com', +}; +const newUserCommand: CreateUserCommand = new CreateUserCommand(newUserRequest); + +const mockUsersRepository = { + create: jest.fn().mockResolvedValue({ + ...newUserRequest, + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + }), +}; + +describe('CreateUserUseCase', () => { + let createUserUseCase: CreateUserUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], + providers: [ + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + CreateUserUseCase, + UserProfile, + ], + }).compile(); + + createUserUseCase = module.get(CreateUserUseCase); + }); + + it('should be defined', () => { + expect(createUserUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should create an User and returns new entity object', async () => { + const newUser: User = await createUserUseCase.execute(newUserCommand); + + expect(newUser.lastName).toBe(newUserRequest.lastName); + expect(newUser.uuid).toBeDefined(); + }); + }); +}); diff --git a/src/modules/users/tests/unit/delete-user.usecase.spec.ts b/src/modules/users/tests/unit/delete-user.usecase.spec.ts new file mode 100644 index 0000000..b88fd9f --- /dev/null +++ b/src/modules/users/tests/unit/delete-user.usecase.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { DeleteUserCommand } from '../../commands/delete-user.command'; +import { DeleteUserUseCase } from '../../domain/usecases/delete-user.usecase'; + +const usersMock = [ + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@email.com', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a92', + firstName: 'Jane', + lastName: 'Doe', + email: 'jane.doe@email.com', + }, + { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a93', + firstName: 'Jimmy', + lastName: 'Doe', + email: 'jimmy.doe@email.com', + }, +]; + +const mockUsersRepository = { + delete: jest.fn().mockImplementation((uuid: string) => { + usersMock.forEach((user, index) => { + if (user.uuid === uuid) { + usersMock.splice(index, 1); + return Promise.resolve(); + } + }); + }), +}; + +describe('DeleteUserUseCase', () => { + let deleteUserUseCase: DeleteUserUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + DeleteUserUseCase, + ], + }).compile(); + + deleteUserUseCase = module.get(DeleteUserUseCase); + }); + + it('should be defined', () => { + expect(deleteUserUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should delete an User', async () => { + const savedUuid = usersMock[0].uuid; + const deleteUserCommand = new DeleteUserCommand(savedUuid); + await deleteUserUseCase.execute(deleteUserCommand); + + const deletedUser = usersMock.find((user) => user.uuid === savedUuid); + expect(deletedUser).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/users/tests/unit/find-user-by-uuid.usecase.spec.ts b/src/modules/users/tests/unit/find-user-by-uuid.usecase.spec.ts index 2ad1f16..b847d7f 100644 --- a/src/modules/users/tests/unit/find-user-by-uuid.usecase.spec.ts +++ b/src/modules/users/tests/unit/find-user-by-uuid.usecase.spec.ts @@ -28,7 +28,6 @@ describe('FindUserByUuidUseCase', () => { provide: UsersRepository, useValue: mockUserRepository, }, - FindUserByUuidUseCase, ], }).compile(); diff --git a/src/modules/users/tests/unit/update-user.usecase.spec.ts b/src/modules/users/tests/unit/update-user.usecase.spec.ts new file mode 100644 index 0000000..140d340 --- /dev/null +++ b/src/modules/users/tests/unit/update-user.usecase.spec.ts @@ -0,0 +1,65 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersRepository } from '../../adapters/secondaries/users.repository'; +import { UpdateUserCommand } from '../../commands/update-user.command'; +import { UpdateUserRequest } from '../../domain/dto/update-user.request'; +import { User } from '../../domain/entities/user'; +import { UpdateUserUseCase } from '../../domain/usecases/update-user.usecase'; +import { UserProfile } from '../../mappers/user.profile'; + +const originalUser: User = new User(); +originalUser.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91'; +originalUser.lastName = 'Doe'; + +const updateUserRequest: UpdateUserRequest = { + uuid: 'bb281075-1b98-4456-89d6-c643d3044a91', + lastName: 'Dane', +}; + +const updateUserCommand: UpdateUserCommand = new UpdateUserCommand( + updateUserRequest, +); + +const mockUsersRepository = { + update: jest.fn().mockImplementation((uuid: string, params: any) => { + originalUser.lastName = params.lastName; + + return Promise.resolve(originalUser); + }), +}; + +describe('UpdateUserUseCase', () => { + let updateUserUseCase: UpdateUserUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], + + providers: [ + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + UpdateUserUseCase, + UserProfile, + ], + }).compile(); + + updateUserUseCase = module.get(UpdateUserUseCase); + }); + + it('should be defined', () => { + expect(updateUserUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should update an User', async () => { + const updatedUser: User = await updateUserUseCase.execute( + updateUserCommand, + ); + + expect(updatedUser.lastName).toBe(updateUserRequest.lastName); + }); + }); +}); diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 8c95b25..2668167 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -3,12 +3,23 @@ import { CqrsModule } from '@nestjs/cqrs'; import { DatabaseModule } from '../database/database.module'; import { UsersController } from './adapters/primaries/users.controller'; import { UsersRepository } from './adapters/secondaries/users.repository'; +import { CreateUserUseCase } from './domain/usecases/create-user.usecase'; +import { DeleteUserUseCase } from './domain/usecases/delete-user.usecase'; import { FindUserByUuidUseCase } from './domain/usecases/find-user-by-uuid.usecase'; +import { UpdateUserUseCase } from './domain/usecases/update-user.usecase'; +import { UserProfile } from './mappers/user.profile'; @Module({ imports: [DatabaseModule, CqrsModule], controllers: [UsersController], - providers: [UsersRepository, FindUserByUuidUseCase], + providers: [ + UserProfile, + UsersRepository, + FindUserByUuidUseCase, + CreateUserUseCase, + UpdateUserUseCase, + DeleteUserUseCase, + ], exports: [], }) export class UsersModule {}