diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 39d107ee..7f603f00 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -11,6 +11,7 @@ import { SignInResponseDto } from './dtos/sign-in-response.dto'; import { RefreshTokenDto } from './dtos/refresh-token.dto'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; import { ForgotPasswordDto } from './dtos/forgot-password.dto'; +import { Role } from '../users/types'; @Controller('auth') export class AuthController { @@ -32,6 +33,8 @@ export class AuthController { signUpDto.email, signUpDto.firstName, signUpDto.lastName, + signUpDto.phone, + Role.STANDARD_VOLUNTEER, ); return user; diff --git a/apps/backend/src/auth/dtos/sign-up.dto.ts b/apps/backend/src/auth/dtos/sign-up.dto.ts index 5756f186..258690eb 100644 --- a/apps/backend/src/auth/dtos/sign-up.dto.ts +++ b/apps/backend/src/auth/dtos/sign-up.dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator'; export class SignUpDto { @IsString() @@ -12,4 +12,12 @@ export class SignUpDto { @IsString() password: string; + + @IsString() + @IsNotEmpty() + @IsPhoneNumber('US', { + message: + 'phone must be a valid phone number (make sure all the digits are correct)', + }) + phone: string; } diff --git a/apps/backend/src/users/dtos/userSchema.dto.ts b/apps/backend/src/users/dtos/userSchema.dto.ts new file mode 100644 index 00000000..b6905ea2 --- /dev/null +++ b/apps/backend/src/users/dtos/userSchema.dto.ts @@ -0,0 +1,34 @@ +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsString, + IsOptional, + IsPhoneNumber, +} from 'class-validator'; +import { Role } from '../types'; + +export class userSchemaDto { + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsNotEmpty() + firstName: string; + + @IsString() + @IsNotEmpty() + lastName: string; + + @IsString() + @IsNotEmpty() + @IsPhoneNumber('US', { + message: + 'phone must be a valid phone number (make sure all the digits are correct)', + }) + phone: string; + + @IsEnum(Role) + role: Role; +} diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts new file mode 100644 index 00000000..78d116a3 --- /dev/null +++ b/apps/backend/src/users/users.controller.spec.ts @@ -0,0 +1,161 @@ +import { BadRequestException } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { User } from './user.entity'; +import { Role } from './types'; +import { userSchemaDto } from './dtos/userSchema.dto'; + +import { Test, TestingModule } from '@nestjs/testing'; +import { mock } from 'jest-mock-extended'; + +const mockUserService = mock(); + +const mockUser1: User = { + id: 1, + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe', + phone: '1234567890', + role: Role.STANDARD_VOLUNTEER, +}; + +const mockUser2: User = { + id: 2543210, + email: 'bobsmith@example.com', + firstName: 'Bob', + lastName: 'Smith', + phone: '9876', + role: Role.LEAD_VOLUNTEER, +}; + +describe('UsersController', () => { + let controller: UsersController; + + beforeEach(async () => { + mockUserService.findUsersByRoles.mockReset(); + mockUserService.findOne.mockReset(); + mockUserService.remove.mockReset(); + mockUserService.update.mockReset(); + mockUserService.create.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: mockUserService, + }, + ], + }).compile(); + + controller = module.get(UsersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /volunteers', () => { + it('should return all volunteers', async () => { + const volunteers = [mockUser1, mockUser2]; + mockUserService.findUsersByRoles.mockResolvedValue(volunteers); + + const result = await controller.getAllVolunteers(); + + const hasAdmin = result.some((user) => user.role === Role.ADMIN); + expect(hasAdmin).toBe(false); + + expect(result).toEqual(volunteers); + expect(mockUserService.findUsersByRoles).toHaveBeenCalledWith([ + Role.LEAD_VOLUNTEER, + Role.STANDARD_VOLUNTEER, + ]); + }); + }); + + describe('GET /:id', () => { + it('should return a user by id', async () => { + mockUserService.findOne.mockResolvedValue(mockUser1); + + const result = await controller.getUser(1); + + expect(result).toEqual(mockUser1); + expect(mockUserService.findOne).toHaveBeenCalledWith(1); + }); + }); + + describe('DELETE /:id', () => { + it('should remove a user by id', async () => { + mockUserService.remove.mockResolvedValue(mockUser1); + + const result = await controller.removeUser(1); + + expect(result).toEqual(mockUser1); + expect(mockUserService.remove).toHaveBeenCalledWith(1); + }); + }); + + describe('PUT :id/role', () => { + it('should update user role with valid role', async () => { + const updatedUser = { ...mockUser1, role: Role.ADMIN }; + mockUserService.update.mockResolvedValue(updatedUser); + + const result = await controller.updateRole(1, Role.ADMIN); + + expect(result).toEqual(updatedUser); + expect(mockUserService.update).toHaveBeenCalledWith(1, { + role: Role.ADMIN, + }); + }); + + it('should throw BadRequestException for invalid role', async () => { + await expect(controller.updateRole(1, 'invalid_role')).rejects.toThrow( + BadRequestException, + ); + expect(mockUserService.update).not.toHaveBeenCalled(); + }); + }); + + describe('POST /api/users', () => { + it('should create a new user with all required fields', async () => { + const createUserSchema: userSchemaDto = { + email: 'newuser@example.com', + firstName: 'Jane', + lastName: 'Smith', + phone: '9876543210', + role: Role.ADMIN, + }; + + const createdUser = { ...createUserSchema, id: 2 }; + mockUserService.create.mockResolvedValue(createdUser); + + const result = await controller.createUser(createUserSchema); + + expect(result).toEqual(createdUser); + expect(mockUserService.create).toHaveBeenCalledWith( + createUserSchema.email, + createUserSchema.firstName, + createUserSchema.lastName, + createUserSchema.phone, + createUserSchema.role, + ); + }); + + it('should handle service errors', async () => { + const createUserSchema: userSchemaDto = { + email: 'newuser@example.com', + firstName: 'Jane', + lastName: 'Smith', + phone: '9876543210', + role: Role.STANDARD_VOLUNTEER, + }; + + const error = new Error('Database error'); + mockUserService.create.mockRejectedValue(error); + + await expect(controller.createUser(createUserSchema)).rejects.toThrow( + error, + ); + }); + }); +}); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 686a8529..93a4dc01 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -5,6 +5,7 @@ import { Param, ParseIntPipe, Put, + Post, BadRequestException, Body, //UseGuards, @@ -15,6 +16,7 @@ import { UsersService } from './users.service'; import { User } from './user.entity'; import { Role } from './types'; import { VOLUNTEER_ROLES } from './types'; +import { userSchemaDto } from './dtos/userSchema.dto'; //import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('users') @@ -48,4 +50,10 @@ export class UsersController { } return this.usersService.update(id, { role: role as Role }); } + + @Post('/') + async createUser(@Body() createUserDto: userSchemaDto): Promise { + const { email, firstName, lastName, phone, role } = createUserDto; + return this.usersService.create(email, firstName, lastName, phone, role); + } } diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts new file mode 100644 index 00000000..8388a79a --- /dev/null +++ b/apps/backend/src/users/users.service.spec.ts @@ -0,0 +1,213 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UsersService } from './users.service'; +import { User } from './user.entity'; +import { Role } from './types'; +import { mock } from 'jest-mock-extended'; +import { In } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; + +const mockUserRepository = mock>(); + +const mockUser: User = { + id: 1, + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + phone: '1234567890', + role: Role.STANDARD_VOLUNTEER, +}; + +describe('UsersService', () => { + let service: UsersService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + beforeEach(() => { + mockUserRepository.create.mockReset(); + mockUserRepository.save.mockReset(); + mockUserRepository.findOneBy.mockReset(); + mockUserRepository.find.mockReset(); + mockUserRepository.remove.mockReset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new user with auto-generated ID', async () => { + const userData = { + email: 'newuser@example.com', + firstName: 'Jane', + lastName: 'Smith', + phone: '9876543210', + role: Role.ADMIN, + }; + + const createdUser = { ...userData, id: 1 }; + mockUserRepository.create.mockReturnValue(createdUser); + mockUserRepository.save.mockResolvedValue(createdUser); + + const result = await service.create( + userData.email, + userData.firstName, + userData.lastName, + userData.phone, + userData.role, + ); + + expect(result).toEqual(createdUser); + expect(mockUserRepository.create).toHaveBeenCalledWith({ + role: userData.role, + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + phone: userData.phone, + }); + expect(mockUserRepository.save).toHaveBeenCalledWith(createdUser); + }); + }); + + describe('findOne', () => { + it('should return a user by id', async () => { + mockUserRepository.findOneBy.mockResolvedValue(mockUser); + + const result = await service.findOne(1); + + expect(result).toEqual(mockUser); + expect(mockUserRepository.findOneBy).toHaveBeenCalledWith({ id: 1 }); + }); + + it('should throw NotFoundException when user is not found', async () => { + mockUserRepository.findOneBy.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow( + new NotFoundException('User 999 not found'), + ); + }); + + it('should throw error for invalid id', async () => { + await expect(service.findOne(-1)).rejects.toThrow( + new BadRequestException('Invalid User ID'), + ); + + expect(mockUserRepository.findOneBy).not.toHaveBeenCalled(); + }); + }); + + describe('find', () => { + it('should return users by email', async () => { + const users = [mockUser]; + mockUserRepository.find.mockResolvedValue(users); + + const result = await service.find('test@example.com'); + + expect(result).toEqual(users); + expect(mockUserRepository.find).toHaveBeenCalledWith({ + where: { email: 'test@example.com' }, + }); + }); + }); + + describe('update', () => { + it('should update user attributes', async () => { + const updateData = { firstName: 'Updated', role: Role.ADMIN }; + const updatedUser = { ...mockUser, ...updateData }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser); + mockUserRepository.save.mockResolvedValue(updatedUser); + + const result = await service.update(1, updateData); + + expect(result).toEqual(updatedUser); + expect(mockUserRepository.save).toHaveBeenCalledWith(updatedUser); + }); + + it('should throw NotFoundException when user is not found', async () => { + mockUserRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.update(999, { firstName: 'Updated' }), + ).rejects.toThrow(new NotFoundException('User 999 not found')); + }); + + it('should throw error for invalid id', async () => { + await expect( + service.update(-1, { firstName: 'Updated' }), + ).rejects.toThrow(new BadRequestException('Invalid User ID')); + + expect(mockUserRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('should remove a user by id', async () => { + mockUserRepository.findOneBy.mockResolvedValue(mockUser); + mockUserRepository.remove.mockResolvedValue(mockUser); + + const result = await service.remove(1); + + expect(result).toEqual(mockUser); + expect(mockUserRepository.remove).toHaveBeenCalledWith(mockUser); + }); + + it('should throw NotFoundException when user is not found', async () => { + mockUserRepository.findOneBy.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow( + new NotFoundException('User 999 not found'), + ); + }); + + it('should throw error for invalid id', async () => { + await expect(service.remove(-1)).rejects.toThrow( + new BadRequestException('Invalid User ID'), + ); + + expect(mockUserRepository.remove).not.toHaveBeenCalled(); + }); + }); + + describe('findUsersByRoles', () => { + it('should return users by roles', async () => { + const roles = [Role.ADMIN, Role.LEAD_VOLUNTEER]; + const users = [mockUser]; + mockUserRepository.find.mockResolvedValue(users); + + const result = await service.findUsersByRoles(roles); + + expect(result).toEqual(users); + expect(mockUserRepository.find).toHaveBeenCalledWith({ + where: { role: In(roles) }, + }); + }); + + it('should return empty array when no users found', async () => { + const roles = [Role.ADMIN]; + mockUserRepository.find.mockResolvedValue([]); + + const result = await service.findUsersByRoles(roles); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index f75037c8..f10a0237 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -14,15 +14,15 @@ export class UsersService { email: string, firstName: string, lastName: string, - role: Role = Role.STANDARD_VOLUNTEER, + phone: string, + role: Role, ) { - const userId = (await this.repo.count()) + 1; const user = this.repo.create({ - id: userId, role, firstName, lastName, email, + phone, }); return this.repo.save(user);