diff --git a/apps/backend/src/users/dtos/updateUserInfo.dto.ts b/apps/backend/src/users/dtos/updateUserInfo.dto.ts new file mode 100644 index 000000000..9ef2c43b3 --- /dev/null +++ b/apps/backend/src/users/dtos/updateUserInfo.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsPhoneNumber, IsOptional, IsNotEmpty, MaxLength } from 'class-validator'; + +export class updateUserInfo { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + firstName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + lastName?: string; + + @IsOptional() + @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/user.entity.ts b/apps/backend/src/users/user.entity.ts index 4481b22dc..28f874335 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -23,13 +23,25 @@ export class User { }) role: Role; - @Column() + @Column({ + type: 'varchar', + name: 'first_name', + length: 255, + }) firstName: string; - - @Column() + + @Column({ + type: 'varchar', + name: 'last_name', + length: 255, + }) lastName: string; - - @Column() + + @Column({ + type: 'varchar', + name: 'email', + length: 255, + }) email: string; @Column({ diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 97811a0e1..d840c1fad 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -1,12 +1,11 @@ -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'; +import { updateUserInfo } from './dtos/updateUserInfo.dto'; import { Pantry } from '../pantries/pantries.entity'; const mockUserService = mock(); @@ -137,24 +136,35 @@ describe('UsersController', () => { }); }); - describe('PUT /:id/role', () => { - it('should update user role with valid role', async () => { - const updatedUser = { ...mockUser1, role: Role.ADMIN }; + describe('PUT :id/info', () => { + it('should update user info with valid information', async () => { + const updatedUser = { + ...mockUser1, + firstName: 'UpdatedFirstName', + lastName: 'UpdatedLastName', + phone: '777-777-7777', + }; mockUserService.update.mockResolvedValue(updatedUser as User); - const result = await controller.updateRole(1, Role.ADMIN); + const updateUserSchema: updateUserInfo = { + firstName: 'UpdatedFirstName', + lastName: 'UpdatedLastName', + phone: '777-777-7777', + }; + const result = await controller.updateInfo(1, updateUserSchema); expect(result).toEqual(updatedUser); - expect(mockUserService.update).toHaveBeenCalledWith(1, { - role: Role.ADMIN, - }); + expect(mockUserService.update).toHaveBeenCalledWith(1, updateUserSchema); }); - it('should throw BadRequestException for invalid role', async () => { - await expect(controller.updateRole(1, 'invalid_role')).rejects.toThrow( - BadRequestException, - ); - expect(mockUserService.update).not.toHaveBeenCalled(); + it('should update user info with defaults', async () => { + mockUserService.update.mockResolvedValue(mockUser1 as User); + + const updateUserSchema: Partial = {}; + const result = await controller.updateInfo(1, updateUserSchema); + + expect(result).toEqual(mockUser1); + expect(mockUserService.update).toHaveBeenCalledWith(1, updateUserSchema); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 7040fc373..8d2d4b97e 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -4,15 +4,14 @@ import { Get, Param, ParseIntPipe, - Put, Post, - BadRequestException, Body, + Patch, } from '@nestjs/common'; import { UsersService } from './users.service'; import { User } from './user.entity'; -import { Role } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; +import { updateUserInfo } from './dtos/updateUserInfo.dto'; import { Pantry } from '../pantries/pantries.entity'; @Controller('users') @@ -43,15 +42,12 @@ export class UsersController { return this.usersService.remove(userId); } - @Put('/:id/role') - async updateRole( + @Patch(':id/info') + async updateInfo( @Param('id', ParseIntPipe) id: number, - @Body('role') role: string, + @Body() dto: updateUserInfo, ): Promise { - if (!Object.values(Role).includes(role as Role)) { - throw new BadRequestException('Invalid role'); - } - return this.usersService.update(id, { role: role as Role }); + return this.usersService.update(id, dto); } @Post('/') diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 64145f5a4..0571197b6 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -9,6 +9,7 @@ import { mock } from 'jest-mock-extended'; import { In } from 'typeorm'; import { BadRequestException } from '@nestjs/common'; import { PantriesService } from '../pantries/pantries.service'; +import { updateUserInfo } from './dtos/updateUserInfo.dto'; const mockUserRepository = mock>(); const mockPantriesService = mock(); @@ -143,16 +144,18 @@ describe('UsersService', () => { describe('update', () => { it('should update user attributes', async () => { - const updateData = { firstName: 'Updated', role: Role.ADMIN }; - const updatedUser = { ...mockUser, ...updateData }; - + const dto: updateUserInfo = { firstName: 'Updated' }; + const updatedUser = { ...mockUser, firstName: 'Updated' }; + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); mockUserRepository.save.mockResolvedValue(updatedUser as User); - - const result = await service.update(1, updateData); - + + const result = await service.update(1, dto); + expect(result).toEqual(updatedUser); - expect(mockUserRepository.save).toHaveBeenCalledWith(updatedUser); + expect(mockUserRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ firstName: 'Updated' }), + ); }); it('should throw NotFoundException when user is not found', async () => { @@ -224,4 +227,100 @@ describe('UsersService', () => { expect(result).toEqual([]); }); }); + + describe('update', () => { + it('should update firstName', async () => { + const dto: updateUserInfo = { firstName: 'Updated' }; + const updatedUser = { ...mockUser, firstName: 'Updated' }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); + + const result = await service.update(1, dto); + + expect(result.firstName).toBe('Updated'); + expect(mockUserRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ firstName: 'Updated' }), + ); + }); + + it('should update lastName', async () => { + const dto: updateUserInfo = { lastName: 'Smith' }; + const updatedUser = { ...mockUser, lastName: 'Smith' }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); + + const result = await service.update(1, dto); + + expect(result.lastName).toBe('Smith'); + }); + + it('should update phone', async () => { + const dto: updateUserInfo = { phone: '0987654321' }; + const updatedUser = { ...mockUser, phone: '0987654321' }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); + + const result = await service.update(1, dto); + + expect(result.phone).toBe('0987654321'); + }); + + it('should update multiple fields at once', async () => { + const dto: updateUserInfo = { firstName: 'Updated', lastName: 'Smith' }; + const updatedUser = { ...mockUser, firstName: 'Updated', lastName: 'Smith' }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); + + const result = await service.update(1, dto); + + expect(result.firstName).toBe('Updated'); + expect(result.lastName).toBe('Smith'); + }); + + it('should not overwrite fields absent from the DTO', async () => { + const dto: updateUserInfo = { firstName: 'OnlyFirst' }; + const updatedUser = { ...mockUser, firstName: 'OnlyFirst' }; + + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); + + const result = await service.update(1, dto); + + expect(result.lastName).toBe(mockUser.lastName); + expect(result.email).toBe(mockUser.email); + expect(result.phone).toBe(mockUser.phone); + expect(result.role).toBe(mockUser.role); + }); + + it('should throw BadRequestException when DTO is empty', async () => { + const dto: updateUserInfo = {}; + + await expect(service.update(1, dto)).rejects.toThrow( + new BadRequestException('At least one field must be provided to update'), + ); + + expect(mockUserRepository.findOneBy).not.toHaveBeenCalled(); + expect(mockUserRepository.save).not.toHaveBeenCalled(); + }); + + 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 BadRequestException for invalid id', async () => { + await expect(service.update(-1, { firstName: 'Updated' })).rejects.toThrow( + new BadRequestException('Invalid User ID'), + ); + + expect(mockUserRepository.findOneBy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 8432ba1a1..60ab66b4c 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -10,6 +10,7 @@ import { Role } from './types'; import { validateId } from '../utils/validation.utils'; import { Pantry } from '../pantries/pantries.entity'; import { PantriesService } from '../pantries/pantries.service'; +import { updateUserInfo } from './dtos/updateUserInfo.dto'; @Injectable() export class UsersService { @@ -73,16 +74,26 @@ export class UsersService { return user; } - async update(id: number, attrs: Partial) { + async update(id: number, dto: updateUserInfo): Promise { validateId(id, 'User'); - const user = await this.findOne(id); + const { firstName, lastName, phone } = dto; - if (!user) { - throw new NotFoundException(`User ${id} not found`); + if ( + firstName === undefined && + lastName === undefined && + phone === undefined + ) { + throw new BadRequestException( + 'At least one field must be provided to update', + ); } - Object.assign(user, attrs); + const user = await this.findOne(id); + + if (firstName !== undefined) user.firstName = firstName; + if (lastName !== undefined) user.lastName = lastName; + if (phone !== undefined) user.phone = phone; return this.repo.save(user); }