diff --git a/apps/backend/src/applications/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts index d0d1398a..fe83f07d 100644 --- a/apps/backend/src/applications/application.service.spec.ts +++ b/apps/backend/src/applications/application.service.spec.ts @@ -12,6 +12,7 @@ import { ApplicantType, } from './types'; import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; +import { EmailService } from '../util/email/email.service'; const dummyApplication: Application = { appId: 1, @@ -88,6 +89,10 @@ describe('ApplicationsService', () => { remove: jest.fn(), }; + const mockEmailService = { + queueEmail: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -96,6 +101,10 @@ describe('ApplicationsService', () => { provide: getRepositoryToken(Application), useValue: mockRepository, }, + { + provide: EmailService, + useValue: mockEmailService, + }, ], }).compile(); @@ -903,4 +912,147 @@ describe('ApplicationsService', () => { } }); }); + + describe('updateStatus', () => { + it('should update the application status and return the updated application', async () => { + const updatedApplication: Application = { + ...dummyApplication, + appStatus: AppStatus.ACCEPTED, + }; + + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue(updatedApplication); + + const result = await service.updateStatus(1, AppStatus.ACCEPTED); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(repository.save).toHaveBeenCalledWith({ + ...dummyApplication, + appStatus: AppStatus.ACCEPTED, + }); + expect(result).toEqual(updatedApplication); + }); + + it('should send an email when status is updated to ACCEPTED', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.ACCEPTED, + }); + + await service.updateStatus(1, AppStatus.ACCEPTED); + + expect(mockEmailService.queueEmail).toHaveBeenCalledWith( + dummyApplication.email, + 'Your Application Has Been Updated', + expect.stringContaining('Congratulations'), + ); + }); + + it('should send an email when status is updated to DECLINED', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.DECLINED, + }); + + await service.updateStatus(1, AppStatus.DECLINED); + + expect(mockEmailService.queueEmail).toHaveBeenCalledWith( + dummyApplication.email, + 'Your Application Has Been Updated', + expect.stringContaining('not been accepted'), + ); + }); + + it('should send an email when status is updated to NO_AVAILABILITY', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.NO_AVAILABILITY, + }); + + await service.updateStatus(1, AppStatus.NO_AVAILABILITY); + + expect(mockEmailService.queueEmail).toHaveBeenCalledWith( + dummyApplication.email, + 'Your Application Has Been Updated', + expect.stringContaining('no availability'), + ); + }); + + it('should not send an email when status is updated to IN_REVIEW', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.IN_REVIEW, + }); + + await service.updateStatus(1, AppStatus.IN_REVIEW); + + expect(mockEmailService.queueEmail).not.toHaveBeenCalled(); + }); + + it('should not send an email when status is updated to ACTIVE', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.ACTIVE, + }); + + await service.updateStatus(1, AppStatus.ACTIVE); + + expect(mockEmailService.queueEmail).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException when application is not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateStatus(999, AppStatus.ACCEPTED), + ).rejects.toThrow('Application with ID 999 not found'); + + expect(mockEmailService.queueEmail).not.toHaveBeenCalled(); + }); + + it('should pass along repo errors during retrieval without information loss', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.updateStatus(1, AppStatus.ACCEPTED)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + + expect(mockEmailService.queueEmail).not.toHaveBeenCalled(); + }); + + it('should pass along repo errors during save without information loss', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockRejectedValue( + new Error('There was a problem saving the info'), + ); + + await expect(service.updateStatus(1, AppStatus.ACCEPTED)).rejects.toThrow( + 'There was a problem saving the info', + ); + + expect(mockEmailService.queueEmail).not.toHaveBeenCalled(); + }); + + it('should pass along email service errors without information loss', async () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue({ + ...dummyApplication, + appStatus: AppStatus.ACCEPTED, + }); + mockEmailService.queueEmail.mockRejectedValueOnce( + new Error('Failed to send email'), + ); + + await expect(service.updateStatus(1, AppStatus.ACCEPTED)).rejects.toThrow( + 'Failed to send email', + ); + }); + }); }); diff --git a/apps/backend/src/applications/applications.controller.spec.ts b/apps/backend/src/applications/applications.controller.spec.ts index 30066dd7..9ffe0296 100644 --- a/apps/backend/src/applications/applications.controller.spec.ts +++ b/apps/backend/src/applications/applications.controller.spec.ts @@ -13,6 +13,7 @@ import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; import { EmailService } from '../util/email/email.service'; import { ApplicationValidationEmailFilter } from './filters/application-validation-email.filter'; import { ApplicationCreationErrorFilter } from './filters/application-creation-validation.filter'; +import { NotFoundException } from '@nestjs/common'; const mockEmailService = { queueEmail: jest.fn().mockResolvedValue(undefined), @@ -27,6 +28,7 @@ const mockApplicationsService: Partial = { findById: jest.fn(), create: jest.fn(), update: jest.fn(), + updateStatus: jest.fn(), delete: jest.fn(), findByDiscipline: jest.fn(), updateProposedStartDate: jest.fn(), @@ -548,4 +550,97 @@ describe('ApplicationsController', () => { ).rejects.toThrow(errorMessage); }); }); + + describe('updateApplicationStatus', () => { + it('should call updateStatus with the correct appId and status', async () => { + const updatedApplication: Application = { + ...mockApplication, + appStatus: AppStatus.ACCEPTED, + }; + + jest + .spyOn(mockApplicationsService, 'updateStatus') + .mockResolvedValue(updatedApplication); + + const result = await controller.updateApplicationStatus(1, { + appStatus: AppStatus.ACCEPTED, + }); + + expect(result).toEqual(updatedApplication); + expect(mockApplicationsService.updateStatus).toHaveBeenCalledWith( + 1, + AppStatus.ACCEPTED, + ); + }); + + it('should call updateStatus with DECLINED status', async () => { + const updatedApplication: Application = { + ...mockApplication, + appStatus: AppStatus.DECLINED, + }; + + jest + .spyOn(mockApplicationsService, 'updateStatus') + .mockResolvedValue(updatedApplication); + + const result = await controller.updateApplicationStatus(1, { + appStatus: AppStatus.DECLINED, + }); + + expect(result).toEqual(updatedApplication); + expect(mockApplicationsService.updateStatus).toHaveBeenCalledWith( + 1, + AppStatus.DECLINED, + ); + }); + + it('should call updateStatus with NO_AVAILABILITY status', async () => { + const updatedApplication: Application = { + ...mockApplication, + appStatus: AppStatus.NO_AVAILABILITY, + }; + + jest + .spyOn(mockApplicationsService, 'updateStatus') + .mockResolvedValue(updatedApplication); + + const result = await controller.updateApplicationStatus(1, { + appStatus: AppStatus.NO_AVAILABILITY, + }); + + expect(result).toEqual(updatedApplication); + expect(mockApplicationsService.updateStatus).toHaveBeenCalledWith( + 1, + AppStatus.NO_AVAILABILITY, + ); + }); + + it('should throw NotFoundException when application does not exist', async () => { + jest + .spyOn(mockApplicationsService, 'updateStatus') + .mockRejectedValue( + new NotFoundException('Application with ID 999 not found'), + ); + + await expect( + controller.updateApplicationStatus(999, { + appStatus: AppStatus.ACCEPTED, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('should pass along service errors without information loss', async () => { + jest + .spyOn(mockApplicationsService, 'updateStatus') + .mockRejectedValue( + new Error('There was a problem updating the status'), + ); + + await expect( + controller.updateApplicationStatus(1, { + appStatus: AppStatus.ACCEPTED, + }), + ).rejects.toThrow('There was a problem updating the status'); + }); + }); }); diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts index a5e6439c..f8665823 100644 --- a/apps/backend/src/applications/applications.controller.ts +++ b/apps/backend/src/applications/applications.controller.ts @@ -140,9 +140,10 @@ export class ApplicationsController { @Param('appId', ParseIntPipe) appId: number, @Body() updateStatusDto: UpdateApplicationStatusDto, ): Promise { - return await this.applicationsService.update(appId, { - appStatus: updateStatusDto.appStatus, - }); + return await this.applicationsService.updateStatus( + appId, + updateStatusDto.appStatus, + ); } /** diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 42433424..be6f4824 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -12,6 +12,21 @@ import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; import { EmailService } from '../util/email/email.service'; import { UsersService } from '../users/users.service'; +const STATUS_EMAIL_SUBJECTS: Partial> = { + [AppStatus.ACCEPTED]: 'Your Application Has Been Updated', + [AppStatus.DECLINED]: 'Your Application Has Been Updated', + [AppStatus.NO_AVAILABILITY]: 'Your Application Has Been Updated', +}; + +const STATUS_EMAIL_BODIES: Partial> = { + [AppStatus.ACCEPTED]: + 'Hello Applicant,

Congratulations! Your application has been accepted. Please complete your forms in the MyForms tab on your applicant portal.

Thank you,
BHCHP Team', + [AppStatus.DECLINED]: + 'Hello Applicant,

We regret to inform you that your application has not been accepted at this time.

Thank you,
BHCHP Team', + [AppStatus.NO_AVAILABILITY]: + 'Hello Applicant,

We wanted to inform you that there is currently no availability at this time.

Thank you,
BHCHP Team', +}; + /** * Escapes characters that have special meaning in HTML so a string is safe to embed in text or attributes. * @@ -33,6 +48,7 @@ export class ApplicationsService { constructor( @InjectRepository(Application) private applicationRepository: Repository, + private emailService: EmailService, ) {} /** @@ -193,6 +209,34 @@ export class ApplicationsService { return await this.applicationRepository.save(application); } + /** + * Updates the status of an application and sends a notification email + * if the new status is ACCEPTED, DECLINED, or NO_AVAILABILITY. + * @param appId The id of the application to update. + * @param appStatus The new application status. + * @returns The updated application object. + * @throws {NotFoundException} if the application does not exist. + * @throws {Error} which is unchanged from what repository or email service throws. + */ + async updateStatus( + appId: number, + appStatus: AppStatus, + ): Promise { + const application = await this.findById(appId); + + application.appStatus = appStatus; + const updated = await this.applicationRepository.save(application); + + const subject = STATUS_EMAIL_SUBJECTS[appStatus]; + const body = STATUS_EMAIL_BODIES[appStatus]; + + if (subject && body) { + await this.emailService.queueEmail(application.email, subject, body); + } + + return updated; + } + /** * Updates an application's commitment starting date with validation. * @param appId The id of the application to update.