diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index ef97a9eaf..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" - day: "monday" - time: "02:34" - timezone: "America/New_York" diff --git a/apps/backend/src/volunteers/dto/update-volunteer.dto.ts b/apps/backend/src/volunteers/dto/update-volunteer.dto.ts new file mode 100644 index 000000000..791f01f69 --- /dev/null +++ b/apps/backend/src/volunteers/dto/update-volunteer.dto.ts @@ -0,0 +1,79 @@ +import { Homebase } from '../../types'; +import { FosterType } from '../volunteers.types'; +import { + IsOptional, + IsString, + IsEnum, + IsNotEmpty, + MaxLength, + IsPhoneNumber, + IsEmail, +} from 'class-validator'; + +export class UpdateVolunteerDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + firstName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + lastName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @IsPhoneNumber('US') + phone?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @IsPhoneNumber('US') + secondaryPhone?: string; + + @IsOptional() + @IsNotEmpty() + @MaxLength(255) + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + address?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + city?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(10) + zipcode?: string; + + @IsOptional() + @IsEnum(Homebase) + homebase?: Homebase; + + @IsOptional() + @IsString() + @IsNotEmpty() + residentAnimals?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + notes?: string; + + @IsOptional() + @IsEnum(FosterType) + fosterType?: FosterType; +} diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index f7ece72f3..a005cf042 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -1,31 +1,43 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; import { VolunteersController } from './volunteers.controller'; import { VolunteersService } from './volunteers.service'; import { MatchesService } from '../matches/matches.service'; import { Match } from '../matches/matches.entity'; import { MatchStatus } from '../matches/matches.types'; +import { FosterVolunteer } from './volunteers.entity'; +import { UpdateVolunteerDto } from './dto/update-volunteer.dto'; describe('VolunteersController', () => { let controller: VolunteersController; - let volunteersService: { findByIdOrFail: jest.Mock }; - let matchesService: { findByVolunteerId: jest.Mock }; + + const mockVolunteer = { + volunteerId: 1, + firstName: 'Jane', + lastName: 'Doe', + notes: 'likes cats', + } as FosterVolunteer; + + let mockVolunteersService: { + findByIdOrFail: jest.Mock; + getVolunteerById: jest.Mock; + updateVolunteerById: jest.Mock; + }; + let mockMatchesService: { findByVolunteerId: jest.Mock }; beforeEach(async () => { - volunteersService = { findByIdOrFail: jest.fn() }; - matchesService = { findByVolunteerId: jest.fn() }; + mockVolunteersService = { + findByIdOrFail: jest.fn(), + getVolunteerById: jest.fn(), + updateVolunteerById: jest.fn(), + }; + mockMatchesService = { findByVolunteerId: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ controllers: [VolunteersController], providers: [ - { - provide: VolunteersService, - useValue: volunteersService, - }, - { - provide: MatchesService, - useValue: matchesService, - }, + { provide: VolunteersService, useValue: mockVolunteersService }, + { provide: MatchesService, useValue: mockMatchesService }, ], }).compile(); @@ -47,32 +59,102 @@ describe('VolunteersController', () => { deniedReason: 'Schedule conflict', }, ] as Match[]; - volunteersService.findByIdOrFail.mockResolvedValue({}); - matchesService.findByVolunteerId.mockResolvedValue(matches); + mockVolunteersService.findByIdOrFail.mockResolvedValue({}); + mockMatchesService.findByVolunteerId.mockResolvedValue(matches); const result = await controller.getVolunteerMatches(7); expect(result).toBe(matches); - expect(volunteersService.findByIdOrFail).toHaveBeenCalledWith(7); - expect(matchesService.findByVolunteerId).toHaveBeenCalledWith(7); + expect(mockVolunteersService.findByIdOrFail).toHaveBeenCalledWith(7); + expect(mockMatchesService.findByVolunteerId).toHaveBeenCalledWith(7); }); it('returns an empty array when the volunteer exists but has no matches', async () => { - volunteersService.findByIdOrFail.mockResolvedValue({}); - matchesService.findByVolunteerId.mockResolvedValue([]); + mockVolunteersService.findByIdOrFail.mockResolvedValue({}); + mockMatchesService.findByVolunteerId.mockResolvedValue([]); await expect(controller.getVolunteerMatches(7)).resolves.toEqual([]); }); it('throws NotFoundException when the volunteer does not exist', async () => { - volunteersService.findByIdOrFail.mockRejectedValue( + mockVolunteersService.findByIdOrFail.mockRejectedValue( new NotFoundException('Volunteer with ID 999 not found'), ); await expect(controller.getVolunteerMatches(999)).rejects.toThrow( new NotFoundException('Volunteer with ID 999 not found'), ); - expect(matchesService.findByVolunteerId).not.toHaveBeenCalled(); + expect(mockMatchesService.findByVolunteerId).not.toHaveBeenCalled(); + }); + }); + + describe('getVolunteerById', () => { + it('should return a volunteer when found', async () => { + mockVolunteersService.getVolunteerById.mockResolvedValue(mockVolunteer); + + const result = await controller.getVolunteerById(1); + + expect(result).toEqual(mockVolunteer); + expect(mockVolunteersService.getVolunteerById).toHaveBeenCalledWith(1); + }); + + it('should throw NotFoundException when volunteer does not exist', async () => { + mockVolunteersService.getVolunteerById.mockRejectedValue( + new NotFoundException('Volunteer with id 999 not found'), + ); + + await expect(controller.getVolunteerById(999)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when id is invalid', async () => { + await expect(controller.getVolunteerById(0)).rejects.toThrow( + BadRequestException, + ); + expect(mockVolunteersService.getVolunteerById).not.toHaveBeenCalled(); + }); + }); + + describe('updateVolunteerById', () => { + it('should update and return the volunteer when found', async () => { + const dto = { notes: 'updated notes' } as UpdateVolunteerDto; + const updatedVolunteer = { ...mockVolunteer, ...dto } as FosterVolunteer; + mockVolunteersService.updateVolunteerById.mockResolvedValue( + updatedVolunteer, + ); + + const result = await controller.updateVolunteerById(1, dto); + + expect(result).toEqual(updatedVolunteer); + expect(mockVolunteersService.updateVolunteerById).toHaveBeenCalledWith( + 1, + dto, + ); + }); + + it('should throw NotFoundException when volunteer does not exist', async () => { + mockVolunteersService.updateVolunteerById.mockRejectedValue( + new NotFoundException('Volunteer with id 999 not found'), + ); + + await expect( + controller.updateVolunteerById(999, { notes: 'x' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when id is invalid', async () => { + await expect( + controller.updateVolunteerById(0, { notes: 'x' }), + ).rejects.toThrow(BadRequestException); + expect(mockVolunteersService.updateVolunteerById).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when no fields are provided', async () => { + await expect( + controller.updateVolunteerById(1, {} as UpdateVolunteerDto), + ).rejects.toThrow(BadRequestException); + expect(mockVolunteersService.updateVolunteerById).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index a28c8aaa1..24b65b8a1 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -1,15 +1,20 @@ import { Controller, + Patch, Get, - HttpStatus, Param, + Body, + HttpStatus, ParseIntPipe, + BadRequestException, } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { VolunteersService } from './volunteers.service'; import { MatchesService } from '../matches/matches.service'; import { Match } from '../matches/matches.entity'; import { validateId } from '../utils/validation.utils'; +import { UpdateVolunteerDto } from './dto/update-volunteer.dto'; +import { FosterVolunteer } from './volunteers.entity'; @ApiTags('Volunteers') // @ApiBearerAuth() @@ -41,4 +46,46 @@ export class VolunteersController { return this.matchesService.findByVolunteerId(volunteerId); } + + @Get('/:volunteerId') + @ApiOperation({ summary: 'Get a volunteer by ID' }) + @ApiParam({ + name: 'volunteerId', + type: Number, + description: 'ID of the volunteer', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The volunteer was found', + type: FosterVolunteer, + }) + async getVolunteerById( + @Param('volunteerId', ParseIntPipe) volunteerId: number, + ): Promise { + validateId(volunteerId, 'Volunteer'); + return this.volunteersService.getVolunteerById(volunteerId); + } + + @Patch('/:volunteerId') + @ApiOperation({ summary: 'Update a volunteer by ID' }) + @ApiParam({ + name: 'volunteerId', + type: Number, + description: 'ID of the volunteer', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The volunteer was updated', + type: FosterVolunteer, + }) + async updateVolunteerById( + @Param('volunteerId', ParseIntPipe) volunteerId: number, + @Body() dto: UpdateVolunteerDto, + ): Promise { + validateId(volunteerId, 'Volunteer'); + if (Object.keys(dto).length === 0) { + throw new BadRequestException('At least one field must be provided'); + } + return this.volunteersService.updateVolunteerById(volunteerId, dto); + } } diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 7ff6ef097..f0e7afaf2 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -3,21 +3,28 @@ import { NotFoundException } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { VolunteersService } from './volunteers.service'; import { FosterVolunteer } from './volunteers.entity'; +import { UpdateVolunteerDto } from './dto/update-volunteer.dto'; describe('VolunteersService', () => { let service: VolunteersService; - let repo: { findOneBy: jest.Mock }; + + const mockVolunteer = { + volunteerId: 1, + firstName: 'Jane', + lastName: 'Doe', + notes: 'likes cats', + assignedCoordinator: null, + } as FosterVolunteer; + + let mockRepo: { findOneBy: jest.Mock; save: jest.Mock }; beforeEach(async () => { - repo = { findOneBy: jest.fn() }; + mockRepo = { findOneBy: jest.fn(), save: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ VolunteersService, - { - provide: getRepositoryToken(FosterVolunteer), - useValue: repo, - }, + { provide: getRepositoryToken(FosterVolunteer), useValue: mockRepo }, ], }).compile(); @@ -31,21 +38,94 @@ describe('VolunteersService', () => { describe('findByIdOrFail', () => { it('returns the volunteer when one with the id exists', async () => { const volunteer = { volunteerId: 7 } as FosterVolunteer; - repo.findOneBy.mockResolvedValue(volunteer); + mockRepo.findOneBy.mockResolvedValue(volunteer); const result = await service.findByIdOrFail(7); expect(result).toBe(volunteer); - expect(repo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); }); it('throws NotFoundException when no volunteer with the id exists', async () => { - repo.findOneBy.mockResolvedValue(null); + mockRepo.findOneBy.mockResolvedValue(null); await expect(service.findByIdOrFail(7)).rejects.toThrow( new NotFoundException('Volunteer with ID 7 not found'), ); - expect(repo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 }); + }); + }); + + describe('getVolunteerById', () => { + it('should return a volunteer when found', async () => { + mockRepo.findOneBy.mockResolvedValue(mockVolunteer); + + const result = await service.getVolunteerById(1); + + expect(result).toEqual(mockVolunteer); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 1 }); + }); + + it('should throw NotFoundException when volunteer does not exist', async () => { + mockRepo.findOneBy.mockResolvedValue(null); + + await expect(service.getVolunteerById(999)).rejects.toThrow( + new NotFoundException('Volunteer with id 999 not found'), + ); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 999 }); + }); + }); + + describe('updateVolunteerById', () => { + it('should update and return the volunteer with only the given fields changed', async () => { + mockRepo.findOneBy.mockResolvedValue({ ...mockVolunteer }); + mockRepo.save.mockImplementation((v) => Promise.resolve(v)); + + const dto = { notes: 'updated notes' } as UpdateVolunteerDto; + const result = await service.updateVolunteerById(1, dto); + + expect(result.notes).toBe('updated notes'); + expect(result.firstName).toBe('Jane'); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 1 }); + expect(mockRepo.save).toHaveBeenCalledWith({ + ...mockVolunteer, + notes: 'updated notes', + }); + }); + + it('should update multiple allowed fields at once', async () => { + mockRepo.findOneBy.mockResolvedValue({ ...mockVolunteer }); + mockRepo.save.mockImplementation((v) => Promise.resolve(v)); + + const dto = { + address: '123 Main St', + city: 'Boston', + zipcode: '02115', + } as UpdateVolunteerDto; + + const result = await service.updateVolunteerById(1, dto); + + expect(result.address).toBe('123 Main St'); + expect(result.city).toBe('Boston'); + expect(result.zipcode).toBe('02115'); + expect(mockRepo.findOneBy).toHaveBeenCalledWith({ volunteerId: 1 }); + expect(mockRepo.save).toHaveBeenCalledWith({ + ...mockVolunteer, + address: '123 Main St', + city: 'Boston', + zipcode: '02115', + }); + }); + + it('should throw NotFoundException when volunteer does not exist', async () => { + mockRepo.findOneBy.mockResolvedValue(null); + + await expect( + service.updateVolunteerById(999, { notes: 'x' }), + ).rejects.toThrow( + new NotFoundException('Volunteer with id 999 not found'), + ); + expect(mockRepo.save).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index 8d89957c8..ecdb07541 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { FosterVolunteer } from './volunteers.entity'; +import { UpdateVolunteerDto } from './dto/update-volunteer.dto'; @Injectable() export class VolunteersService { @@ -28,4 +29,39 @@ export class VolunteersService { return volunteer; } + + /** + * Gets a single foster volunteer by ID. + * Looks up the volunteer in the database and throws if no volunteer has that ID. + * @param id - ID of the volunteer to get + * @returns The volunteer with the given ID + * @throws NotFoundException if no volunteer exists with the given ID + */ + async getVolunteerById(id: number): Promise { + const volunteer = await this.repo.findOneBy({ volunteerId: id }); + if (!volunteer) { + throw new NotFoundException(`Volunteer with id ${id} not found`); + } + return volunteer; + } + + /** + * Updates a foster volunteer by ID. + * Only the fields included in the DTO are changed; all other fields keep their current values. + * @param id - ID of the volunteer to update + * @param dto - The fields to update + * @returns The updated volunteer + * @throws NotFoundException if no volunteer exists with the given ID + */ + async updateVolunteerById( + id: number, + dto: UpdateVolunteerDto, + ): Promise { + const volunteer = await this.repo.findOneBy({ volunteerId: id }); + if (!volunteer) { + throw new NotFoundException(`Volunteer with id ${id} not found`); + } + Object.assign(volunteer, dto); + return this.repo.save(volunteer); + } }