diff --git a/.gitignore b/.gitignore index 91b1948..dc4cfa8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules/ .env.local .env_local dist/ -.next/ \ No newline at end of file +.next/ +coverage \ No newline at end of file diff --git a/README.md b/README.md index f849ebf..ddc32ee 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,42 @@ Você pode se cadastrar como: - 🎒 **Treinador** — cadastra e gerencia seus próprios pokémons e solicita cuidados no Centro Pokémon - 🏥 **Enfermeira Joy** — visualiza todos os pokémons cadastrados e aprova ou recusa as solicitações de cuidado dos treinadores +### Testes unitários + +O backend conta com testes unitários implementados com **Jest** e **ts-jest**, cobrindo as camadas de service e controller da aplicação. + +#### Arquivos com cobertura de testes + +| Arquivo | Camada | +|---|---| +| `src/auth/auth.controller.spec.ts` | Controller | +| `src/auth/auth.service.spec.ts` | Service | +| `src/pokemons/pokemons.controller.spec.ts` | Controller | +| `src/pokemons/pokemons.service.spec.ts` | Service | +| `src/pokemon-actions/pokemon-actions.controller.spec.ts` | Controller | +| `src/pokemon-actions/pokemon-actions.service.spec.ts` | Service | +| `src/users/users.service.spec.ts` | Service | + +##### Coverage +![PokéCenter Backend Tests Coverage](/assets/pokecenter_backend_test_coverage.jpeg) + +#### Comandos + +Execute os comandos a seguir dentro do diretório `backend/`: +```bash +# Rodar todos os testes +npm run test + +# Rodar os testes em modo watch — re-executa ao salvar arquivos +npm run test:watch + +# Rodar os testes e exibir o relatório de cobertura +npm run test:cov + +# Rodar os testes e salvar o relatório de cobertura em uma pasta personalizada +npm test -- --coverage --coverageDirectory='my-coverage-report' +``` + ## Deploy > Banco de Dados: Neon diff --git a/assets/pokecenter_backend_test_coverage.jpeg b/assets/pokecenter_backend_test_coverage.jpeg new file mode 100644 index 0000000..1faa307 Binary files /dev/null and b/assets/pokecenter_backend_test_coverage.jpeg differ diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..be3669b --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; + +// ------------------------------------------------------------------ // +// Mocks +// ------------------------------------------------------------------ // + +const mockAuthService = () => ({ + register: jest.fn(), + login: jest.fn(), +}); + +// Usuário autenticado simulado — retornado pelo JwtAuthGuard via req.user +const mockAuthenticatedUser = (role: UserRole = UserRole.TRAINER) => ({ + id: 'uuid-123', + email: 'ash@pokemon.com', + name: 'Ash Ketchum', + role, +}); + +describe('AuthController', () => { + let controller: AuthController; + let authService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthService, useFactory: mockAuthService }, + ], + }) + // Substitui o guard real por um que sempre permite a requisição + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + // ------------------------------------------------------------------ // + describe('register', () => { + it('deve chamar authService.register e retornar o token', async () => { + const dto = { + name: 'Ash Ketchum', + email: 'ash@pokemon.com', + password: '123456', + role: UserRole.TRAINER, + }; + const token = { access_token: 'mock_token' }; + + authService.register.mockResolvedValue(token); + + const result = await controller.register(dto); + + expect(result).toEqual(token); + expect(authService.register).toHaveBeenCalledWith(dto); + }); + + it('deve registrar uma enfermeira Joy corretamente', async () => { + const dto = { + name: 'Enfermeira Joy', + email: 'joy@pokemon.com', + password: '123456', + role: UserRole.NURSE, + }; + const token = { access_token: 'mock_token' }; + + authService.register.mockResolvedValue(token); + + const result = await controller.register(dto); + + expect(result).toEqual(token); + expect(authService.register).toHaveBeenCalledWith(dto); + }); + }); + + // ------------------------------------------------------------------ // + describe('login', () => { + it('deve chamar authService.login e retornar o token', async () => { + const dto = { email: 'ash@pokemon.com', password: '123456' }; + const token = { access_token: 'mock_token' }; + + authService.login.mockResolvedValue(token); + + const result = await controller.login(dto); + + expect(result).toEqual(token); + expect(authService.login).toHaveBeenCalledWith(dto); + }); + }); + + // ------------------------------------------------------------------ // + describe('getProfile', () => { + it('deve retornar os dados do treinador autenticado via req.user', () => { + const user = mockAuthenticatedUser(UserRole.TRAINER); + const req = { user }; + + const result = controller.getProfile(req); + + expect(result).toEqual(user); + expect(result.role).toBe(UserRole.TRAINER); + }); + + it('deve retornar os dados da enfermeira autenticada via req.user', () => { + const user = mockAuthenticatedUser(UserRole.NURSE); + const req = { user }; + + const result = controller.getProfile(req); + + expect(result).toEqual(user); + expect(result.role).toBe(UserRole.NURSE); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 0cf883f..3dbb507 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -4,6 +4,7 @@ import { UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; +import { UserRole } from '../users/enums/user-role.enum'; // Mocka o módulo inteiro do bcryptjs — evita o erro "Cannot redefine property" jest.mock('bcryptjs', () => ({ @@ -14,11 +15,13 @@ jest.mock('bcryptjs', () => ({ // Importa depois do mock para pegar a versão mockada import * as bcrypt from 'bcryptjs'; -const mockUser = (): User => ({ +// Cria um usuário mock com role configurável — trainer por padrão +const mockUser = (role: UserRole = UserRole.TRAINER): User => ({ id: 'uuid-123', name: 'Ash Ketchum', email: 'ash@pokemon.com', password: 'hashed_password', + role, createdAt: new Date(), }); @@ -49,7 +52,6 @@ describe('AuthService', () => { usersService = module.get(UsersService); jwtService = module.get(JwtService); - // Reseta os mocks do bcrypt antes de cada teste jest.clearAllMocks(); }); @@ -57,11 +59,11 @@ describe('AuthService', () => { expect(service).toBeDefined(); }); + // ------------------------------------------------------------------ // describe('register', () => { - it('deve registrar um usuário e retornar o token', async () => { - const user = mockUser(); + it('deve registrar um treinador e retornar o token', async () => { + const user = mockUser(UserRole.TRAINER); - // Usa mockResolvedValue diretamente na função mockada (bcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); usersService.create.mockResolvedValue(user); @@ -69,6 +71,7 @@ describe('AuthService', () => { name: 'Ash Ketchum', email: 'ash@pokemon.com', password: '123456', + role: UserRole.TRAINER, }); expect(result).toEqual({ access_token: 'mock_token' }); @@ -76,13 +79,56 @@ describe('AuthService', () => { expect(jwtService.sign).toHaveBeenCalledWith({ sub: user.id, email: user.email, + role: UserRole.TRAINER, + name: user.name, }); }); + + it('deve registrar uma enfermeira Joy e retornar o token', async () => { + const user = mockUser(UserRole.NURSE); + + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + usersService.create.mockResolvedValue(user); + + const result = await service.register({ + name: 'Ash Ketchum', + email: 'ash@pokemon.com', + password: '123456', + role: UserRole.NURSE, + }); + + expect(result).toEqual({ access_token: 'mock_token' }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: user.id, + email: user.email, + role: UserRole.NURSE, + name: user.name, + }); + }); + + it('deve registrar com role trainer por padrão quando role não for informada', async () => { + const user = mockUser(UserRole.TRAINER); + + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + usersService.create.mockResolvedValue(user); + + await service.register({ + name: 'Ash Ketchum', + email: 'ash@pokemon.com', + password: '123456', + // role não informada — deve usar trainer como padrão + }); + + expect(jwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ role: UserRole.TRAINER }), + ); + }); }); + // ------------------------------------------------------------------ // describe('login', () => { - it('deve autenticar o usuário e retornar o token', async () => { - const user = mockUser(); + it('deve autenticar um treinador e retornar o token', async () => { + const user = mockUser(UserRole.TRAINER); usersService.findByEmail.mockResolvedValue(user); (bcrypt.compare as jest.Mock).mockResolvedValue(true); @@ -93,6 +139,32 @@ describe('AuthService', () => { }); expect(result).toEqual({ access_token: 'mock_token' }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: user.id, + email: user.email, + role: UserRole.TRAINER, + name: user.name, + }); + }); + + it('deve autenticar uma enfermeira Joy e retornar o token', async () => { + const user = mockUser(UserRole.NURSE); + + usersService.findByEmail.mockResolvedValue(user); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + const result = await service.login({ + email: 'joy@pokemon.com', + password: '123456', + }); + + expect(result).toEqual({ access_token: 'mock_token' }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: user.id, + email: user.email, + role: UserRole.NURSE, + name: user.name, + }); }); it('deve lançar UnauthorizedException quando usuário não existe', async () => { @@ -107,7 +179,7 @@ describe('AuthService', () => { }); it('deve lançar UnauthorizedException quando senha está incorreta', async () => { - const user = mockUser(); + const user = mockUser(UserRole.TRAINER); usersService.findByEmail.mockResolvedValue(user); (bcrypt.compare as jest.Mock).mockResolvedValue(false); @@ -117,4 +189,4 @@ describe('AuthService', () => { ).rejects.toThrow(UnauthorizedException); }); }); -}); +}); \ No newline at end of file diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index 2ab4ef2..57c57ee 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger/dist/decorators/api-property.decorator'; import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator'; -import { UserRole } from 'src/users/enums/user-role.enum'; +import { UserRole } from '../../users/enums/user-role.enum'; export class RegisterDto { @IsString() diff --git a/backend/src/pokemon-actions/pokemon-actions.controller.spec.ts b/backend/src/pokemon-actions/pokemon-actions.controller.spec.ts new file mode 100644 index 0000000..983acc9 --- /dev/null +++ b/backend/src/pokemon-actions/pokemon-actions.controller.spec.ts @@ -0,0 +1,246 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PokemonActionsController } from './pokemon-actions.controller'; +import { PokemonActionsService } from './pokemon-actions.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { ActionType } from './enums/action-type.enum'; +import { ActionStatus } from './enums/action-status.enum'; +import { PokemonStatus } from '../pokemons/enums/pokemon-status.enum'; +import { PokemonAction } from './entities/pokemon-action.entity'; +import { User } from '../users/entities/user.entity'; +import { Pokemon } from '../pokemons/entities/pokemon.entity'; + +// ------------------------------------------------------------------ // +// Mocks +// ------------------------------------------------------------------ // + +const mockPokemonActionsService = () => ({ + findAll: jest.fn(), + requestAction: jest.fn(), + reviewAction: jest.fn(), +}); + +const mockTrainerRequest = () => ({ + user: { + id: 'trainer-uuid', + email: 'ash@pokemon.com', + name: 'Ash Ketchum', + role: UserRole.TRAINER, + }, +}); + +const mockNurseRequest = () => ({ + user: { + id: 'nurse-uuid', + email: 'joy@pokemon.com', + name: 'Enfermeira Joy', + role: UserRole.NURSE, + }, +}); + +const mockPokemon = (): Pokemon => ({ + id: 'pokemon-uuid', + name: 'pikachu', + nickname: 'Pika', + pokedexNumber: 25, + level: 50, + hp: 100, + height: 0.4, + weight: 6.0, + types: [], + gender: 'Male' as any, + healthStatus: PokemonStatus.HEALTHY, + createdBy: { id: 'trainer-uuid' } as User, + createdAt: new Date(), + updatedAt: new Date(), +}); + +const mockAction = ( + type: ActionType = ActionType.HEAL, + status: ActionStatus = ActionStatus.PENDING, +): PokemonAction => ({ + id: 'action-uuid', + type, + status, + trainerNote: null, + nurseNote: null, + pokemon: mockPokemon(), + requestedBy: { id: 'trainer-uuid', name: 'Ash Ketchum' } as User, + reviewedBy: null, + createdAt: new Date(), + updatedAt: new Date(), +}); + +describe('PokemonActionsController', () => { + let controller: PokemonActionsController; + let actionsService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PokemonActionsController], + providers: [ + { provide: PokemonActionsService, useFactory: mockPokemonActionsService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(PokemonActionsController); + actionsService = module.get(PokemonActionsService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + // ------------------------------------------------------------------ // + describe('findAll', () => { + it('deve retornar todas as ações quando usuário for enfermeira', async () => { + const actions = [mockAction(), mockAction(ActionType.FEED)]; + actionsService.findAll.mockResolvedValue(actions); + + const result = await controller.findAll(mockNurseRequest()); + + expect(result).toEqual(actions); + + // Enfermeira passa isNurse=true para ver todas as ações + expect(actionsService.findAll).toHaveBeenCalledWith('nurse-uuid', true); + }); + + it('deve retornar apenas as ações do treinador quando usuário for treinador', async () => { + const actions = [mockAction()]; + actionsService.findAll.mockResolvedValue(actions); + + const result = await controller.findAll(mockTrainerRequest()); + + expect(result).toEqual(actions); + + // Treinador passa seu id e isNurse=false para filtrar apenas as suas ações + expect(actionsService.findAll).toHaveBeenCalledWith('trainer-uuid', false); + }); + }); + + // ------------------------------------------------------------------ // + describe('requestAction', () => { + it('deve criar uma solicitação de cura para o treinador autenticado', async () => { + const action = mockAction(ActionType.HEAL); + const dto = { pokemonId: 'pokemon-uuid', type: ActionType.HEAL }; + + actionsService.requestAction.mockResolvedValue(action); + + const result = await controller.requestAction(dto, mockTrainerRequest()); + + expect(result).toEqual(action); + + // Garante que o id do treinador autenticado é passado ao service + expect(actionsService.requestAction).toHaveBeenCalledWith(dto, 'trainer-uuid'); + }); + + it('deve criar uma solicitação de alimentação para o treinador autenticado', async () => { + const action = mockAction(ActionType.FEED); + const dto = { + pokemonId: 'pokemon-uuid', + type: ActionType.FEED, + trainerNote: 'Meu Pikachu está com fome!', + }; + + actionsService.requestAction.mockResolvedValue(action); + + const result = await controller.requestAction(dto, mockTrainerRequest()); + + expect(result).toEqual(action); + expect(actionsService.requestAction).toHaveBeenCalledWith(dto, 'trainer-uuid'); + }); + }); + + // ------------------------------------------------------------------ // + describe('reviewAction', () => { + it('deve aprovar uma solicitação passando o id da enfermeira autenticada', async () => { + const nurse = { id: 'nurse-uuid', name: 'Enfermeira Joy' } as User; + const approvedAction = { + ...mockAction(ActionType.HEAL, ActionStatus.APPROVED), + reviewedBy: nurse, + nurseNote: 'Pikachu curado com sucesso!', + }; + const dto = { + status: ActionStatus.APPROVED as ActionStatus.APPROVED, + nurseNote: 'Pikachu curado com sucesso!', + }; + + actionsService.reviewAction.mockResolvedValue(approvedAction); + + const result = await controller.reviewAction( + 'action-uuid', + dto, + mockNurseRequest(), + ); + + expect(result.status).toBe(ActionStatus.APPROVED); + expect(result.reviewedBy).toEqual(nurse); + + // Garante que o service recebe o id da ação, o dto e o id da enfermeira + expect(actionsService.reviewAction).toHaveBeenCalledWith( + 'action-uuid', + dto, + 'nurse-uuid', + ); + }); + + it('deve recusar uma solicitação passando o id da enfermeira autenticada', async () => { + const nurse = { id: 'nurse-uuid', name: 'Enfermeira Joy' } as User; + const rejectedAction = { + ...mockAction(ActionType.HEAL, ActionStatus.REJECTED), + reviewedBy: nurse, + nurseNote: 'Pokémon está saudável, não precisa de cura.', + }; + const dto = { + status: ActionStatus.REJECTED as ActionStatus.REJECTED, + nurseNote: 'Pokémon está saudável, não precisa de cura.', + }; + + actionsService.reviewAction.mockResolvedValue(rejectedAction); + + const result = await controller.reviewAction( + 'action-uuid', + dto, + mockNurseRequest(), + ); + + expect(result.status).toBe(ActionStatus.REJECTED); + expect(result.reviewedBy).toEqual(nurse); + expect(actionsService.reviewAction).toHaveBeenCalledWith( + 'action-uuid', + dto, + 'nurse-uuid', + ); + }); + + it('deve registrar a observação da enfermeira ao revisar', async () => { + const nurseNote = 'Charizard precisa de mais repouso.'; + const reviewedAction = { + ...mockAction(ActionType.HEAL, ActionStatus.REJECTED), + nurseNote, + }; + const dto = { + status: ActionStatus.REJECTED as ActionStatus.REJECTED, + nurseNote, + }; + + actionsService.reviewAction.mockResolvedValue(reviewedAction); + + const result = await controller.reviewAction( + 'action-uuid', + dto, + mockNurseRequest(), + ); + + expect(result.nurseNote).toBe(nurseNote); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/pokemon-actions/pokemon-actions.service.spec.ts b/backend/src/pokemon-actions/pokemon-actions.service.spec.ts new file mode 100644 index 0000000..db81631 --- /dev/null +++ b/backend/src/pokemon-actions/pokemon-actions.service.spec.ts @@ -0,0 +1,470 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { PokemonActionsService } from './pokemon-actions.service'; +import { PokemonsService } from '../pokemons/pokemons.service'; +import { UsersService } from '../users/users.service'; +import { PokemonAction } from './entities/pokemon-action.entity'; +import { ActionStatus } from './enums/action-status.enum'; +import { ActionType } from './enums/action-type.enum'; +import { PokemonStatus } from '../pokemons/enums/pokemon-status.enum'; +import { UserRole } from '../users/enums/user-role.enum'; +import { User } from '../users/entities/user.entity'; +import { Pokemon } from '../pokemons/entities/pokemon.entity'; + +// ------------------------------------------------------------------ // +// Factories de mocks +// ------------------------------------------------------------------ // + +const mockTrainer = (): User => ({ + id: 'trainer-uuid', + name: 'Ash Ketchum', + email: 'ash@pokemon.com', + password: 'hashed_password', + role: UserRole.TRAINER, + createdAt: new Date(), +}); + +const mockNurse = (): User => ({ + id: 'nurse-uuid', + name: 'Enfermeira Joy', + email: 'joy@pokemon.com', + password: 'hashed_password', + role: UserRole.NURSE, + createdAt: new Date(), +}); + +const mockPokemon = (ownerId = 'trainer-uuid'): Pokemon => ({ + id: 'pokemon-uuid', + name: 'pikachu', + nickname: 'Pika', + pokedexNumber: 25, + level: 50, + hp: 100, + height: 0.4, + weight: 6.0, + types: [], + gender: 'Male' as any, + healthStatus: PokemonStatus.HEALTHY, + createdBy: { id: ownerId } as User, + createdAt: new Date(), + updatedAt: new Date(), +}); + +const mockAction = ( + type: ActionType = ActionType.HEAL, + status: ActionStatus = ActionStatus.PENDING, +): PokemonAction => ({ + id: 'action-uuid', + type, + status, + trainerNote: null, + nurseNote: null, + pokemon: mockPokemon(), + requestedBy: mockTrainer(), + reviewedBy: null, + createdAt: new Date(), + updatedAt: new Date(), +}); + +// ------------------------------------------------------------------ // +// Mocks dos providers +// ------------------------------------------------------------------ // + +const mockActionsRepository = () => ({ + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}); + +const mockPokemonsService = () => ({ + findOne: jest.fn(), + update: jest.fn(), +}); + +const mockUsersService = () => ({ + findById: jest.fn(), +}); + +// ------------------------------------------------------------------ // +// Testes +// ------------------------------------------------------------------ // + +describe('PokemonActionsService', () => { + let service: PokemonActionsService; + let actionsRepository: ReturnType; + let pokemonsService: jest.Mocked; + let usersService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PokemonActionsService, + { + provide: getRepositoryToken(PokemonAction), + useFactory: mockActionsRepository, + }, + { provide: PokemonsService, useFactory: mockPokemonsService }, + { provide: UsersService, useFactory: mockUsersService }, + ], + }).compile(); + + service = module.get(PokemonActionsService); + actionsRepository = module.get(getRepositoryToken(PokemonAction)); + pokemonsService = module.get(PokemonsService); + usersService = module.get(UsersService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ------------------------------------------------------------------ // + describe('findAll', () => { + it('deve retornar todas as ações quando usuário for enfermeira', async () => { + const actions = [mockAction(), mockAction(ActionType.FEED)]; + actionsRepository.find.mockResolvedValue(actions); + + const result = await service.findAll('nurse-uuid', true); + + expect(result).toEqual(actions); + + // Enfermeira não filtra por userId — where deve ser undefined + expect(actionsRepository.find).toHaveBeenCalledWith({ + where: undefined, + order: { createdAt: 'DESC' }, + }); + }); + + it('deve retornar apenas as ações do treinador quando usuário for treinador', async () => { + const actions = [mockAction()]; + actionsRepository.find.mockResolvedValue(actions); + + const result = await service.findAll('trainer-uuid', false); + + expect(result).toEqual(actions); + + // Treinador filtra apenas pelas suas próprias ações + expect(actionsRepository.find).toHaveBeenCalledWith({ + where: { requestedBy: { id: 'trainer-uuid' } }, + order: { createdAt: 'DESC' }, + }); + }); + }); + + // ------------------------------------------------------------------ // + describe('requestAction', () => { + it('deve criar uma solicitação de cura para o próprio pokémon', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const action = mockAction(ActionType.HEAL); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + actionsRepository.findOne.mockResolvedValue(null); // sem pendente duplicado + actionsRepository.create.mockReturnValue(action); + actionsRepository.save.mockResolvedValue(action); + + const result = await service.requestAction( + { pokemonId: pokemon.id, type: ActionType.HEAL }, + trainer.id, + ); + + expect(result).toEqual(action); + expect(actionsRepository.save).toHaveBeenCalled(); + }); + + it('deve criar uma solicitação de alimentação para o próprio pokémon', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const action = mockAction(ActionType.FEED); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + actionsRepository.findOne.mockResolvedValue(null); + actionsRepository.create.mockReturnValue(action); + actionsRepository.save.mockResolvedValue(action); + + const result = await service.requestAction( + { pokemonId: pokemon.id, type: ActionType.FEED }, + trainer.id, + ); + + expect(result).toEqual(action); + }); + + it('deve incluir a observação do treinador quando informada', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const action = { ...mockAction(), trainerNote: 'Pikachu foi envenenado!' }; + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + actionsRepository.findOne.mockResolvedValue(null); + actionsRepository.create.mockReturnValue(action); + actionsRepository.save.mockResolvedValue(action); + + const result = await service.requestAction( + { + pokemonId: pokemon.id, + type: ActionType.HEAL, + trainerNote: 'Pikachu foi envenenado!', + }, + trainer.id, + ); + + expect(result.trainerNote).toBe('Pikachu foi envenenado!'); + }); + + it('deve lançar ForbiddenException ao solicitar ação para pokémon de outro treinador', async () => { + const trainer = mockTrainer(); + + // Pokémon pertence a outro treinador + const pokemon = mockPokemon('outro-trainer-uuid'); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + + await expect( + service.requestAction( + { pokemonId: pokemon.id, type: ActionType.HEAL }, + trainer.id, + ), + ).rejects.toThrow(ForbiddenException); + + // Garante que nenhuma ação foi salva + expect(actionsRepository.save).not.toHaveBeenCalled(); + }); + + it('deve lançar BadRequestException quando já existe solicitação de cura pendente', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const existingAction = mockAction(ActionType.HEAL, ActionStatus.PENDING); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + + // Simula solicitação de cura já pendente + actionsRepository.findOne.mockResolvedValue(existingAction); + + await expect( + service.requestAction( + { pokemonId: pokemon.id, type: ActionType.HEAL }, + trainer.id, + ), + ).rejects.toThrow(BadRequestException); + + expect(actionsRepository.save).not.toHaveBeenCalled(); + }); + + it('deve lançar BadRequestException quando já existe solicitação de alimentação pendente', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const existingAction = mockAction(ActionType.FEED, ActionStatus.PENDING); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + actionsRepository.findOne.mockResolvedValue(existingAction); + + await expect( + service.requestAction( + { pokemonId: pokemon.id, type: ActionType.FEED }, + trainer.id, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('deve permitir solicitação de alimentação mesmo com cura pendente', async () => { + const trainer = mockTrainer(); + const pokemon = mockPokemon(trainer.id); + const feedAction = mockAction(ActionType.FEED); + + pokemonsService.findOne.mockResolvedValue(pokemon); + usersService.findById.mockResolvedValue(trainer); + + // Não há alimentação pendente, apenas cura + actionsRepository.findOne.mockResolvedValue(null); + actionsRepository.create.mockReturnValue(feedAction); + actionsRepository.save.mockResolvedValue(feedAction); + + const result = await service.requestAction( + { pokemonId: pokemon.id, type: ActionType.FEED }, + trainer.id, + ); + + expect(result).toEqual(feedAction); + }); + }); + + // ------------------------------------------------------------------ // + describe('reviewAction', () => { + it('deve aprovar uma solicitação de cura e curar o pokémon', async () => { + const action = mockAction(ActionType.HEAL, ActionStatus.PENDING); + const nurse = mockNurse(); + const approvedAction = { + ...action, + status: ActionStatus.APPROVED, + reviewedBy: nurse, + }; + + actionsRepository.findOne.mockResolvedValue(action); + usersService.findById.mockResolvedValue(nurse); + pokemonsService.update.mockResolvedValue(mockPokemon()); + actionsRepository.save.mockResolvedValue(approvedAction); + + const result = await service.reviewAction( + action.id, + { status: ActionStatus.APPROVED }, + nurse.id, + ); + + expect(result.status).toBe(ActionStatus.APPROVED); + expect(result.reviewedBy).toEqual(nurse); + + // Garante que o pokémon foi curado com HP máximo e status saudável + expect(pokemonsService.update).toHaveBeenCalledWith( + action.pokemon.id, + { hp: 255, healthStatus: PokemonStatus.HEALTHY }, + action.pokemon.createdBy.id, + ); + }); + + it('deve recusar uma solicitação sem alterar o pokémon', async () => { + const action = mockAction(ActionType.HEAL, ActionStatus.PENDING); + const nurse = mockNurse(); + const rejectedAction = { + ...action, + status: ActionStatus.REJECTED, + reviewedBy: nurse, + nurseNote: 'Pokémon está saudável, não precisa de cura.', + }; + + actionsRepository.findOne.mockResolvedValue(action); + usersService.findById.mockResolvedValue(nurse); + actionsRepository.save.mockResolvedValue(rejectedAction); + + const result = await service.reviewAction( + action.id, + { + status: ActionStatus.REJECTED, + nurseNote: 'Pokémon está saudável, não precisa de cura.', + }, + nurse.id, + ); + + expect(result.status).toBe(ActionStatus.REJECTED); + + // Garante que o pokémon NÃO foi alterado ao recusar + expect(pokemonsService.update).not.toHaveBeenCalled(); + }); + + it('deve registrar o nome da enfermeira que revisou a solicitação', async () => { + const action = mockAction(ActionType.HEAL, ActionStatus.PENDING); + const nurse = mockNurse(); + const approvedAction = { ...action, status: ActionStatus.APPROVED, reviewedBy: nurse }; + + actionsRepository.findOne.mockResolvedValue(action); + usersService.findById.mockResolvedValue(nurse); + pokemonsService.update.mockResolvedValue(mockPokemon()); + actionsRepository.save.mockResolvedValue(approvedAction); + + const result = await service.reviewAction( + action.id, + { status: ActionStatus.APPROVED }, + nurse.id, + ); + + expect(result.reviewedBy).toEqual(nurse); + expect(result.reviewedBy?.name).toBe('Enfermeira Joy'); + }); + + it('deve salvar a observação da enfermeira ao revisar', async () => { + const action = mockAction(ActionType.HEAL, ActionStatus.PENDING); + const nurse = mockNurse(); + const nurseNote = 'Pikachu curado com antídoto!'; + const approvedAction = { ...action, status: ActionStatus.APPROVED, nurseNote }; + + actionsRepository.findOne.mockResolvedValue(action); + usersService.findById.mockResolvedValue(nurse); + pokemonsService.update.mockResolvedValue(mockPokemon()); + actionsRepository.save.mockResolvedValue(approvedAction); + + const result = await service.reviewAction( + action.id, + { status: ActionStatus.APPROVED, nurseNote }, + nurse.id, + ); + + expect(result.nurseNote).toBe(nurseNote); + }); + + it('deve aprovar uma solicitação de alimentação sem alterar status de saúde', async () => { + const action = mockAction(ActionType.FEED, ActionStatus.PENDING); + const nurse = mockNurse(); + const approvedAction = { ...action, status: ActionStatus.APPROVED, reviewedBy: nurse }; + + actionsRepository.findOne.mockResolvedValue(action); + usersService.findById.mockResolvedValue(nurse); + actionsRepository.save.mockResolvedValue(approvedAction); + + const result = await service.reviewAction( + action.id, + { status: ActionStatus.APPROVED }, + nurse.id, + ); + + expect(result.status).toBe(ActionStatus.APPROVED); + + // Alimentação não altera o pokémon por enquanto + expect(pokemonsService.update).not.toHaveBeenCalled(); + }); + + it('deve lançar NotFoundException quando solicitação não existe', async () => { + actionsRepository.findOne.mockResolvedValue(null); + + await expect( + service.reviewAction( + 'uuid-inexistente', + { status: ActionStatus.APPROVED }, + 'nurse-uuid', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('deve lançar BadRequestException ao revisar solicitação já aprovada', async () => { + const alreadyApproved = mockAction(ActionType.HEAL, ActionStatus.APPROVED); + actionsRepository.findOne.mockResolvedValue(alreadyApproved); + + await expect( + service.reviewAction( + alreadyApproved.id, + { status: ActionStatus.APPROVED }, + 'nurse-uuid', + ), + ).rejects.toThrow(BadRequestException); + + expect(actionsRepository.save).not.toHaveBeenCalled(); + }); + + it('deve lançar BadRequestException ao revisar solicitação já recusada', async () => { + // Solicitação já foi recusada anteriormente + const alreadyRejected = mockAction(ActionType.HEAL, ActionStatus.REJECTED); + actionsRepository.findOne.mockResolvedValue(alreadyRejected); + + await expect( + service.reviewAction( + alreadyRejected.id, + { status: ActionStatus.REJECTED }, + 'nurse-uuid', + ), + ).rejects.toThrow(BadRequestException); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/pokemons/pokemons.controller.spec.ts b/backend/src/pokemons/pokemons.controller.spec.ts new file mode 100644 index 0000000..edc9fb1 --- /dev/null +++ b/backend/src/pokemons/pokemons.controller.spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PokemonsController } from './pokemons.controller'; +import { PokemonsService } from './pokemons.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { PokemonStatus } from './enums/pokemon-status.enum'; +import { Pokemon } from './entities/pokemon.entity'; +import { User } from '../users/entities/user.entity'; + +// ------------------------------------------------------------------ // +// Mocks +// ------------------------------------------------------------------ // + +const mockPokemonsService = () => ({ + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), +}); + +const mockTrainerRequest = () => ({ + user: { + id: 'trainer-uuid', + email: 'ash@pokemon.com', + name: 'Ash Ketchum', + role: UserRole.TRAINER, + }, +}); + +const mockNurseRequest = () => ({ + user: { + id: 'nurse-uuid', + email: 'joy@pokemon.com', + name: 'Enfermeira Joy', + role: UserRole.NURSE, + }, +}); + +const mockPokemon = (): Pokemon => ({ + id: 'pokemon-uuid', + name: 'pikachu', + nickname: 'Pika', + pokedexNumber: 25, + level: 50, + hp: 100, + height: 0.4, + weight: 6.0, + types: [], + gender: 'Male' as any, + healthStatus: PokemonStatus.HEALTHY, + createdBy: { id: 'trainer-uuid' } as User, + createdAt: new Date(), + updatedAt: new Date(), +}); + +describe('PokemonsController', () => { + let controller: PokemonsController; + let pokemonsService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PokemonsController], + providers: [ + { provide: PokemonsService, useFactory: mockPokemonsService }, + ], + }) + // Substitui os guards reais — autenticação e autorização são testadas nos seus próprios testes + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(PokemonsController); + pokemonsService = module.get(PokemonsService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + // ------------------------------------------------------------------ // + describe('findAll', () => { + it('deve retornar todos os pokémons quando usuário for enfermeira', async () => { + const pokemons = [mockPokemon()]; + pokemonsService.findAll.mockResolvedValue(pokemons); + + const result = await controller.findAll(mockNurseRequest()); + + expect(result).toEqual(pokemons); + + // Enfermeira passa undefined para receber todos os pokémons + expect(pokemonsService.findAll).toHaveBeenCalledWith(undefined); + }); + + it('deve retornar apenas os pokémons do treinador quando usuário for treinador', async () => { + const pokemons = [mockPokemon()]; + pokemonsService.findAll.mockResolvedValue(pokemons); + + const result = await controller.findAll(mockTrainerRequest()); + + expect(result).toEqual(pokemons); + + // Treinador passa seu próprio id para filtrar apenas seus pokémons + expect(pokemonsService.findAll).toHaveBeenCalledWith('trainer-uuid'); + }); + }); + + // ------------------------------------------------------------------ // + describe('findOne', () => { + it('deve retornar um pokémon pelo id', async () => { + const pokemon = mockPokemon(); + pokemonsService.findOne.mockResolvedValue(pokemon); + + const result = await controller.findOne('pokemon-uuid'); + + expect(result).toEqual(pokemon); + expect(pokemonsService.findOne).toHaveBeenCalledWith('pokemon-uuid'); + }); + }); + + // ------------------------------------------------------------------ // + describe('create', () => { + it('deve criar um pokémon e associá-lo ao treinador autenticado', async () => { + const pokemon = mockPokemon(); + const dto = { + name: 'pikachu', + pokedexNumber: 25, + level: 50, + hp: 100, + height: 0.4, + weight: 6.0, + types: [], + gender: 'Male' as any, + }; + + pokemonsService.create.mockResolvedValue(pokemon); + + const result = await controller.create(dto, mockTrainerRequest()); + + expect(result).toEqual(pokemon); + + // Garante que o id do treinador autenticado é passado ao service + expect(pokemonsService.create).toHaveBeenCalledWith(dto, 'trainer-uuid'); + }); + }); + + // ------------------------------------------------------------------ // + describe('update', () => { + it('deve atualizar um pokémon passando o id do treinador autenticado', async () => { + const pokemon = { ...mockPokemon(), level: 60 }; + const dto = { level: 60 }; + + pokemonsService.update.mockResolvedValue(pokemon); + + const result = await controller.update( + 'pokemon-uuid', + dto, + mockTrainerRequest(), + ); + + expect(result).toEqual(pokemon); + + // Garante que o service recebe o id do pokémon, o dto e o id do dono + expect(pokemonsService.update).toHaveBeenCalledWith( + 'pokemon-uuid', + dto, + 'trainer-uuid', + ); + }); + }); + + // ------------------------------------------------------------------ // + describe('remove', () => { + it('deve excluir um pokémon passando o id do treinador autenticado', async () => { + pokemonsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove('pokemon-uuid', mockTrainerRequest()); + + expect(result).toBeUndefined(); + + // Garante que o service recebe o id do pokémon e o id do dono + expect(pokemonsService.remove).toHaveBeenCalledWith( + 'pokemon-uuid', + 'trainer-uuid', + ); + }); + }); +}); \ No newline at end of file