diff --git a/api/src/application/application.entity.ts b/api/src/application/application.entity.ts index a9e0dd3..222cd13 100644 --- a/api/src/application/application.entity.ts +++ b/api/src/application/application.entity.ts @@ -40,12 +40,6 @@ export class Application implements ApplicationInterface { @Column({ length: 255 }) cv: string; - // @Column() - // availability: TimeSlot[]; - - // @Column({ "name": "interview_id" }) - // interviewId: number; - @Column({ name: 'ita_level' }) itaLevel: LangLevel; } diff --git a/api/src/application/applications.service.ts b/api/src/application/applications.service.ts index 079b473..98b1f0f 100644 --- a/api/src/application/applications.service.ts +++ b/api/src/application/applications.service.ts @@ -53,6 +53,15 @@ export class ApplicationsService { return match.length > 0; } + async findLastApplicationByActiveUserId(applicantId: string): Promise { + return await this.applicationRepository.findOne({ + where: { applicantId }, + order: { + lastModified: 'DESC' + }, + }); + } + async listApplications( submittedFrom: string, submittedUntil: string, diff --git a/api/src/interview/create-interview.dto.ts b/api/src/interview/create-interview.dto.ts index 5c0c074..4541420 100644 --- a/api/src/interview/create-interview.dto.ts +++ b/api/src/interview/create-interview.dto.ts @@ -1,28 +1,10 @@ import { Interview } from '@hkrecruitment/shared'; -import { User } from '../users/user.entity' import { ApiProperty } from '@nestjs/swagger'; - export class CreateInterviewDto implements Partial { - - @ApiProperty() - notes: string; - - @ApiProperty() - created_at: Date; - @ApiProperty() - id_timeslot: number; + createdAt: Date; @ApiProperty() - id_application: number; - - @ApiProperty() - interviewer_1: User; - - @ApiProperty() - interviewer_2: User; - - @ApiProperty({ required: false }) - optional_interviewer?: User; + timeslot_id: number; } diff --git a/api/src/interview/interview.controller.spec.ts b/api/src/interview/interview.controller.spec.ts new file mode 100644 index 0000000..41a08f8 --- /dev/null +++ b/api/src/interview/interview.controller.spec.ts @@ -0,0 +1,162 @@ +import { Action, Person, Role } from '@hkrecruitment/shared'; +import { InterviewController } from './interview.controller'; +import { InterviewService } from './interview.service'; +import { TestBed } from '@automock/jest'; +import { createMockAbility } from '@hkrecruitment/shared/abilities.spec'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { CreateInterviewDto } from './create-interview.dto'; +import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; +import { createMock } from '@golevelup/ts-jest'; +import { UpdateInterviewDto } from './update-interview.dto'; +import { Interview } from './interview.entity'; +import { + mockInterview, + MockCreateInterviewDTO, + MockUpdateInterviewDTO, + mockTimeSlot, +} from '@mocks/data'; + +describe('InterviewController', () => { + let controller: InterviewController; + let service: InterviewService; + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create(InterviewController).compile(); + + controller = unit; + service = unitRef.get(InterviewService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(service).toBeDefined(); + }); + + describe('findById', () => { + it('should return an Interview if it exists', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Read, 'Interview'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(mockInterview); + expect(await controller.findById(123, mockAbility)).toStrictEqual( + mockInterview, + ); + expect(service.findById).toHaveBeenCalled(); + expect(mockAbility.can).toHaveBeenCalled(); + }); + + it("should throw a NotFoundException if the Interview doesn't exist", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Read, 'Interview'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(null); + await expect(controller.findById(321, mockAbility)).rejects.toThrow( + NotFoundException, + ); + expect(service.findById).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + const mockReq = createMock(); + const mockInterviewDto: UpdateInterviewDto = { + ...mockInterview, + }; + it('should update an Interview if it exists and the user is allowed to update it', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'Interview'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(mockInterview); + jest + .spyOn(service, 'update') + .mockImplementation((mockInterview) => Promise.resolve(mockInterview)); + expect( + await controller.update( + 123, + { ...mockInterviewDto, notes: 'Notes' }, + mockAbility, + mockReq, + ), + ).toStrictEqual({ ...mockInterview, notes: 'Notes' }); + expect(service.findById).toHaveBeenCalledTimes(1); + expect(service.update).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + + it("should throw a NotFoundException if the Interview doesn't exist", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'Interview'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(null); + await expect( + controller.update(321, mockInterviewDto, mockAbility, mockReq), + ).rejects.toThrow(NotFoundException); + expect(service.findById).toHaveBeenCalledTimes(1); + }); + + it('should throw a ForbiddenException if the user is not allowed to update the Interview', async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Update, 'Interview', { oauthId: 123 }); + }); + jest.spyOn(service, 'findById').mockResolvedValue(mockInterview); + await expect( + controller.update(123, mockInterviewDto, mockAbility, mockReq), + ).rejects.toThrow(ForbiddenException); + expect(service.findById).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + }); + + describe('create', () => { + const mockReq = createMock(); + const mockInterviewDto: CreateInterviewDto = { + createdAt: new Date(2023, 0, 1), + timeslot_id: mockTimeSlot.id, + }; + + beforeEach(() => { + jest + .spyOn(service, 'create') + .mockImplementation((mockInterviewDto) => + Promise.resolve(mockInterview), + ); + }); + + it("should create an Interview if it doesn't exist and it's allowed", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Create, 'Person'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(null); + expect( + await controller.create(mockInterviewDto, mockAbility, mockReq), + ).toStrictEqual(mockInterview); + expect(service.findById).toHaveBeenCalledTimes(1); + expect(service.create).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + + it('should throw if interview with that Id already exists', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Create, 'Interview'); + }); + jest.spyOn(service, 'findById').mockResolvedValue(mockInterview); + await expect( + controller.create(mockInterviewDto, mockAbility, mockReq), + ).rejects.toThrow(ForbiddenException); + expect(service.findById).toHaveBeenCalledTimes(1); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should throw if the user is not allowed to create an Interview', async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Create, 'Interview', { oauthId: '312' }); + }); + jest.spyOn(service, 'findById').mockResolvedValue(null); + await expect( + controller.create(mockInterviewDto, mockAbility, mockReq), + ).rejects.toThrow(ForbiddenException); + expect(service.create).not.toHaveBeenCalled(); + expect(mockAbility.can).toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/interview/interview.controller.ts b/api/src/interview/interview.controller.ts index 9969a82..bd4134f 100644 --- a/api/src/interview/interview.controller.ts +++ b/api/src/interview/interview.controller.ts @@ -8,9 +8,10 @@ import { Body, Post, Req, - Delete + Delete, + ConflictException, } from '@nestjs/common'; -import { Interview } from './interview.entity'; +import { Interview } from './interview.entity'; import { InterviewService } from './interview.Service'; import { TimeSlotsService } from '../timeslots/timeslots.service'; import { ApplicationsService } from '../application/applications.service'; @@ -24,7 +25,7 @@ import { updateInterviewSchema, } from '@hkrecruitment/shared'; import { JoiValidate } from 'src/joi-validation/joi-validate.decorator'; -import { +import { ApiBadRequestResponse, ApiBearerAuth, ApiNotFoundResponse, @@ -38,13 +39,12 @@ import { Ability } from 'src/authorization/ability.decorator'; @ApiBearerAuth() @ApiTags('interview') @Controller('interview') - export class InterviewController { constructor( private readonly interviewService: InterviewService, private readonly timeSlotService: TimeSlotsService, - private readonly applicationService: ApplicationsService - ) {} + private readonly applicationService: ApplicationsService, + ) {} @ApiNotFoundResponse() @ApiBadRequestResponse() @@ -108,7 +108,7 @@ export class InterviewController { ...updateInterview, }); } - + @ApiNotFoundResponse() @ApiBadRequestResponse() @Post() @@ -121,19 +121,20 @@ export class InterviewController { @Ability() ability: AppAbility, @Req() req: AuthenticatedRequest, ): Promise { - const timeslot = await this.timeSlotService.findById(interview.id_timeslot) - if (timeslot === null) { - throw new NotFoundException(); - } - const application = await this.applicationService.findByApplicationId(interview.id_application) - if (application === null) { - throw new NotFoundException(); - } - return this.interviewService.create( - interview, - application, - timeslot - ); + const Id = req.user.sub; + const timeslot = await this.timeSlotService.findById(interview.timeslot_id); + if (timeslot === null) { + throw new NotFoundException(); + } + const application = + await this.applicationService.findLastApplicationByActiveUserId(Id); + if (application === null) { + throw new NotFoundException(); + } + try { + return this.interviewService.create(interview, application, timeslot); + } catch { + throw new ConflictException(); + } } } - \ No newline at end of file diff --git a/api/src/interview/interview.entity.ts b/api/src/interview/interview.entity.ts index 58c7784..e6e0c7f 100644 --- a/api/src/interview/interview.entity.ts +++ b/api/src/interview/interview.entity.ts @@ -1,6 +1,6 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; -import {Interview as InterviewSlot} from '@hkrecruitment/shared'; -import {User} from '../users/user.entity'; +import { Interview as InterviewSlot } from '@hkrecruitment/shared'; +import { User } from '../users/user.entity'; import { TimeSlot } from '../timeslots/timeslot.entity'; import { Application } from '../application/application.entity'; @@ -12,8 +12,8 @@ export class Interview implements InterviewSlot { @Column() notes: string; - @Column() - created_at: Date; + @Column({ name: 'created_at' }) + createdAt: Date; @Column() timeslot: TimeSlot; @@ -21,12 +21,12 @@ export class Interview implements InterviewSlot { @Column() application: Application; - @Column() - interviewer_1: User; + @Column({ name: 'interviewer_1' }) + interviewer1: User; - @Column() - interviewer_2: User; + @Column({ name: 'interviewer_2' }) + interviewer2: User; - @Column({nullable: true}) - optional_interviewer?: User; + @Column({ name: 'optional_interviewer', nullable: true }) + optionalInterviewer?: User; } diff --git a/api/src/interview/interview.module.ts b/api/src/interview/interview.module.ts index b07b9fc..df2a3a2 100644 --- a/api/src/interview/interview.module.ts +++ b/api/src/interview/interview.module.ts @@ -3,9 +3,10 @@ import { InterviewService } from './interview.service'; import { InterviewController } from './interview.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Interview } from './interview.entity'; +import { RecruitmentSessionModule } from 'src/recruitment-session/recruitment-session.module'; @Module({ - imports: [TypeOrmModule.forFeature([Interview])], + imports: [TypeOrmModule.forFeature([Interview]), RecruitmentSessionModule], providers: [InterviewService], controllers: [InterviewController], exports: [InterviewService], diff --git a/api/src/interview/interview.service.spec.ts b/api/src/interview/interview.service.spec.ts new file mode 100644 index 0000000..467a754 --- /dev/null +++ b/api/src/interview/interview.service.spec.ts @@ -0,0 +1,82 @@ +import { InterviewService } from './interview.service'; +import { TestBed } from '@automock/jest'; +import { Repository } from 'typeorm'; +import { Interview } from './interview.entity'; +import { CreateInterviewDto } from './create-interview.dto'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { createMock } from '@golevelup/ts-jest'; +import { TimeSlot } from 'src/timeslots/timeslot.entity'; +import { Application } from 'src/application/application.entity'; + +describe('InterviewService', () => { + let service: InterviewService; + let model: Repository; + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create(InterviewService) + .mock>(getRepositoryToken(Interview) as string) + .using(createMock>()) + .compile(); + + service = unit; + model = unitRef.get(getRepositoryToken(Interview) as string); + }); + + describe('findById', () => { + it('should return interview if found', async () => { + const mockInterview = new Interview(); + mockInterview.id = -1; + jest.spyOn(model, 'findOne').mockResolvedValue(mockInterview); + + expect(await service.findById(-1)).toBe(mockInterview); + expect(model.findOne).toHaveBeenCalledTimes(1); + }); + + it('should return null if not found', async () => { + jest.spyOn(model, 'findOne').mockResolvedValue(null); + + expect(await service.findById(0)).toBe(null); + expect(model.findOne).toHaveBeenCalledTimes(1); + }); + }); + + describe('delete', () => { + it('should delete interview', async () => { + const mockInterview = new Interview(); + jest.spyOn(model, 'remove').mockResolvedValue(mockInterview as any); + + expect(await service.delete(mockInterview)).toBe(mockInterview); + expect(model.remove).toHaveBeenCalledTimes(1); + expect(model.remove).toHaveBeenCalledWith(mockInterview); + }); + }); + + describe('create', () => { + it('should create interview', async () => { + const mockInterview = new Interview(); + const mockInterviewDto = new CreateInterviewDto(); + const mockApplication = new Application(); + const mockTimeSlot = new TimeSlot(); + + jest.spyOn(model, 'save').mockResolvedValue(mockInterview as any); + jest.spyOn(model, 'findOne').mockResolvedValue(null); + + expect( + await service.create(mockInterviewDto, mockApplication, mockTimeSlot), + ).toBe(mockInterview); + expect(model.save).toHaveBeenCalledTimes(1); + expect(model.save).toHaveBeenCalledWith(mockInterview); + }); + }); + + describe('update', () => { + it('should update interview', async () => { + const mockInterview = new Interview(); + jest.spyOn(model, 'save').mockResolvedValue(mockInterview as any); + + expect(await service.update(mockInterview)).toBe(mockInterview); + expect(model.save).toHaveBeenCalledTimes(1); + expect(model.save).toHaveBeenCalledWith(mockInterview); + }); + }); +}); diff --git a/api/src/interview/interview.service.ts b/api/src/interview/interview.service.ts index b60132f..b721ec9 100644 --- a/api/src/interview/interview.service.ts +++ b/api/src/interview/interview.service.ts @@ -1,16 +1,21 @@ -import { Interview } from "./interview.entity" -import { Application } from "../application/application.entity"; -import { TimeSlot } from "../timeslots/timeslot.entity"; -import { InjectRepository } from '@nestjs/typeorm'; +import { Interview } from './interview.entity'; +import { Application } from '../application/application.entity'; +import { TimeSlot } from '../timeslots/timeslot.entity'; +import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; import { Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { CreateInterviewDto } from "./create-interview.dto"; +import { Repository, DataSource } from 'typeorm'; +import { CreateInterviewDto } from './create-interview.dto'; +import { transaction } from 'src/utils/database'; +import { RecruitmentSessionService } from 'src/recruitment-session/recruitment-session.service'; @Injectable() export class InterviewService { constructor( @InjectRepository(Interview) private readonly interviewRepository: Repository, + @InjectDataSource() + private dataSource: DataSource, + private readonly recruitmentSessionService: RecruitmentSessionService, ) {} async findById(id: number): Promise { @@ -21,12 +26,27 @@ export class InterviewService { return await this.interviewRepository.remove(interview); } - async create(interview: CreateInterviewDto, application: Application, timeslot: TimeSlot): Promise { - return await this.interviewRepository.save({...interview, application, timeslot}); + async create( + interview: CreateInterviewDto, + application: Application, + timeslot: TimeSlot, + ): Promise { + return transaction(this.dataSource, async (queryRunner) => { + const recruitmentSession = + await this.recruitmentSessionService.findActiveRecruitmentSession(); + + // filtrare applicant no None e No applicant + // join per disponibilità usando timeslot, caso non disponibili mando eccezione, + // 1 board 1 expert necessaria MODIFICA FUTURA + + const scheduledInterview = await queryRunner.manager + .getRepository(Interview) + .save({ ...interview, application, timeslot, recruitmentSession }); + return recruitmentSession; + }); } async update(interview: Interview): Promise { return await this.interviewRepository.save(interview); } } - \ No newline at end of file diff --git a/api/src/interview/update-interview.dto.ts b/api/src/interview/update-interview.dto.ts index 5152d68..b2e6dbc 100644 --- a/api/src/interview/update-interview.dto.ts +++ b/api/src/interview/update-interview.dto.ts @@ -1,28 +1,28 @@ import { ApiProperty, PartialType } from '@nestjs/swagger'; import { Interview } from '@hkrecruitment/shared'; -import { User } from '../users/user.entity' +import { User } from '../users/user.entity'; import { TimeSlot } from '../timeslots/timeslot.entity'; import { Application } from '../application/application.entity'; -export class UpdateInterviewDto implements Partial{ - @ApiProperty({required: false}) +export class UpdateInterviewDto implements Partial { + @ApiProperty({ required: false }) notes: string; - @ApiProperty({required: false}) - created_at: Date; + @ApiProperty({ required: false }) + createdAt: Date; - @ApiProperty({required: false}) + @ApiProperty({ required: false }) timeslot: TimeSlot; - @ApiProperty({required: false}) + @ApiProperty({ required: false }) application: Application; - @ApiProperty({required: false}) - interviewer_1: User; + @ApiProperty({ required: false }) + interviewer1: User; - @ApiProperty({required: false}) - interviewer_2: User; + @ApiProperty({ required: false }) + interviewer2: User; @ApiProperty({ required: false }) - optional_interviewer?: User; -} \ No newline at end of file + optional_interviewer?: User; +} diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index 64b1c36..ab6b7aa 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -20,6 +20,9 @@ import { AvailabilityState, TimeSlot, } from '@hkrecruitment/shared'; +import { Interview } from 'src/interview/interview.entity'; +import { UpdateInterviewDto } from 'src/interview/update-interview.dto'; +import { CreateInterviewDto } from 'src/interview/create-interview.dto'; export const testDate = new Date(2023, 0, 1, 10, 0, 0); export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0); @@ -228,3 +231,50 @@ export const mockAvailability = { export const mockCreateAvailabilityDto = { timeSlotId: mockTimeSlot.id, } as CreateAvailabilityDto; + +export const mockInterview: Interview = { + id: 123, + notes: 'qwerty', + createdAt: new Date(2023, 0, 1), + timeslot: mockTimeSlot, + application: mockMscApplication, + interviewer1: { + oauthId: '123', + firstName: 'Jane', + lastName: 'Doe', + sex: 'F', + email: 'jane@hknpolito.org', + is_board: true, + is_expert: false, + role: Role.Member, + }, + interviewer2: { + oauthId: '456', + firstName: 'John', + lastName: 'Doe', + sex: 'M', + email: 'john@hknpolito.org', + is_board: false, + is_expert: true, + role: Role.Member, + }, + optionalInterviewer: { + oauthId: '678', + firstName: 'Jack', + lastName: 'Dao', + sex: 'M', + email: 'jack@hknpolito.org', + is_board: false, + is_expert: false, + role: Role.Member, + }, +}; + +export const MockCreateInterviewDTO: CreateInterviewDto = { + createdAt: new Date(2023, 0, 1), + timeslot_id: mockTimeSlot.id, +}; + +export const MockUpdateInterviewDTO = { + notes: 'Notes', +} as UpdateInterviewDto; diff --git a/shared/src/index.ts b/shared/src/index.ts index c0afbc8..e4112bc 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,5 +4,5 @@ export * from "./application"; export * from "./availability"; export * from "./timeslot"; export * from "./slot"; -export * from "./interview" export * from "./recruitment-session"; +export * from "./interview"; diff --git a/shared/src/interview.ts b/shared/src/interview.ts index d475814..88fb56d 100644 --- a/shared/src/interview.ts +++ b/shared/src/interview.ts @@ -1,47 +1,49 @@ import { Person, createUserSchema, updateUserSchema, Role } from "./person"; -import { TimeSlot, createTimeSlotSchema } from "timeslot"; -import { Application, createApplicationSchema, updateApplicationSchema } from "application"; +import { TimeSlot } from "timeslot"; +import { + Application, + createApplicationSchema, + updateApplicationSchema, +} from "application"; import { Action, ApplyAbilities } from "./abilities"; import * as Joi from "joi"; export interface Interview { - id: number; - notes: string; - created_at: Date; - timeslot: TimeSlot; - application: Application; - interviewer_1: Person; - interviewer_2: Person; - optional_interviewer?: Person; -}; + id: number; + notes: string; + createdAt: Date; + timeslot: TimeSlot; + application: Application; + interviewer1: Person; + interviewer2: Person; + optionalInterviewer?: Person; +} export const createInterviewSchema = Joi.object({ - notes: Joi.string().required(), - created_at: Joi.date().required(), - interviewer_1: createUserSchema.required(), - interviewer_2: createUserSchema.required(), - optional_interviewer: createUserSchema.optional(), + notes: Joi.string().required(), + createdAt: Joi.date().required(), + interviewer1: createUserSchema.required(), + interviewer2: createUserSchema.required(), + optionalInterviewer: createUserSchema.optional(), }); export const updateInterviewSchema = Joi.object({ - notes: Joi.string().optional(), - created_at: Joi.date().optional(), - timeslot: createTimeSlotSchema.optional(), - application: updateApplicationSchema.optional(), - interviewer_1: updateUserSchema.optional(), - interviewer_2: updateUserSchema.optional(), - optional_interviewer: updateUserSchema.optional() -}); + notes: Joi.string().optional(), + createdAt: Joi.date().optional(), + //timeslot: updateTimeSlotSchema.optional(), + application: updateApplicationSchema.optional(), + interviewer1: updateUserSchema.optional(), + interviewer2: updateUserSchema.optional(), + optionalInterviewer: updateUserSchema.optional(), +}); export const applyAbilitiesOnInterview: ApplyAbilities = ( - user, - { can, cannot } - ) => { - if (user.role === Role.Admin || user.role === Role.Supervisor) { - can(Action.Manage, "Interview"); - } - else{ - cannot(Action.Manage, "Interview") - } - }; - \ No newline at end of file + user, + { can, cannot } +) => { + if (user.role === Role.Admin || user.role === Role.Supervisor) { + can(Action.Manage, "Interview"); + } else { + cannot(Action.Manage, "Interview"); + } +};