From 74d1a9cb3b773839918255f753a6d1e0372fb037 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 17:53:42 +0100 Subject: [PATCH] test: Recruitment Session Controller tests --- api/src/mocks/data.ts | 16 + .../recruitment-session.controller.spec.ts | 361 ++++++++++++++++++ .../recruitment-session.controller.ts | 23 +- 3 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 api/src/recruitment-session/recruitment-session.controller.spec.ts diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index 61b8b02..be63dcf 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -12,6 +12,8 @@ import { } from 'src/application/application.entity'; import { UpdateApplicationDto } from 'src/application/update-application.dto'; import { RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; +import { CreateRecruitmentSessionDto } from 'src/recruitment-session/create-recruitment-session.dto'; +import { UpdateRecruitmentSessionDto } from 'src/recruitment-session/update-recruitment-session.dto'; export const testDate = new Date(2023, 0, 1); export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0); @@ -44,6 +46,20 @@ export const mockRecruitmentSession = { lastModified: testDateLastModified, }; +export const mockCreateRecruitmentSessionDto = { + slotDuration: 50, + interviewStart: testInterviewStart, + interviewEnd: testInterviewEnd, + days: [testDay1, testDay2, testDay3], +} as CreateRecruitmentSessionDto; + +export const mockUpdateRecruitmentSessionDto = { + slotDuration: 50, + interviewStart: testInterviewStart, + interviewEnd: testInterviewEnd, + days: [testDay1, testDay2, testDay3], +} as UpdateRecruitmentSessionDto; + export const baseFile = { encoding: '7bit', mimetype: 'application/pdf', diff --git a/api/src/recruitment-session/recruitment-session.controller.spec.ts b/api/src/recruitment-session/recruitment-session.controller.spec.ts new file mode 100644 index 0000000..9e64d2d --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.controller.spec.ts @@ -0,0 +1,361 @@ +import { createMockAbility } from '@hkrecruitment/shared/abilities.spec'; +import { RecruitmentSessionController } from './recruitment-session.controller'; +import { RecruitmentSessionService } from './recruitment-session.service'; +import { Action, RecruitmentSessionState, Role } from '@hkrecruitment/shared'; +import { TestBed } from '@automock/jest'; +import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; +import { RecruitmentSession } from './recruitment-session.entity'; +import { + mockRecruitmentSession, + mockUpdateRecruitmentSessionDto, + mockCreateRecruitmentSessionDto, + testDate, +} from '@mocks/data'; +import { + BadRequestException, + ConflictException, + Delete, + ForbiddenException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; +import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; +import { UpdateRecruitmentSessionDto } from './update-recruitment-session.dto'; + +describe('RecruitmentSessionController', () => { + let controller: RecruitmentSessionController; + let service: RecruitmentSessionService; + + /************* Test setup ************/ + + beforeAll(() => { + jest + .spyOn(global, 'Date') + .mockImplementation(() => testDate as unknown as string); + }); + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create( + RecruitmentSessionController, + ).compile(); + + controller = unit; + service = unitRef.get(RecruitmentSessionService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(service).toBeDefined(); + }); + + describe('getActive RecruitmentSession', () => { + it('should return an active recruitment session if it exists', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Read, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = await controller.findActive(mockAbility); + const expectedApp = { + ...mockRecruitmentSession, + } as RecruitmentSessionResponseDto; + expect(result).toEqual(expectedApp); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + + it("should throw a ForbiddenException if the user can't read the recruitment session", async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Read, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue({ ...mockRecruitmentSession }); + const result = controller.findActive(mockAbility); + await expect(result).rejects.toThrow(ForbiddenException); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + }); + + describe('createRecruitmentSession', () => { + it('should create a recruitment session', async () => { + const expectedRecruitmentSession = { + ...mockRecruitmentSession, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'createRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = await controller.createRecruitmentSession( + mockCreateRecruitmentSessionDto, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.createRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.createRecruitmentSession).toHaveBeenCalledWith( + mockCreateRecruitmentSessionDto, + ); + }); + + it('should throw a ConflictException if there is already an active recruitment session', async () => { + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = controller.createRecruitmentSession( + mockCreateRecruitmentSessionDto, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateRecruitmentSession', () => { + it('should update a recruitment session', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockUpdatedRecruitmentSession = { + ...mockRecruitmentSession, + ...mockUpdateRecruitmentSessionDto, + } as RecruitmentSession; + const expectedRecruitmentSession = { + id: mockUpdatedRecruitmentSession.id, + state: mockUpdatedRecruitmentSession.state, + createdAt: mockUpdatedRecruitmentSession.createdAt, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + jest + .spyOn(service, 'updateRecruitmentSession') + .mockResolvedValue(mockUpdatedRecruitmentSession); + const result = await controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.updateRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.updateRecruitmentSession).toHaveBeenCalledWith({ + ...mockRecruitmentSession, + lastModified: testDate, + }); + }); + + it('should throw a NotFoundException if the recruitment session does not exist', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + jest.spyOn(service, 'findRecruitmentSessionById').mockResolvedValue(null); + const result = controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(NotFoundException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ForbiddenException if the user can't update the recruitment session", async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Update, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ForbiddenException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ConflictException when updating a RecruitmentSection state to 'Active' and there is already an active recruitment session", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Concluded, + id: 1, + } as RecruitmentSession; + const activeRecruitmentSession = { + ...mockRecruitmentSession, + id: 2, + state: RecruitmentSessionState.Active, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Active, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(activeRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + }); + + it("shouldn't throw a ConflictException when updating the currentyl active RecruitmentSection state to 'Active'", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Concluded, + id: 1, + } as RecruitmentSession; + const activeRecruitmentSession = { + ...mockRecruitmentSession, + id: 1, + state: RecruitmentSessionState.Active, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Active, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(activeRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).resolves.not.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ConflictException when updating a RecruitmentSection state to 'Concluded' and there are pending interviews", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Active, + id: 1, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Concluded, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'sessionHasPendingInterviews') + .mockResolvedValue(true); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledTimes(1); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledWith( + mockRecruitmentSessionToUpdate, + ); + }); + }); + + describe('deleteRecruitmentSession', () => { + it('should delete a recruitment session', async () => { + const mockDeletedRecruitmentSession = { + ...mockRecruitmentSession, + } as RecruitmentSession; + const expectedRecruitmentSession = { + id: mockDeletedRecruitmentSession.id, + state: mockDeletedRecruitmentSession.state, + createdAt: mockDeletedRecruitmentSession.createdAt, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + jest + .spyOn(service, 'deletRecruitmentSession') + .mockResolvedValue(mockDeletedRecruitmentSession); + const result = await controller.deleteRecruitmentSession( + mockRecruitmentSession.id, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.deletRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.deletRecruitmentSession).toHaveBeenCalledWith( + mockRecruitmentSession, + ); + }); + + it('should throw a NotFoundException if the recruitment session does not exist', async () => { + jest.spyOn(service, 'findRecruitmentSessionById').mockResolvedValue(null); + const result = controller.deleteRecruitmentSession( + mockRecruitmentSession.id, + ); + await expect(result).rejects.toThrow(NotFoundException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it('should throw a ConflictException when deleting a RecruitmentSection that has pending interviews', async () => { + const mockRecruitmentSessionToDelete = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Active, + id: 1, + } as RecruitmentSession; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToDelete); + jest + .spyOn(service, 'sessionHasPendingInterviews') + .mockResolvedValue(true); + const result = controller.deleteRecruitmentSession( + mockRecruitmentSessionToDelete.id, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledTimes(1); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledWith( + mockRecruitmentSessionToDelete, + ); + }); + }); +}); diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 7271f11..d68eddd 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -138,7 +138,6 @@ export class RecruitmentSessionController { Action.Update, sessionToCheck, 'RecruitmentSession', - ['applicantId'], ) ) throw new ForbiddenException(); @@ -148,7 +147,10 @@ export class RecruitmentSessionController { if (updateRecruitmentSession.state === RecruitmentSessionState.Active) { const currentlyActiveRecruitmentSession = await this.recruitmentSessionService.findActiveRecruitmentSession(); - if (currentlyActiveRecruitmentSession) + if ( + currentlyActiveRecruitmentSession && + currentlyActiveRecruitmentSession.id !== recruitmentSession.id // It's ok to set 'Active' to the (already) active recruitment session + ) throw new ConflictException( 'There is already an active recruitment session', ); @@ -174,8 +176,6 @@ export class RecruitmentSessionController { lastModified: new Date(), }); - // #TODO: CAN'T EDIT A REC SESSION IF IT'S NOT ACTIVE - return plainToClass( RecruitmentSessionResponseDto, updatedRecruitmentSession, @@ -187,6 +187,7 @@ export class RecruitmentSessionController { @ApiForbiddenResponse() @ApiNotFoundResponse() @ApiOkResponse() + @ApiConflictResponse() @ApiNoContentResponse() @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) @Delete('/:recruitment_session_id') @@ -197,9 +198,9 @@ export class RecruitmentSessionController { .required() .label('recruitment_session_id'), }) - async deleteRecruitmentSessionById( + async deleteRecruitmentSession( @Param('recruitment_session_id') recruitmentSessionId: number, - ): Promise { + ): Promise { // Check if recruitment session exists const toRemove = await this.recruitmentSessionService.findRecruitmentSessionById( @@ -214,14 +215,18 @@ export class RecruitmentSessionController { toRemove, ); if (hasPendingInterviews) - throw new BadRequestException( + throw new ConflictException( "Recruitment session can't be deleted because it has pending interviews", ); } // Delete recruitment session - return await this.recruitmentSessionService.deletRecruitmentSession( - toRemove, + const deletedRecruitmentSession = + await this.recruitmentSessionService.deletRecruitmentSession(toRemove); + + return plainToClass( + RecruitmentSessionResponseDto, + deletedRecruitmentSession, ); } }