Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions apps/backend/src/applications/application.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [
Expand All @@ -96,6 +101,10 @@ describe('ApplicationsService', () => {
provide: getRepositoryToken(Application),
useValue: mockRepository,
},
{
provide: EmailService,
useValue: mockEmailService,
},
],
}).compile();

Expand Down Expand Up @@ -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',
);
});
});
});
95 changes: 95 additions & 0 deletions apps/backend/src/applications/applications.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -27,6 +28,7 @@ const mockApplicationsService: Partial<ApplicationsService> = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
findByDiscipline: jest.fn(),
updateProposedStartDate: jest.fn(),
Expand Down Expand Up @@ -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');
});
});
});
7 changes: 4 additions & 3 deletions apps/backend/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ export class ApplicationsController {
@Param('appId', ParseIntPipe) appId: number,
@Body() updateStatusDto: UpdateApplicationStatusDto,
): Promise<Application> {
return await this.applicationsService.update(appId, {
appStatus: updateStatusDto.appStatus,
});
return await this.applicationsService.updateStatus(
appId,
updateStatusDto.appStatus,
);
}

/**
Expand Down
44 changes: 44 additions & 0 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@
import { AppStatus, PHONE_REGEX } from './types';
import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants';
import { EmailService } from '../util/email/email.service';
import { UsersService } from '../users/users.service';

Check warning on line 13 in apps/backend/src/applications/applications.service.ts

View workflow job for this annotation

GitHub Actions / build

'UsersService' is defined but never used

const STATUS_EMAIL_SUBJECTS: Partial<Record<AppStatus, string>> = {
[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<Record<AppStatus, string>> = {
[AppStatus.ACCEPTED]:
'Hello Applicant,<br><br>Congratulations! Your application has been accepted. Please complete your forms in the MyForms tab on your applicant portal.<br><br>Thank you,<br>BHCHP Team',
[AppStatus.DECLINED]:
'Hello Applicant,<br><br>We regret to inform you that your application has not been accepted at this time.<br><br>Thank you,<br>BHCHP Team',
[AppStatus.NO_AVAILABILITY]:
'Hello Applicant,<br><br>We wanted to inform you that there is currently no availability at this time.<br><br>Thank you,<br>BHCHP Team',
};

/**
* Escapes characters that have special meaning in HTML so a string is safe to embed in text or attributes.
*
Expand All @@ -33,6 +48,7 @@
constructor(
@InjectRepository(Application)
private applicationRepository: Repository<Application>,
private emailService: EmailService,
) {}

/**
Expand Down Expand Up @@ -193,6 +209,34 @@
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<Application> {
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.
Expand Down
Loading