diff --git a/server/src/modules/job-postings/job-posting.controller.ts b/server/src/modules/job-postings/job-posting.controller.ts new file mode 100644 index 0000000..61ad14c --- /dev/null +++ b/server/src/modules/job-postings/job-posting.controller.ts @@ -0,0 +1,208 @@ +import { ControllerMethod } from "@/utils/errorHandler"; +import { Request, Response } from "express"; +import { InputJobPosting } from "./job-posting.model"; +import { JobPostingService } from "./job-posting.service"; + +class JobPostingController { + constructor( + private jobPostingService: JobPostingService = new JobPostingService() + ) {} + + /** + * Retrieves all job postings from the database with populated company references. + * @returns An array of all job postings + * @codes 200 + */ + @ControllerMethod() + async getAllJobPostings(req: Request, res: Response): Promise { + const jobPostings = await this.jobPostingService.getAllJobPostings(); + res.status(200).json({ + status: "success", + data: jobPostings, + message: "Job postings retrieved successfully", + }); + } + + /** + * Retrieves job postings by company (professional profile) ID. + * @param {string} req.params.companyId The unique identifier of the company (professional profile) + * @returns An array of job postings for the specified company + * @codes 200, 404 + * @throws A {@link ProfessionalProfileNotFoundError} if the company with the specified id does not exist + */ + @ControllerMethod() + async getJobPostingsByCompany(req: Request, res: Response): Promise { + const { companyId } = req.params; + const jobPostings = await this.jobPostingService.getJobPostingsByCompany(companyId); + res.status(200).json({ + status: "success", + data: jobPostings, + message: `Job postings for company ${companyId} retrieved successfully`, + }); + } + + /** + * Retrieves a specific job posting by its unique identifier with populated company reference. + * @param {string} req.params.id The unique identifier of the job posting to retrieve + * @returns The job posting object if found + * @codes 200, 404 + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + @ControllerMethod() + async getJobPostingById(req: Request, res: Response): Promise { + const { id } = req.params; + const jobPosting = await this.jobPostingService.getJobPostingById(id); + res.status(200).json({ + status: "success", + data: jobPosting, + message: `Job posting with id ${jobPosting.id} retrieved successfully`, + }); + } + + /** + * Creates a new job posting with the provided data. + * @param {InputJobPosting} req.body The input job posting data used to create a new job posting + * @returns The newly created job posting object with populated company reference + * @codes 201, 404 + * @throws A {@link ProfessionalProfileNotFoundError} if the company with the specified id does not exist + */ + @ControllerMethod() + async createJobPosting(req: Request, res: Response): Promise { + const jobPostingData: InputJobPosting = req.body; + const jobPosting = await this.jobPostingService.createJobPosting(jobPostingData); + res.status(201).json({ + status: "success", + data: jobPosting, + message: `Job posting with id ${jobPosting.id} created successfully`, + }); + } + + /** + * Updates an existing job posting with the provided data. + * @param {string} req.params.id The unique identifier of the job posting to update + * @param {Partial} req.body Partial job posting data to update + * @returns The updated job posting object with populated company reference + * @codes 200, 404 + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + * @throws A {@link ProfessionalProfileNotFoundError} if a new company reference is provided and does not exist + */ + @ControllerMethod() + async updateJobPosting(req: Request, res: Response): Promise { + const { id } = req.params; + const jobPostingData = req.body; + const jobPosting = await this.jobPostingService.updateJobPosting(id, jobPostingData); + res.status(200).json({ + status: "success", + data: jobPosting, + message: `Job posting with id ${jobPosting.id} updated successfully`, + }); + } + + /** + * Deletes a job posting by its unique identifier. + * @param {string} req.params.id The unique identifier of the job posting to delete + * @returns The deleted job posting object with populated company reference + * @codes 200, 404 + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + @ControllerMethod() + async deleteJobPosting(req: Request, res: Response): Promise { + const { id } = req.params; + const jobPosting = await this.jobPostingService.deleteJobPosting(id); + res.status(200).json({ + status: "success", + data: jobPosting, + message: `Job posting with id ${jobPosting.id} deleted successfully`, + }); + } + + /** + * Increments the number of applicants for a job posting. + * @param {string} req.params.id The unique identifier of the job posting + * @returns The updated job posting object with populated company reference + * @codes 200, 404 + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + @ControllerMethod() + async incrementApplicants(req: Request, res: Response): Promise { + const { id } = req.params; + const jobPosting = await this.jobPostingService.incrementApplicants(id); + res.status(200).json({ + status: "success", + data: jobPosting, + message: `Applicant count incremented for job posting with id ${jobPosting.id}`, + }); + } + + /** + * Updates the job status of a job posting. + * @param {string} req.params.id The unique identifier of the job posting + * @param {string} req.body.status The new job status to set + * @returns The updated job posting object with populated company reference + * @codes 200, 404 + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + @ControllerMethod() + async updateJobStatus(req: Request, res: Response): Promise { + const { id } = req.params; + const { status } = req.body; + const jobPosting = await this.jobPostingService.updateJobStatus(id, status); + res.status(200).json({ + status: "success", + data: jobPosting, + message: `Job status updated for job posting with id ${jobPosting.id}`, + }); + } + + /** + * Searches job postings by skills tags. + * @param {string[]} req.query.skills Array of skills to search for (comma-separated) + * @returns An array of job postings that match any of the provided skills + * @codes 200 + */ + @ControllerMethod() + async searchJobPostingsBySkills(req: Request, res: Response): Promise { + const skillsQuery = req.query.skills as string; + const skillsTags = skillsQuery ? skillsQuery.split(',').map(skill => skill.trim()) : []; + + const jobPostings = await this.jobPostingService.searchJobPostingsBySkills(skillsTags); + res.status(200).json({ + status: "success", + data: jobPostings, + message: `Job postings matching skills retrieved successfully`, + }); + } + + /** + * Searches job postings by location. + * @param {string} req.query.location Location to search for + * @returns An array of job postings that match the location + * @codes 200 + */ + @ControllerMethod() + async searchJobPostingsByLocation(req: Request, res: Response): Promise { + const { location } = req.query; + + if (!location || typeof location !== 'string') { + res.status(400).json({ + status: "error", + errors: [{ + type: "validation", + loc: "query", + field: "location", + details: "Location query parameter is required", + }], + }); + return; + } + + const jobPostings = await this.jobPostingService.searchJobPostingsByLocation(location); + res.status(200).json({ + status: "success", + data: jobPostings, + message: `Job postings in location "${location}" retrieved successfully`, + }); + } +} + +export { JobPostingController }; diff --git a/server/src/modules/job-postings/job-posting.model.ts b/server/src/modules/job-postings/job-posting.model.ts new file mode 100644 index 0000000..9a77448 --- /dev/null +++ b/server/src/modules/job-postings/job-posting.model.ts @@ -0,0 +1,224 @@ +import { Replace } from "@/types/mongoose"; +import mongoose, { HydratedDocument, InferSchemaType, Schema } from "mongoose"; + +/** Job status enum values */ +export enum JobStatus { + ACTIVELY_HIRING = "actively_hiring", + CLOSED = "closed", + REVIEWING = "reviewing", + ON_HOLD = "on_hold", +} + +/** Work type enum values */ +export enum WorkType { + REMOTE = "remote", + IN_PERSON = "in_person", + HYBRID = "hybrid", +} + +/** Hours type enum values */ +export enum HoursType { + FULL_TIME = "full_time", + PART_TIME = "part_time", + SPECIFIC_HOURS = "specific_hours", +} + +/** Pay range schema for structured salary information */ +const payRangeSchema = new Schema({ + min: { type: Number, required: true }, + max: { type: Number, required: true }, + currency: { type: String, required: true, default: "USD" }, + period: { type: String, enum: ["hourly", "monthly", "yearly"], required: true }, +}, { _id: false }); + +/** Hours schema for flexible hour definitions */ +const hoursSchema = new Schema({ + type: { type: String, enum: Object.values(HoursType), required: true }, + specificHours: { type: Number }, // Used when type is SPECIFIC_HOURS +}, { _id: false }); + +/** Mongoose schema definition for job posting */ +const jobPostingSchema = new Schema({ + // Basic Information + jobTitle: { type: String, required: true }, + companyRef: { type: Schema.Types.ObjectId, ref: "ProfessionalProfile", required: true }, + + // Data section + postDate: { type: Date, required: true, default: () => Date.now() }, + location: { type: String, required: true }, + numApplicants: { type: Number, required: true, default: 0 }, + numAccepted: { type: Number, required: true }, + jobStatus: { + type: String, + enum: Object.values(JobStatus), + required: true, + default: JobStatus.ACTIVELY_HIRING + }, + workType: { type: String, enum: Object.values(WorkType), required: true }, + payRange: { type: payRangeSchema, required: true }, + hours: { type: hoursSchema, required: true }, + deadline: { type: Date, required: true }, + skillsTags: [{ type: String }], + externalApplicationLink: { type: String }, + + // Content sections + about: { type: String, required: true }, + qualifications: { type: String, required: true }, + responsibilities: { type: String, required: true }, + + // Metadata + entryDate: { type: Date, default: () => Date.now() }, +}); + +type JobPostingSchema = InferSchemaType; + +/** + * An input object used when creating a job posting. + * The companyRef should be a string ObjectId. + */ +type InputJobPosting = Replace< + Omit, + { + companyRef: string; + } +>; + +/** + * The job posting document returned by a mongoose query without population. + */ +type UnpopulatedJobPostingDoc = HydratedDocument; + +/** + * The job posting document with populated company reference. + */ +type JobPostingDoc = UnpopulatedJobPostingDoc & { + companyRef: { + _id: mongoose.Types.ObjectId; + name: string; + tag: string; + location?: string; + }; +}; + +/** Pay range interface for type safety */ +interface PayRange { + min: number; + max: number; + currency: string; + period: "hourly" | "monthly" | "yearly"; +} + +/** Hours interface for type safety */ +interface Hours { + type: HoursType; + specificHours?: number; +} + +/** The base level job posting object to be returned by the API. */ +class JobPosting { + constructor( + public id: string, + public jobTitle: string, + public companyRef: string | { id: string; name: string; tag: string; location?: string }, + public postDate: Date, + public location: string, + public numApplicants: number, + public numAccepted: number, + public jobStatus: JobStatus, + public workType: WorkType, + public payRange: PayRange, + public hours: Hours, + public deadline: Date, + public skillsTags: string[], + public about: string, + public qualifications: string, + public responsibilities: string, + public entryDate: Date, + public externalApplicationLink?: string | null + ) {} + + /** + * Converts a {@link JobPostingDoc} to a {@link JobPosting} object. + */ + static fromDoc(doc: JobPostingDoc): JobPosting { + // Handle populated company reference + const companyRef = doc.companyRef._id + ? { + id: doc.companyRef._id.toString(), + name: doc.companyRef.name, + tag: doc.companyRef.tag, + location: doc.companyRef.location, + } + : doc.companyRef.toString(); + + return new JobPosting( + doc._id.toString(), + doc.jobTitle, + companyRef, + doc.postDate, + doc.location, + doc.numApplicants, + doc.numAccepted, + doc.jobStatus as JobStatus, + doc.workType as WorkType, + doc.payRange, + { + type: doc.hours.type as HoursType, + specificHours: doc.hours.specificHours || undefined, + }, + doc.deadline, + doc.skillsTags, + doc.about, + doc.qualifications, + doc.responsibilities, + doc.entryDate, + doc.externalApplicationLink + ); + } + + /** + * Converts an {@link UnpopulatedJobPostingDoc} to a {@link JobPosting} object. + */ + static fromUnpopulatedDoc(doc: UnpopulatedJobPostingDoc): JobPosting { + return new JobPosting( + doc._id.toString(), + doc.jobTitle, + doc.companyRef.toString(), + doc.postDate, + doc.location, + doc.numApplicants, + doc.numAccepted, + doc.jobStatus as JobStatus, + doc.workType as WorkType, + doc.payRange, + { + type: doc.hours.type as HoursType, + specificHours: doc.hours.specificHours || undefined, + }, + doc.deadline, + doc.skillsTags, + doc.about, + doc.qualifications, + doc.responsibilities, + doc.entryDate, + doc.externalApplicationLink + ); + } +} + +const JobPostingModel = mongoose.model( + "JobPosting", + jobPostingSchema, + "jobPostings" +); + +export { + Hours, + InputJobPosting, + JobPosting, + JobPostingDoc, + JobPostingModel, + JobPostingSchema, + PayRange, + UnpopulatedJobPostingDoc, +}; diff --git a/server/src/modules/job-postings/job-posting.router.ts b/server/src/modules/job-postings/job-posting.router.ts new file mode 100644 index 0000000..ebfe6c4 --- /dev/null +++ b/server/src/modules/job-postings/job-posting.router.ts @@ -0,0 +1,92 @@ +import { authenticate } from "@/utils/authentication"; +import { + validateBody, + validateId, + validatePartialBody, + validation, +} from "@/utils/validationMiddleware"; +import Router from "express"; +import { body } from "express-validator"; +import { JobPostingController } from "./job-posting.controller"; +import { JobPostingValidator } from "./utils/job-posting.validator"; + +const jobPostingRouter = Router(); +const jobPostingController = new JobPostingController(); + +// Apply authentication to all routes +jobPostingRouter.use(authenticate); + +// GET /api/job-postings - Get all job postings +jobPostingRouter.get( + "/", + jobPostingController.getAllJobPostings +); + +// GET /api/job-postings/company/:companyId - Get job postings by company +jobPostingRouter.get( + "/company/:companyId", + validation(validateId("companyId")), + jobPostingController.getJobPostingsByCompany +); + +// GET /api/job-postings/search/skills - Search job postings by skills +jobPostingRouter.get( + "/search/skills", + jobPostingController.searchJobPostingsBySkills +); + +// GET /api/job-postings/search/location - Search job postings by location +jobPostingRouter.get( + "/search/location", + jobPostingController.searchJobPostingsByLocation +); + +// GET /api/job-postings/:id - Get specific job posting +jobPostingRouter.get( + "/:id", + validation(validateId()), + jobPostingController.getJobPostingById +); + +// POST /api/job-postings - Create new job posting +jobPostingRouter.post( + "/", + validation(validateBody(JobPostingValidator)), + jobPostingController.createJobPosting +); + +// PATCH /api/job-postings/:id - Update job posting +jobPostingRouter.patch( + "/:id", + validation(validateId(), validatePartialBody(JobPostingValidator)), + jobPostingController.updateJobPosting +); + +// DELETE /api/job-postings/:id - Delete job posting +jobPostingRouter.delete( + "/:id", + validation(validateId()), + jobPostingController.deleteJobPosting +); + +// POST /api/job-postings/:id/apply - Increment applicant count +jobPostingRouter.post( + "/:id/apply", + validation(validateId()), + jobPostingController.incrementApplicants +); + +// PATCH /api/job-postings/:id/status - Update job status +jobPostingRouter.patch( + "/:id/status", + validation( + validateId(), + body("status") + .isString() + .notEmpty() + .withMessage("Status is required") + ), + jobPostingController.updateJobStatus +); + +export { jobPostingRouter }; diff --git a/server/src/modules/job-postings/job-posting.service.ts b/server/src/modules/job-postings/job-posting.service.ts new file mode 100644 index 0000000..de6ddae --- /dev/null +++ b/server/src/modules/job-postings/job-posting.service.ts @@ -0,0 +1,236 @@ +import { ProfessionalProfileService } from "@/modules/professional-profiles/professional-profile.service"; +import { ProfessionalProfileNotFoundError } from "@/modules/professional-profiles/utils/professional-profile.errors"; +import { Model } from "mongoose"; +import { + InputJobPosting, + JobPosting, + JobPostingDoc, + JobPostingModel, + JobPostingSchema, + UnpopulatedJobPostingDoc, +} from "./job-posting.model"; +import { JobPostingNotFoundError } from "./utils/job-posting.errors"; + +/** + * Handles job posting-related business logic such as CRUD operations. + */ +class JobPostingService { + constructor( + private jobPostingModel: Model = JobPostingModel, + private professionalProfileService: ProfessionalProfileService = new ProfessionalProfileService() + ) {} + + /** + * Validates that the company (professional profile) reference exists + */ + private async validateCompanyExists(companyRef: string): Promise { + try { + await this.professionalProfileService.getProfessionalProfileById(companyRef); + } catch (error) { + if (error instanceof ProfessionalProfileNotFoundError) { + throw new ProfessionalProfileNotFoundError( + `Company with id ${companyRef} not found` + ); + } + throw error; + } + } + + /** + * Retrieves all job postings from the database with populated company references. + * @returns An array of all job postings + * @throws No specific errors, but may throw database-related errors + */ + async getAllJobPostings(): Promise { + const docs = await this.jobPostingModel + .find() + .populate("companyRef", "name tag location") + .exec(); + return docs.map((doc) => JobPosting.fromDoc(doc)); + } + + /** + * Retrieves job postings by company (professional profile) ID with populated company references. + * @param companyId The unique identifier of the company (professional profile) + * @returns An array of job postings for the specified company + * @throws A {@link ProfessionalProfileNotFoundError} if the company with the specified id does not exist + */ + async getJobPostingsByCompany(companyId: string): Promise { + await this.validateCompanyExists(companyId); + + const docs = await this.jobPostingModel + .find({ companyRef: companyId }) + .populate("companyRef", "name tag location") + .exec(); + return docs.map((doc) => JobPosting.fromDoc(doc)); + } + + /** + * Retrieves a job posting by its unique identifier with populated company reference. + * @param jobPostingId The unique identifier of the job posting + * @returns The job posting object if found + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + async getJobPostingById(jobPostingId: string): Promise { + const doc = await this.jobPostingModel + .findById(jobPostingId) + .populate("companyRef", "name tag location") + .exec(); + if (!doc) { + throw new JobPostingNotFoundError( + `Job posting with id ${jobPostingId} not found` + ); + } + return JobPosting.fromDoc(doc); + } + + /** + * Creates a new job posting with the provided data. + * @param jobPostingData Job posting data used to create a new job posting + * @returns The newly created job posting object with populated company reference + * @throws A {@link ProfessionalProfileNotFoundError} if the company with the specified id does not exist + */ + async createJobPosting( + jobPostingData: InputJobPosting + ): Promise { + await this.validateCompanyExists(jobPostingData.companyRef); + + const jobPosting = new this.jobPostingModel(jobPostingData); + await jobPosting.save(); + await jobPosting.populate("companyRef", "name tag location"); + return JobPosting.fromDoc(jobPosting as JobPostingDoc); + } + + /** + * Updates an existing job posting with the provided data. + * @param jobPostingId The unique identifier of the job posting to update + * @param jobPostingData Partial job posting data to update + * @returns The updated job posting object with populated company reference + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + * @throws A {@link ProfessionalProfileNotFoundError} if a new company reference is provided and does not exist + */ + async updateJobPosting( + jobPostingId: string, + jobPostingData: Partial + ): Promise { + if (jobPostingData.companyRef) { + await this.validateCompanyExists(jobPostingData.companyRef); + } + + const doc = await this.jobPostingModel + .findByIdAndUpdate(jobPostingId, jobPostingData, { + new: true, + }) + .populate("companyRef", "name tag location") + .exec(); + if (!doc) { + throw new JobPostingNotFoundError( + `Job posting with id ${jobPostingId} not found` + ); + } + return JobPosting.fromDoc(doc); + } + + /** + * Deletes a job posting by its unique identifier. + * @param jobPostingId The unique identifier of the job posting to delete + * @returns The deleted job posting object with populated company reference + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + async deleteJobPosting(jobPostingId: string): Promise { + const doc = await this.jobPostingModel + .findByIdAndDelete(jobPostingId) + .populate("companyRef", "name tag location") + .exec(); + if (!doc) { + throw new JobPostingNotFoundError( + `Job posting with id ${jobPostingId} not found` + ); + } + return JobPosting.fromDoc(doc); + } + + /** + * Increments the number of applicants for a job posting. + * @param jobPostingId The unique identifier of the job posting + * @returns The updated job posting object with populated company reference + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + async incrementApplicants(jobPostingId: string): Promise { + const doc = await this.jobPostingModel + .findByIdAndUpdate( + jobPostingId, + { $inc: { numApplicants: 1 } }, + { new: true } + ) + .populate("companyRef", "name tag location") + .exec(); + + if (!doc) { + throw new JobPostingNotFoundError( + `Job posting with id ${jobPostingId} not found` + ); + } + return JobPosting.fromDoc(doc); + } + + /** + * Updates the job status of a job posting. + * @param jobPostingId The unique identifier of the job posting + * @param status The new job status to set + * @returns The updated job posting object with populated company reference + * @throws A {@link JobPostingNotFoundError} if the job posting with the specified id is not found + */ + async updateJobStatus( + jobPostingId: string, + status: string + ): Promise { + const doc = await this.jobPostingModel + .findByIdAndUpdate( + jobPostingId, + { jobStatus: status }, + { new: true } + ) + .populate("companyRef", "name tag location") + .exec(); + + if (!doc) { + throw new JobPostingNotFoundError( + `Job posting with id ${jobPostingId} not found` + ); + } + return JobPosting.fromDoc(doc); + } + + /** + * Searches job postings by skills tags. + * @param skillsTags Array of skills to search for + * @returns An array of job postings that match any of the provided skills + */ + async searchJobPostingsBySkills(skillsTags: string[]): Promise { + const docs = await this.jobPostingModel + .find({ + skillsTags: { $in: skillsTags }, + }) + .populate("companyRef", "name tag location") + .exec(); + return docs.map((doc) => JobPosting.fromDoc(doc)); + } + + /** + * Searches job postings by location. + * @param location Location to search for (case-insensitive partial match) + * @returns An array of job postings that match the location + */ + async searchJobPostingsByLocation(location: string): Promise { + const docs = await this.jobPostingModel + .find({ + location: { $regex: location, $options: "i" }, + }) + .populate("companyRef", "name tag location") + .exec(); + return docs.map((doc) => JobPosting.fromDoc(doc)); + } +} + +export { JobPostingService }; diff --git a/server/src/modules/job-postings/utils/job-posting.errors.ts b/server/src/modules/job-postings/utils/job-posting.errors.ts new file mode 100644 index 0000000..9d5eca1 --- /dev/null +++ b/server/src/modules/job-postings/utils/job-posting.errors.ts @@ -0,0 +1,13 @@ +import { NotFoundError } from "@/types/errors"; + +enum JobPostingCode { + JobPostingNotFound = "JOB_POSTING_NOT_FOUND", +} + +class JobPostingNotFoundError extends NotFoundError { + constructor(message: string = "Job posting not found") { + super(message, JobPostingCode.JobPostingNotFound); + } +} + +export { JobPostingCode, JobPostingNotFoundError }; diff --git a/server/src/modules/job-postings/utils/job-posting.validator.ts b/server/src/modules/job-postings/utils/job-posting.validator.ts new file mode 100644 index 0000000..710b4e6 --- /dev/null +++ b/server/src/modules/job-postings/utils/job-posting.validator.ts @@ -0,0 +1,122 @@ +import { Transform, Type } from "class-transformer"; +import { + IsArray, + IsDateString, + IsEnum, + IsISO8601, + IsMongoId, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUrl, + ValidateNested, + Min, +} from "class-validator"; +import { JobStatus, WorkType, HoursType } from "../job-posting.model"; + +class PayRangeValidator { + @IsNumber() + @IsPositive() + min!: number; + + @IsNumber() + @IsPositive() + max!: number; + + @IsString() + @IsNotEmpty() + currency!: string; + + @IsEnum(["hourly", "monthly", "yearly"]) + period!: "hourly" | "monthly" | "yearly"; +} + +class HoursValidator { + @IsEnum(HoursType) + type!: HoursType; + + @IsOptional() + @IsNumber() + @IsPositive() + specificHours?: number; +} + +class JobPostingValidator { + @IsString() + @IsNotEmpty() + jobTitle!: string; + + @IsMongoId() + companyRef!: string; + + @IsISO8601() + @IsOptional() + @Transform(({ value }) => + value instanceof Date ? value.toISOString() : value + ) + postDate?: string; + + @IsString() + @IsNotEmpty() + location!: string; + + @IsNumber() + @Min(0) + @IsOptional() + numApplicants?: number; + + @IsNumber() + @IsPositive() + numAccepted!: number; + + @IsEnum(JobStatus) + @IsOptional() + jobStatus?: JobStatus; + + @IsEnum(WorkType) + workType!: WorkType; + + @ValidateNested() + @Type(() => PayRangeValidator) + payRange!: PayRangeValidator; + + @ValidateNested() + @Type(() => HoursValidator) + hours!: HoursValidator; + + @IsDateString() + deadline!: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + skillsTags?: string[]; + + @IsOptional() + @IsString() + @IsUrl() + externalApplicationLink?: string; + + @IsString() + @IsNotEmpty() + about!: string; + + @IsString() + @IsNotEmpty() + qualifications!: string; + + @IsString() + @IsNotEmpty() + responsibilities!: string; + + @IsISO8601() + @IsOptional() + @Transform(({ value }) => + value instanceof Date ? value.toISOString() : value + ) + entryDate?: string; +} + +export { JobPostingValidator, PayRangeValidator, HoursValidator }; diff --git a/server/src/modules/professional-profiles/professional-profile.router.ts b/server/src/modules/professional-profiles/professional-profile.router.ts index 87fa995..e649c71 100644 --- a/server/src/modules/professional-profiles/professional-profile.router.ts +++ b/server/src/modules/professional-profiles/professional-profile.router.ts @@ -5,12 +5,16 @@ import { validatePartialBody, validation, } from "@/utils/validationMiddleware"; +import { Request, Response, NextFunction } from "express"; import Router from "express"; import { ProfessionalProfileController } from "./professional-profile.controller"; import { ProfessionalProfileValidator } from "./utils/professional-profile.validator"; +import { JobPostingController } from "@/modules/job-postings/job-posting.controller"; +import { JobPostingValidator } from "@/modules/job-postings/utils/job-posting.validator"; const professionalProfileRouter = Router(); const professionalProfileController = new ProfessionalProfileController(); +const jobPostingController = new JobPostingController(); professionalProfileRouter.use(authenticate); @@ -43,4 +47,33 @@ professionalProfileRouter.delete( professionalProfileController.deleteProfessionalProfile ); +// Job posting routes attached to professional profiles +// GET /api/professional-profiles/:id/job-postings - Get job postings for a specific company +professionalProfileRouter.get( + "/:id/job-postings", + validation(validateId()), + (req: Request, res: Response, next: NextFunction) => { + // Transform the route parameter to match the controller expectation + req.params.companyId = req.params.id; + // The decorator wraps the method to accept 3 parameters, so we cast it + (jobPostingController.getJobPostingsByCompany as any)(req, res, next); + } +); + +// POST /api/professional-profiles/:id/job-postings - Create job posting for a specific company +professionalProfileRouter.post( + "/:id/job-postings", + validation(validateId()), + // Custom middleware to set companyRef before validation + (req: Request, res: Response, next: NextFunction) => { + req.body.companyRef = req.params.id; + next(); + }, + validation(validateBody(JobPostingValidator)), + (req: Request, res: Response, next: NextFunction) => { + // The decorator wraps the method to accept 3 parameters, so we cast it + (jobPostingController.createJobPosting as any)(req, res, next); + } +); + export { professionalProfileRouter }; diff --git a/server/src/server.ts b/server/src/server.ts index 3fc5240..a2e6ea1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -11,6 +11,7 @@ import { errorHandler } from "@/utils/errorHandler"; import { authRouter } from "@/modules/auth/auth.router"; import { professionalProfileRouter } from "@/modules/professional-profiles/professional-profile.router"; import { studentProfileRouter } from "@/modules/student-profiles/student-profile.router"; +import { jobPostingRouter } from "@/modules/job-postings/job-posting.router"; import { loggingMiddleware } from "./utils/logging"; // Express configuration @@ -57,6 +58,7 @@ app.use("/api/accounts", authRouter); app.use("/api/files", fileRouter); app.use("/api/student-profiles", studentProfileRouter); app.use("/api/professional-profiles", professionalProfileRouter); +app.use("/api/job-postings", jobPostingRouter); // Error handler must come last app.use(errorHandler); diff --git a/server/src/types/errorCodes.ts b/server/src/types/errorCodes.ts index 211cfe9..66a2e5e 100644 --- a/server/src/types/errorCodes.ts +++ b/server/src/types/errorCodes.ts @@ -1,5 +1,6 @@ import { AccountCode } from "@/modules/auth/utils/auth.errors"; import { FileCode } from "@/modules/files/utils/file.errors"; +import { JobPostingCode } from "@/modules/job-postings/utils/job-posting.errors"; import { ProfessionalProfileCode } from "@/modules/professional-profiles/utils/professional-profile.errors"; import { StudentProfileCode } from "@/modules/student-profiles/utils/student-profile.errors"; import { GeneralCode } from "@/types/errors"; @@ -10,6 +11,7 @@ const allErrorCodes = [ ...Object.values(AccountCode), ...Object.values(StudentProfileCode), ...Object.values(ProfessionalProfileCode), + ...Object.values(JobPostingCode), ]; type ErrorCode = (typeof allErrorCodes)[number]; diff --git a/server/src/utils/errorHandler.ts b/server/src/utils/errorHandler.ts index 2270de3..cbcd7d3 100644 --- a/server/src/utils/errorHandler.ts +++ b/server/src/utils/errorHandler.ts @@ -81,7 +81,7 @@ function ControllerMethod() { next: NextFunction ) { try { - return await originalMethod.call(this, req, res, next); + return await originalMethod.call(this, req, res); } catch (error) { next(error); } diff --git a/server/test-job-postings.sh b/server/test-job-postings.sh new file mode 100755 index 0000000..bc3f2eb --- /dev/null +++ b/server/test-job-postings.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🚀 Running Job Postings Module Tests${NC}" +echo "==================================" + +# Change to server directory +cd "$(dirname "$0")" + +echo -e "\n${YELLOW}📋 Running Job Posting Integration Tests...${NC}" +npm test -- --testPathPattern="job-postings.*integration" --verbose + +echo -e "\n${YELLOW}🔧 Running Job Posting Unit Tests...${NC}" +npm test -- --testPathPattern="job-postings.*unit" --verbose + +echo -e "\n${YELLOW}🏢 Running Professional Profile Job Posting Tests...${NC}" +npm test -- --testPathPattern="professional-profile-job-posting" --verbose + +echo -e "\n${GREEN}✅ All Job Posting Tests Completed!${NC}" diff --git a/server/tests/modules/job-postings/integration/job-posting.router.test.ts b/server/tests/modules/job-postings/integration/job-posting.router.test.ts new file mode 100644 index 0000000..e677ec6 --- /dev/null +++ b/server/tests/modules/job-postings/integration/job-posting.router.test.ts @@ -0,0 +1,452 @@ +import { + createTestJobPosting, + createTestJobPostingsForCompany, + getJobPostingData, +} from "#/modules/job-postings/utils/job-posting.helpers"; +import { TestJobPostingValidator } from "#/modules/job-postings/utils/job-posting.validators"; +import { createTestProfessionalProfile } from "#/modules/professional-profiles/utils/professional-profile.helpers"; +import { + expectHttpErrorResponse, + expectSuccessResponse, +} from "#/utils/helpers"; +import { + expectEndpointToRequireAuth, + getAuthenticatedAgent, + HTTPMethod, +} from "#/utils/mockAuthentication"; +import { expectValidationErrors } from "#/utils/validation"; +import { + JobPosting, + JobPostingModel, + JobStatus, + WorkType, + HoursType, +} from "@/modules/job-postings/job-posting.model"; +import { JobPostingCode } from "@/modules/job-postings/utils/job-posting.errors"; +import { ProfessionalProfileCode } from "@/modules/professional-profiles/utils/professional-profile.errors"; +import { Types } from "mongoose"; +import TestAgent from "supertest/lib/agent"; + +describe("Job Posting Router", () => { + let agent: TestAgent; + + beforeAll(() => { + agent = getAuthenticatedAgent(); + }); + + describe("endpoint authentication", () => { + test.each<[HTTPMethod, string]>([ + ["get", "/api/job-postings"], + ["get", "/api/job-postings/:id"], + ["post", "/api/job-postings"], + ["patch", "/api/job-postings/:id"], + ["delete", "/api/job-postings/:id"], + ["get", "/api/job-postings/company/:companyId"], + ["post", "/api/job-postings/:id/apply"], + ["patch", "/api/job-postings/:id/status"], + ["get", "/api/job-postings/search/skills"], + ["get", "/api/job-postings/search/location"], + ])("`%s %s` should require authentication", async (method, endpoint) => { + await expectEndpointToRequireAuth(method, endpoint); + }); + }); + + describe("GET /", () => { + it("should return an empty list when there are no job postings", async () => { + const response = await agent.get("/api/job-postings"); + + expectSuccessResponse(response); + expect(response.body.data).toEqual([]); + }); + + it("should return all job postings with populated company references", async () => { + const jobPosting = await createTestJobPosting(); + + const response = await agent.get("/api/job-postings"); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe(jobPosting.id); + expect(response.body.data[0].companyRef).toHaveProperty("id"); + expect(response.body.data[0].companyRef).toHaveProperty("name"); + }); + }); + + describe("GET /company/:companyId", () => { + it("should return job postings for a specific company", async () => { + const company = await createTestProfessionalProfile(); + const jobPostings = await createTestJobPostingsForCompany(company.id, 2); + + // Create a job posting for a different company + await createTestJobPosting(); + + const response = await agent.get(`/api/job-postings/company/${company.id}`); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.every((jp: any) => jp.companyRef.id === company.id)).toBe(true); + }); + + it("should return empty array for company with no job postings", async () => { + const company = await createTestProfessionalProfile(); + + const response = await agent.get(`/api/job-postings/company/${company.id}`); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toEqual([]); + }); + + it("should return an error for non-existent company", async () => { + const badId = new Types.ObjectId(); + + const response = await agent.get(`/api/job-postings/company/${badId}`); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: ProfessionalProfileCode.ProfessionalProfileNotFound, + }, + ], + }); + }); + }); + + describe("GET /:id", () => { + it("should return a job posting by id with populated company reference", async () => { + const jobPosting = await createTestJobPosting(); + + const response = await agent.get(`/api/job-postings/${jobPosting.id}`); + + expectSuccessResponse(response, TestJobPostingValidator); + expect(response.body.data.id).toBe(jobPosting.id); + expect(response.body.data.companyRef).toHaveProperty("id"); + }); + + it("should return an error for job posting not found", async () => { + const badId = new Types.ObjectId(); + + const response = await agent.get(`/api/job-postings/${badId}`); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: JobPostingCode.JobPostingNotFound, + }, + ], + }); + }); + + it("should return validation error for invalid id format", async () => { + const response = await agent.get("/api/job-postings/invalid-id"); + + expectValidationErrors(response, ["id"], "params"); + }); + }); + + describe("POST /", () => { + it("should create a new job posting", async () => { + const jobPostingData = await getJobPostingData(); + + const response = await agent + .post("/api/job-postings") + .send(jobPostingData); + + expect(response.body.data).toBeDefined(); + const jobPosting = await JobPostingModel.findById(response.body.data.id); + expect(jobPosting).toBeDefined(); + expectSuccessResponse( + response, + TestJobPostingValidator, + undefined, + { status: 201 } + ); + expect(response.body.data.jobTitle).toBe(jobPostingData.jobTitle); + expect(response.body.data.numApplicants).toBe(0); // Should default to 0 + }); + + it("should return validation errors for missing required fields", async () => { + const invalidData = { + jobTitle: "Test Job", + // Missing required fields: companyRef, numAccepted, workType, etc. + }; + + const response = await agent + .post("/api/job-postings") + .send(invalidData); + + expectValidationErrors(response, [ + "companyRef", + "location", + "numAccepted", + "workType", + "deadline", + "about", + "qualifications", + "responsibilities", + ]); + }); + + it("should return error for non-existent company reference", async () => { + const jobPostingData = await getJobPostingData(); + jobPostingData.companyRef = new Types.ObjectId().toString(); + + const response = await agent + .post("/api/job-postings") + .send(jobPostingData); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(jobPostingData.companyRef), + code: ProfessionalProfileCode.ProfessionalProfileNotFound, + }, + ], + }); + }); + + it("should create job posting with all optional fields", async () => { + const jobPostingData = await getJobPostingData(); + jobPostingData.hours = { + type: HoursType.SPECIFIC_HOURS, + specificHours: 20, + }; + + const response = await agent + .post("/api/job-postings") + .send(jobPostingData); + + expectSuccessResponse(response, TestJobPostingValidator, undefined, { status: 201 }); + expect(response.body.data.hours.type).toBe(HoursType.SPECIFIC_HOURS); + expect(response.body.data.hours.specificHours).toBe(20); + }); + }); + + describe("PATCH /:id", () => { + it("should update an existing job posting", async () => { + const jobPosting = await createTestJobPosting(); + const updateData = { + jobTitle: "Updated Job Title", + jobStatus: JobStatus.REVIEWING, + skillsTags: ["React", "Node.js", "MongoDB"], + }; + + const response = await agent + .patch(`/api/job-postings/${jobPosting.id}`) + .send(updateData); + + expectSuccessResponse(response, TestJobPostingValidator); + expect(response.body.data.jobTitle).toBe(updateData.jobTitle); + expect(response.body.data.jobStatus).toBe(updateData.jobStatus); + expect(response.body.data.skillsTags).toEqual(updateData.skillsTags); + + const updatedJobPosting = await JobPostingModel.findById(jobPosting.id); + expect(updatedJobPosting!.jobTitle).toBe(updateData.jobTitle); + }); + + it("should return an error for job posting not found", async () => { + const badId = new Types.ObjectId(); + const updateData = { jobTitle: "Updated Title" }; + + const response = await agent + .patch(`/api/job-postings/${badId}`) + .send(updateData); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: JobPostingCode.JobPostingNotFound, + }, + ], + }); + }); + }); + + describe("DELETE /:id", () => { + it("should delete an existing job posting", async () => { + const jobPosting1 = await createTestJobPosting(); + const jobPosting2 = await createTestJobPosting(); + + const response = await agent.delete(`/api/job-postings/${jobPosting1.id}`); + + expectSuccessResponse(response, TestJobPostingValidator); + const jobPostings = await JobPostingModel.find(); + expect(jobPostings.length).toBe(1); + expect(jobPostings[0]!.id).toEqual(jobPosting2.id); + }); + + it("should return an error for job posting not found", async () => { + const badId = new Types.ObjectId(); + + const response = await agent.delete(`/api/job-postings/${badId}`); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: JobPostingCode.JobPostingNotFound, + }, + ], + }); + }); + }); + + describe("POST /:id/apply", () => { + it("should increment the applicant count", async () => { + const jobPosting = await createTestJobPosting(); + const initialCount = jobPosting.numApplicants; + + const response = await agent.post(`/api/job-postings/${jobPosting.id}/apply`); + + expectSuccessResponse(response, TestJobPostingValidator); + expect(response.body.data.numApplicants).toBe(initialCount + 1); + + const updatedJobPosting = await JobPostingModel.findById(jobPosting.id); + expect(updatedJobPosting!.numApplicants).toBe(initialCount + 1); + }); + + it("should return an error for non-existent job posting", async () => { + const badId = new Types.ObjectId(); + + const response = await agent.post(`/api/job-postings/${badId}/apply`); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: JobPostingCode.JobPostingNotFound, + }, + ], + }); + }); + }); + + describe("PATCH /:id/status", () => { + it("should update the job status", async () => { + const jobPosting = await createTestJobPosting(); + const newStatus = JobStatus.CLOSED; + + const response = await agent + .patch(`/api/job-postings/${jobPosting.id}/status`) + .send({ status: newStatus }); + + expectSuccessResponse(response, TestJobPostingValidator); + expect(response.body.data.jobStatus).toBe(newStatus); + + const updatedJobPosting = await JobPostingModel.findById(jobPosting.id); + expect(updatedJobPosting!.jobStatus).toBe(newStatus); + }); + + it("should return validation error for missing status", async () => { + const jobPosting = await createTestJobPosting(); + + const response = await agent + .patch(`/api/job-postings/${jobPosting.id}/status`) + .send({}); + + expectValidationErrors(response, ["status"]); + }); + }); + + describe("GET /search/skills", () => { + it("should return job postings matching skills", async () => { + const jobPosting1 = await createTestJobPosting({ + skillsTags: ["JavaScript", "React", "Node.js"], + }); + const jobPosting2 = await createTestJobPosting({ + skillsTags: ["Python", "Django", "PostgreSQL"], + }); + const jobPosting3 = await createTestJobPosting({ + skillsTags: ["JavaScript", "Vue.js", "MongoDB"], + }); + + const response = await agent.get( + "/api/job-postings/search/skills?skills=JavaScript,Python" + ); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(3); // All should match + + const ids = response.body.data.map((jp: any) => jp.id); + expect(ids).toContain(jobPosting1.id); + expect(ids).toContain(jobPosting2.id); + expect(ids).toContain(jobPosting3.id); + }); + + it("should return empty array when no skills match", async () => { + await createTestJobPosting({ + skillsTags: ["Python", "Django"], + }); + + const response = await agent.get( + "/api/job-postings/search/skills?skills=Java,C++" + ); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toEqual([]); + }); + + it("should handle empty skills query", async () => { + await createTestJobPosting(); + + const response = await agent.get("/api/job-postings/search/skills"); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toEqual([]); + }); + }); + + describe("GET /search/location", () => { + it("should return job postings matching location", async () => { + const jobPosting1 = await createTestJobPosting({ + location: "San Francisco, CA", + }); + const jobPosting2 = await createTestJobPosting({ + location: "New York, NY", + }); + const jobPosting3 = await createTestJobPosting({ + location: "San Jose, CA", + }); + + const response = await agent.get( + "/api/job-postings/search/location?location=San" + ); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(2); + + const ids = response.body.data.map((jp: any) => jp.id); + expect(ids).toContain(jobPosting1.id); + expect(ids).toContain(jobPosting3.id); + }); + + it("should return error for missing location parameter", async () => { + const response = await agent.get("/api/job-postings/search/location"); + + expect(response.status).toBe(400); + expect(response.body.status).toBe("error"); + expect(response.body.errors[0].details).toContain("Location query parameter is required"); + }); + + it("should perform case-insensitive search", async () => { + await createTestJobPosting({ + location: "Boston, MA", + }); + + const response = await agent.get( + "/api/job-postings/search/location?location=BOSTON" + ); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(1); + }); + }); +}); diff --git a/server/tests/modules/job-postings/integration/professional-profile-job-posting.router.test.ts b/server/tests/modules/job-postings/integration/professional-profile-job-posting.router.test.ts new file mode 100644 index 0000000..59fff02 --- /dev/null +++ b/server/tests/modules/job-postings/integration/professional-profile-job-posting.router.test.ts @@ -0,0 +1,200 @@ +import { + createTestJobPosting, + getJobPostingData, +} from "#/modules/job-postings/utils/job-posting.helpers"; +import { TestJobPostingValidator } from "#/modules/job-postings/utils/job-posting.validators"; +import { createTestProfessionalProfile } from "#/modules/professional-profiles/utils/professional-profile.helpers"; +import { + expectHttpErrorResponse, + expectSuccessResponse, +} from "#/utils/helpers"; +import { + expectEndpointToRequireAuth, + getAuthenticatedAgent, + HTTPMethod, +} from "#/utils/mockAuthentication"; +import { expectValidationErrors } from "#/utils/validation"; +import { JobPostingModel } from "@/modules/job-postings/job-posting.model"; +import { ProfessionalProfileCode } from "@/modules/professional-profiles/utils/professional-profile.errors"; +import { Types } from "mongoose"; +import TestAgent from "supertest/lib/agent"; + +describe("Professional Profile Job Posting Routes", () => { + let agent: TestAgent; + + beforeAll(() => { + agent = getAuthenticatedAgent(); + }); + + describe("endpoint authentication", () => { + test.each<[HTTPMethod, string]>([ + ["get", "/api/professional-profiles/:id/job-postings"], + ["post", "/api/professional-profiles/:id/job-postings"], + ])("`%s %s` should require authentication", async (method, endpoint) => { + await expectEndpointToRequireAuth(method, endpoint); + }); + }); + + describe("GET /:id/job-postings", () => { + it("should return job postings for a specific professional profile", async () => { + const company = await createTestProfessionalProfile(); + + // Create job postings for this company + const jobPosting1 = await createTestJobPosting({ companyRef: company.id }); + const jobPosting2 = await createTestJobPosting({ companyRef: company.id }); + + // Create job posting for a different company + await createTestJobPosting(); + + const response = await agent.get(`/api/professional-profiles/${company.id}/job-postings`); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toHaveLength(2); + + const ids = response.body.data.map((jp: any) => jp.id); + expect(ids).toContain(jobPosting1.id); + expect(ids).toContain(jobPosting2.id); + + // Verify all job postings belong to the correct company + expect(response.body.data.every((jp: any) => jp.companyRef.id === company.id)).toBe(true); + }); + + it("should return empty array for professional profile with no job postings", async () => { + const company = await createTestProfessionalProfile(); + + const response = await agent.get(`/api/professional-profiles/${company.id}/job-postings`); + + expectSuccessResponse(response, [TestJobPostingValidator]); + expect(response.body.data).toEqual([]); + }); + + it("should return an error for non-existent professional profile", async () => { + const badId = new Types.ObjectId(); + + const response = await agent.get(`/api/professional-profiles/${badId}/job-postings`); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: ProfessionalProfileCode.ProfessionalProfileNotFound, + }, + ], + }); + }); + + it("should return validation error for invalid professional profile id", async () => { + const response = await agent.get("/api/professional-profiles/invalid-id/job-postings"); + + expectValidationErrors(response, ["id"], "params"); + }); + }); + + describe("POST /:id/job-postings", () => { + it("should create a job posting for a specific professional profile", async () => { + const company = await createTestProfessionalProfile(); + const jobPostingData = await getJobPostingData(); + + // Remove companyRef since it should be set automatically from the route + delete (jobPostingData as any).companyRef; + + const response = await agent + .post(`/api/professional-profiles/${company.id}/job-postings`) + .send(jobPostingData); + + expectSuccessResponse(response, TestJobPostingValidator, undefined, { status: 201 }); + expect(response.body.data.companyRef.id).toBe(company.id); + expect(response.body.data.jobTitle).toBe(jobPostingData.jobTitle); + + // Verify the job posting was created in the database + const jobPosting = await JobPostingModel.findById(response.body.data.id); + expect(jobPosting).toBeDefined(); + expect(jobPosting!.companyRef.toString()).toBe(company.id); + }); + + it("should override companyRef from request body with route parameter", async () => { + const company = await createTestProfessionalProfile(); + const otherCompany = await createTestProfessionalProfile(); + const jobPostingData = await getJobPostingData(); + + // Set companyRef to a different company - should be overridden + jobPostingData.companyRef = otherCompany.id; + + const response = await agent + .post(`/api/professional-profiles/${company.id}/job-postings`) + .send(jobPostingData); + + expectSuccessResponse(response, TestJobPostingValidator, undefined, { status: 201 }); + // Should use the company from the route, not from the body + expect(response.body.data.companyRef.id).toBe(company.id); + }); + + it("should return an error for non-existent professional profile", async () => { + const badId = new Types.ObjectId(); + const jobPostingData = await getJobPostingData(); + delete (jobPostingData as any).companyRef; + + const response = await agent + .post(`/api/professional-profiles/${badId}/job-postings`) + .send(jobPostingData); + + expectHttpErrorResponse(response, { + status: 404, + errors: [ + { + details: expect.stringContaining(badId.toString()), + code: ProfessionalProfileCode.ProfessionalProfileNotFound, + }, + ], + }); + }); + + it("should return validation errors for missing required fields", async () => { + const company = await createTestProfessionalProfile(); + const invalidData = { + jobTitle: "Test Job", + // Missing required fields + }; + + const response = await agent + .post(`/api/professional-profiles/${company.id}/job-postings`) + .send(invalidData); + + expectValidationErrors(response, [ + "location", + "numAccepted", + "workType", + "deadline", + "about", + "qualifications", + "responsibilities", + ]); + }); + + it("should return validation error for invalid professional profile id", async () => { + const jobPostingData = await getJobPostingData(); + delete (jobPostingData as any).companyRef; + + const response = await agent + .post("/api/professional-profiles/invalid-id/job-postings") + .send(jobPostingData); + + expectValidationErrors(response, ["id"], "params"); + }); + + it("should create job posting with default values", async () => { + const company = await createTestProfessionalProfile(); + const jobPostingData = await getJobPostingData(); + delete (jobPostingData as any).companyRef; + + const response = await agent + .post(`/api/professional-profiles/${company.id}/job-postings`) + .send(jobPostingData); + + expectSuccessResponse(response, TestJobPostingValidator, undefined, { status: 201 }); + expect(response.body.data.numApplicants).toBe(0); // Should default to 0 + expect(response.body.data.postDate).toBeDefined(); // Should be auto-set + }); + }); +}); diff --git a/server/tests/modules/job-postings/unit/job-posting.controller.test.ts b/server/tests/modules/job-postings/unit/job-posting.controller.test.ts new file mode 100644 index 0000000..963070d --- /dev/null +++ b/server/tests/modules/job-postings/unit/job-posting.controller.test.ts @@ -0,0 +1,302 @@ +import { createTestJobPosting, getJobPostingData } from "#/modules/job-postings/utils/job-posting.helpers"; +import { expectControllerSuccessResponse } from "#/utils/helpers"; +import { JobPostingController } from "@/modules/job-postings/job-posting.controller"; +import { JobPostingService } from "@/modules/job-postings/job-posting.service"; +import { JobPostingNotFoundError } from "@/modules/job-postings/utils/job-posting.errors"; +import { Request, Response } from "express"; +import { Types } from "mongoose"; + +// Mock the service +jest.mock("@/modules/job-postings/job-posting.service"); + +describe("JobPostingController", () => { + let jobPostingController: JobPostingController; + let mockJobPostingService: jest.Mocked; + let mockRequest: Partial; + let mockResponse: jest.Mocked; + + beforeEach(() => { + mockJobPostingService = new JobPostingService() as jest.Mocked; + jobPostingController = new JobPostingController(mockJobPostingService); + + mockRequest = {}; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as any; + }); + + describe("getAllJobPostings", () => { + it("should return all job postings", async () => { + const jobPostings = [await createTestJobPosting(), await createTestJobPosting()]; + mockJobPostingService.getAllJobPostings.mockResolvedValue(jobPostings); + + await jobPostingController.getAllJobPostings(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.getAllJobPostings).toHaveBeenCalledWith(); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPostings, + message: "Job postings retrieved successfully", + }); + }); + }); + + describe("getJobPostingsByCompany", () => { + it("should return job postings for a specific company", async () => { + const companyId = new Types.ObjectId().toString(); + const jobPostings = [await createTestJobPosting()]; + + mockRequest.params = { companyId }; + mockJobPostingService.getJobPostingsByCompany.mockResolvedValue(jobPostings); + + await jobPostingController.getJobPostingsByCompany(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.getJobPostingsByCompany).toHaveBeenCalledWith(companyId); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPostings, + message: `Job postings for company ${companyId} retrieved successfully`, + }); + }); + }); + + describe("getJobPostingById", () => { + it("should return a job posting by id", async () => { + const jobPosting = await createTestJobPosting(); + + mockRequest.params = { id: jobPosting.id }; + mockJobPostingService.getJobPostingById.mockResolvedValue(jobPosting); + + await jobPostingController.getJobPostingById(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.getJobPostingById).toHaveBeenCalledWith(jobPosting.id); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPosting, + message: `Job posting with id ${jobPosting.id} retrieved successfully`, + }); + }); + }); + + describe("createJobPosting", () => { + it("should create a new job posting", async () => { + const jobPostingData = await getJobPostingData(); + const createdJobPosting = await createTestJobPosting(); + + mockRequest.body = jobPostingData; + mockJobPostingService.createJobPosting.mockResolvedValue(createdJobPosting); + + await jobPostingController.createJobPosting(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.createJobPosting).toHaveBeenCalledWith(jobPostingData); + expectControllerSuccessResponse(mockResponse, { + status: 201, + data: createdJobPosting, + message: `Job posting with id ${createdJobPosting.id} created successfully`, + }); + }); + }); + + describe("updateJobPosting", () => { + it("should update an existing job posting", async () => { + const jobPosting = await createTestJobPosting(); + const updateData = { jobTitle: "Updated Job Title" }; + const updatedJobPosting = { ...jobPosting, ...updateData }; + + mockRequest.params = { id: jobPosting.id }; + mockRequest.body = updateData; + mockJobPostingService.updateJobPosting.mockResolvedValue(updatedJobPosting); + + await jobPostingController.updateJobPosting(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.updateJobPosting).toHaveBeenCalledWith(jobPosting.id, updateData); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: updatedJobPosting, + message: `Job posting with id ${jobPosting.id} updated successfully`, + }); + }); + }); + + describe("deleteJobPosting", () => { + it("should delete a job posting", async () => { + const jobPosting = await createTestJobPosting(); + + mockRequest.params = { id: jobPosting.id }; + mockJobPostingService.deleteJobPosting.mockResolvedValue(jobPosting); + + await jobPostingController.deleteJobPosting(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.deleteJobPosting).toHaveBeenCalledWith(jobPosting.id); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPosting, + message: `Job posting with id ${jobPosting.id} deleted successfully`, + }); + }); + }); + + describe("incrementApplicants", () => { + it("should increment applicant count", async () => { + const jobPosting = await createTestJobPosting(); + const updatedJobPosting = { ...jobPosting, numApplicants: jobPosting.numApplicants + 1 }; + + mockRequest.params = { id: jobPosting.id }; + mockJobPostingService.incrementApplicants.mockResolvedValue(updatedJobPosting); + + await jobPostingController.incrementApplicants(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.incrementApplicants).toHaveBeenCalledWith(jobPosting.id); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: updatedJobPosting, + message: `Applicant count incremented for job posting with id ${jobPosting.id}`, + }); + }); + }); + + describe("updateJobStatus", () => { + it("should update job status", async () => { + const jobPosting = await createTestJobPosting(); + const newStatus = "closed"; + const updatedJobPosting = { ...jobPosting, jobStatus: newStatus }; + + mockRequest.params = { id: jobPosting.id }; + mockRequest.body = { status: newStatus }; + mockJobPostingService.updateJobStatus.mockResolvedValue(updatedJobPosting as any); + + await jobPostingController.updateJobStatus(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.updateJobStatus).toHaveBeenCalledWith(jobPosting.id, newStatus); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: updatedJobPosting, + message: `Job status updated for job posting with id ${jobPosting.id}`, + }); + }); + }); + + describe("searchJobPostingsBySkills", () => { + it("should search job postings by skills", async () => { + const jobPostings = [await createTestJobPosting()]; + const skills = "JavaScript,TypeScript,React"; + + mockRequest.query = { skills }; + mockJobPostingService.searchJobPostingsBySkills.mockResolvedValue(jobPostings); + + await jobPostingController.searchJobPostingsBySkills(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.searchJobPostingsBySkills).toHaveBeenCalledWith([ + "JavaScript", + "TypeScript", + "React" + ]); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPostings, + message: "Job postings matching skills retrieved successfully", + }); + }); + + it("should handle empty skills query", async () => { + mockRequest.query = {}; + mockJobPostingService.searchJobPostingsBySkills.mockResolvedValue([]); + + await jobPostingController.searchJobPostingsBySkills(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.searchJobPostingsBySkills).toHaveBeenCalledWith([]); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: [], + message: "Job postings matching skills retrieved successfully", + }); + }); + + it("should handle skills with extra whitespace", async () => { + const jobPostings = [await createTestJobPosting()]; + const skills = " JavaScript , TypeScript , React "; + + mockRequest.query = { skills }; + mockJobPostingService.searchJobPostingsBySkills.mockResolvedValue(jobPostings); + + await jobPostingController.searchJobPostingsBySkills(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.searchJobPostingsBySkills).toHaveBeenCalledWith([ + "JavaScript", + "TypeScript", + "React" + ]); + }); + }); + + describe("searchJobPostingsByLocation", () => { + it("should search job postings by location", async () => { + const jobPostings = [await createTestJobPosting()]; + const location = "San Francisco"; + + mockRequest.query = { location }; + mockJobPostingService.searchJobPostingsByLocation.mockResolvedValue(jobPostings); + + await jobPostingController.searchJobPostingsByLocation(mockRequest as Request, mockResponse); + + expect(mockJobPostingService.searchJobPostingsByLocation).toHaveBeenCalledWith(location); + expectControllerSuccessResponse(mockResponse, { + status: 200, + data: jobPostings, + message: `Job postings in location "${location}" retrieved successfully`, + }); + }); + + it("should return error for missing location parameter", async () => { + mockRequest.query = {}; + + await jobPostingController.searchJobPostingsByLocation(mockRequest as Request, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "error", + errors: [{ + type: "validation", + loc: "query", + field: "location", + details: "Location query parameter is required", + }], + }); + }); + + it("should return error for empty location parameter", async () => { + mockRequest.query = { location: "" }; + + await jobPostingController.searchJobPostingsByLocation(mockRequest as Request, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "error", + errors: [{ + type: "validation", + loc: "query", + field: "location", + details: "Location query parameter is required", + }], + }); + }); + + it("should return error for non-string location parameter", async () => { + mockRequest.query = { location: 123 as any }; + + await jobPostingController.searchJobPostingsByLocation(mockRequest as Request, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + status: "error", + errors: [{ + type: "validation", + loc: "query", + field: "location", + details: "Location query parameter is required", + }], + }); + }); + }); +}); diff --git a/server/tests/modules/job-postings/unit/job-posting.service.test.ts b/server/tests/modules/job-postings/unit/job-posting.service.test.ts new file mode 100644 index 0000000..9d037a1 --- /dev/null +++ b/server/tests/modules/job-postings/unit/job-posting.service.test.ts @@ -0,0 +1,302 @@ +import { createTestJobPosting, getJobPostingData } from "#/modules/job-postings/utils/job-posting.helpers"; +import { createTestProfessionalProfile } from "#/modules/professional-profiles/utils/professional-profile.helpers"; +import { JobPostingService } from "@/modules/job-postings/job-posting.service"; +import { JobPostingModel, JobStatus } from "@/modules/job-postings/job-posting.model"; +import { JobPostingNotFoundError } from "@/modules/job-postings/utils/job-posting.errors"; +import { ProfessionalProfileNotFoundError } from "@/modules/professional-profiles/utils/professional-profile.errors"; +import { Types } from "mongoose"; + +describe("JobPostingService", () => { + let jobPostingService: JobPostingService; + + beforeEach(() => { + jobPostingService = new JobPostingService(); + }); + + describe("getAllJobPostings", () => { + it("should return all job postings with populated company references", async () => { + const jobPosting1 = await createTestJobPosting(); + const jobPosting2 = await createTestJobPosting(); + + const result = await jobPostingService.getAllJobPostings(); + + expect(result).toHaveLength(2); + expect(result.some(jp => jp.id === jobPosting1.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting2.id)).toBe(true); + + // Verify company reference is populated + expect(typeof result[0].companyRef).toBe("object"); + expect(result[0].companyRef).toHaveProperty("id"); + expect(result[0].companyRef).toHaveProperty("name"); + }); + + it("should return empty array when no job postings exist", async () => { + const result = await jobPostingService.getAllJobPostings(); + + expect(result).toEqual([]); + }); + }); + + describe("getJobPostingsByCompany", () => { + it("should return job postings for a specific company", async () => { + const company1 = await createTestProfessionalProfile(); + const company2 = await createTestProfessionalProfile(); + + const jobPosting1 = await createTestJobPosting({ companyRef: company1.id }); + const jobPosting2 = await createTestJobPosting({ companyRef: company1.id }); + await createTestJobPosting({ companyRef: company2.id }); // Different company + + const result = await jobPostingService.getJobPostingsByCompany(company1.id); + + expect(result).toHaveLength(2); + expect(result.some(jp => jp.id === jobPosting1.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting2.id)).toBe(true); + expect(result.every(jp => typeof jp.companyRef === "object" && jp.companyRef.id === company1.id)).toBe(true); + }); + + it("should return empty array for company with no job postings", async () => { + const company = await createTestProfessionalProfile(); + + const result = await jobPostingService.getJobPostingsByCompany(company.id); + + expect(result).toEqual([]); + }); + + it("should throw ProfessionalProfileNotFoundError for non-existent company", async () => { + const badId = new Types.ObjectId().toString(); + + await expect(jobPostingService.getJobPostingsByCompany(badId)) + .rejects.toThrow(ProfessionalProfileNotFoundError); + }); + }); + + describe("getJobPostingById", () => { + it("should return a job posting by id with populated company reference", async () => { + const jobPosting = await createTestJobPosting(); + + const result = await jobPostingService.getJobPostingById(jobPosting.id); + + expect(result.id).toBe(jobPosting.id); + expect(result.jobTitle).toBe(jobPosting.jobTitle); + expect(typeof result.companyRef).toBe("object"); + expect(result.companyRef).toHaveProperty("id"); + }); + + it("should throw JobPostingNotFoundError for non-existent job posting", async () => { + const badId = new Types.ObjectId().toString(); + + await expect(jobPostingService.getJobPostingById(badId)) + .rejects.toThrow(JobPostingNotFoundError); + }); + }); + + describe("createJobPosting", () => { + it("should create a new job posting with populated company reference", async () => { + const jobPostingData = await getJobPostingData(); + + const result = await jobPostingService.createJobPosting(jobPostingData); + + expect(result.id).toBeDefined(); + expect(result.jobTitle).toBe(jobPostingData.jobTitle); + expect(result.numApplicants).toBe(0); // Should default to 0 + expect(typeof result.companyRef).toBe("object"); + + // Verify it was saved to database + const saved = await JobPostingModel.findById(result.id); + expect(saved).toBeDefined(); + }); + + it("should throw ProfessionalProfileNotFoundError for non-existent company", async () => { + const jobPostingData = await getJobPostingData(); + jobPostingData.companyRef = new Types.ObjectId().toString(); + + await expect(jobPostingService.createJobPosting(jobPostingData)) + .rejects.toThrow(ProfessionalProfileNotFoundError); + }); + }); + + describe("updateJobPosting", () => { + it("should update an existing job posting", async () => { + const jobPosting = await createTestJobPosting(); + const updateData = { + jobTitle: "Updated Job Title", + jobStatus: JobStatus.REVIEWING, + skillsTags: ["React", "Node.js"], + }; + + const result = await jobPostingService.updateJobPosting(jobPosting.id, updateData); + + expect(result.id).toBe(jobPosting.id); + expect(result.jobTitle).toBe(updateData.jobTitle); + expect(result.jobStatus).toBe(updateData.jobStatus); + expect(result.skillsTags).toEqual(updateData.skillsTags); + + // Verify database was updated + const updated = await JobPostingModel.findById(jobPosting.id); + expect(updated!.jobTitle).toBe(updateData.jobTitle); + }); + + it("should validate company reference when updating", async () => { + const jobPosting = await createTestJobPosting(); + const updateData = { + companyRef: new Types.ObjectId().toString(), + }; + + await expect(jobPostingService.updateJobPosting(jobPosting.id, updateData)) + .rejects.toThrow(ProfessionalProfileNotFoundError); + }); + + it("should throw JobPostingNotFoundError for non-existent job posting", async () => { + const badId = new Types.ObjectId().toString(); + const updateData = { jobTitle: "Updated Title" }; + + await expect(jobPostingService.updateJobPosting(badId, updateData)) + .rejects.toThrow(JobPostingNotFoundError); + }); + }); + + describe("deleteJobPosting", () => { + it("should delete an existing job posting", async () => { + const jobPosting = await createTestJobPosting(); + + const result = await jobPostingService.deleteJobPosting(jobPosting.id); + + expect(result.id).toBe(jobPosting.id); + + // Verify it was deleted from database + const deleted = await JobPostingModel.findById(jobPosting.id); + expect(deleted).toBeNull(); + }); + + it("should throw JobPostingNotFoundError for non-existent job posting", async () => { + const badId = new Types.ObjectId().toString(); + + await expect(jobPostingService.deleteJobPosting(badId)) + .rejects.toThrow(JobPostingNotFoundError); + }); + }); + + describe("incrementApplicants", () => { + it("should increment the applicant count", async () => { + const jobPosting = await createTestJobPosting(); + const initialCount = jobPosting.numApplicants; + + const result = await jobPostingService.incrementApplicants(jobPosting.id); + + expect(result.numApplicants).toBe(initialCount + 1); + + // Verify database was updated + const updated = await JobPostingModel.findById(jobPosting.id); + expect(updated!.numApplicants).toBe(initialCount + 1); + }); + + it("should throw JobPostingNotFoundError for non-existent job posting", async () => { + const badId = new Types.ObjectId().toString(); + + await expect(jobPostingService.incrementApplicants(badId)) + .rejects.toThrow(JobPostingNotFoundError); + }); + }); + + describe("updateJobStatus", () => { + it("should update the job status", async () => { + const jobPosting = await createTestJobPosting(); + const newStatus = JobStatus.CLOSED; + + const result = await jobPostingService.updateJobStatus(jobPosting.id, newStatus); + + expect(result.jobStatus).toBe(newStatus); + + // Verify database was updated + const updated = await JobPostingModel.findById(jobPosting.id); + expect(updated!.jobStatus).toBe(newStatus); + }); + + it("should throw JobPostingNotFoundError for non-existent job posting", async () => { + const badId = new Types.ObjectId().toString(); + + await expect(jobPostingService.updateJobStatus(badId, JobStatus.CLOSED)) + .rejects.toThrow(JobPostingNotFoundError); + }); + }); + + describe("searchJobPostingsBySkills", () => { + it("should return job postings matching any of the provided skills", async () => { + const jobPosting1 = await createTestJobPosting({ + skillsTags: ["JavaScript", "React", "Node.js"], + }); + const jobPosting2 = await createTestJobPosting({ + skillsTags: ["Python", "Django", "PostgreSQL"], + }); + const jobPosting3 = await createTestJobPosting({ + skillsTags: ["Java", "Spring", "MySQL"], + }); + + const result = await jobPostingService.searchJobPostingsBySkills(["JavaScript", "Python"]); + + expect(result).toHaveLength(2); + expect(result.some(jp => jp.id === jobPosting1.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting2.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting3.id)).toBe(false); + }); + + it("should return empty array when no skills match", async () => { + await createTestJobPosting({ + skillsTags: ["Python", "Django"], + }); + + const result = await jobPostingService.searchJobPostingsBySkills(["Java", "C++"]); + + expect(result).toEqual([]); + }); + + it("should handle empty skills array", async () => { + await createTestJobPosting(); + + const result = await jobPostingService.searchJobPostingsBySkills([]); + + expect(result).toEqual([]); + }); + }); + + describe("searchJobPostingsByLocation", () => { + it("should return job postings matching location (case-insensitive)", async () => { + const jobPosting1 = await createTestJobPosting({ + location: "San Francisco, CA", + }); + const jobPosting2 = await createTestJobPosting({ + location: "New York, NY", + }); + const jobPosting3 = await createTestJobPosting({ + location: "San Jose, CA", + }); + + const result = await jobPostingService.searchJobPostingsByLocation("san"); + + expect(result).toHaveLength(2); + expect(result.some(jp => jp.id === jobPosting1.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting3.id)).toBe(true); + expect(result.some(jp => jp.id === jobPosting2.id)).toBe(false); + }); + + it("should return empty array when no locations match", async () => { + await createTestJobPosting({ + location: "Boston, MA", + }); + + const result = await jobPostingService.searchJobPostingsByLocation("seattle"); + + expect(result).toEqual([]); + }); + + it("should perform partial matches", async () => { + await createTestJobPosting({ + location: "Los Angeles, CA", + }); + + const result = await jobPostingService.searchJobPostingsByLocation("Angeles"); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/server/tests/modules/job-postings/utils/job-posting.helpers.ts b/server/tests/modules/job-postings/utils/job-posting.helpers.ts new file mode 100644 index 0000000..eb4fe9e --- /dev/null +++ b/server/tests/modules/job-postings/utils/job-posting.helpers.ts @@ -0,0 +1,85 @@ +import { createTestProfessionalProfile } from "#/modules/professional-profiles/utils/professional-profile.helpers"; +import { + InputJobPosting, + JobPosting, + JobPostingModel, + JobStatus, + WorkType, + HoursType, +} from "@/modules/job-postings/job-posting.model"; + +const getJobPostingData = async (): Promise => { + const company = await createTestProfessionalProfile(); + + return { + jobTitle: `Test Job ${Date.now()}`, + companyRef: company.id, + postDate: new Date(), + location: "Test City, Test State", + numAccepted: 5, + jobStatus: JobStatus.ACTIVELY_HIRING, + workType: WorkType.HYBRID, + payRange: { + min: 50000, + max: 80000, + currency: "USD", + period: "yearly", + }, + hours: { + type: HoursType.FULL_TIME, + }, + deadline: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + skillsTags: ["JavaScript", "TypeScript", "Node.js"], + externalApplicationLink: "https://test-company.test/apply", + about: "This is a test job posting about section with detailed description of the role and company.", + qualifications: "Bachelor's degree in Computer Science or related field. 3+ years of experience.", + responsibilities: "Develop and maintain web applications, collaborate with team members, write tests.", + }; +}; + +/** + * @param data Optional data to override the default job posting data. + * Defaults to {@link getJobPostingData}. + */ +async function createTestJobPosting( + data?: Partial +): Promise { + const defaultJobPosting = await getJobPostingData(); + + const jobPostingData = { + ...defaultJobPosting, + ...data, + }; + + const jobPosting = new JobPostingModel(jobPostingData); + const doc = await jobPosting.save(); + await doc.populate("companyRef", "name tag location"); + return JobPosting.fromDoc(doc as any); +} + +/** + * Creates multiple test job postings for a specific company + */ +async function createTestJobPostingsForCompany( + companyId: string, + count: number = 3 +): Promise { + const jobPostings: JobPosting[] = []; + + for (let i = 0; i < count; i++) { + const jobPosting = await createTestJobPosting({ + companyRef: companyId, + jobTitle: `Test Job ${i + 1} for Company`, + skillsTags: i === 0 ? ["JavaScript", "React"] : i === 1 ? ["Python", "Django"] : ["Java", "Spring"], + }); + jobPostings.push(jobPosting); + } + + return jobPostings; +} + +export { + createTestJobPosting, + createTestJobPostingsForCompany, + getJobPostingData +}; diff --git a/server/tests/modules/job-postings/utils/job-posting.validators.ts b/server/tests/modules/job-postings/utils/job-posting.validators.ts new file mode 100644 index 0000000..d94b2d3 --- /dev/null +++ b/server/tests/modules/job-postings/utils/job-posting.validators.ts @@ -0,0 +1,134 @@ +import { + IsArray, + IsDateString, + IsEnum, + IsMongoId, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUrl, + ValidateNested, + Min, +} from "class-validator"; +import { Type } from "class-transformer"; +import { JobStatus, WorkType, HoursType } from "@/modules/job-postings/job-posting.model"; + +class TestPayRangeValidator { + @IsNumber() + @Min(0) + min!: number; + + @IsNumber() + @Min(0) + max!: number; + + @IsString() + @IsNotEmpty() + currency!: string; + + @IsEnum(["hourly", "monthly", "yearly"]) + period!: "hourly" | "monthly" | "yearly"; +} + +class TestHoursValidator { + @IsEnum(HoursType) + type!: HoursType; + + @IsOptional() + @IsNumber() + @Min(0) + specificHours?: number; +} + +class TestCompanyRefValidator { + @IsMongoId() + id!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + tag!: string; + + @IsOptional() + @IsString() + location?: string; +} + +class TestJobPostingValidator { + @IsMongoId() + id!: string; + + @IsString() + @IsNotEmpty() + jobTitle!: string; + + // Can be either a string (ObjectId) or populated company object + companyRef!: string | TestCompanyRefValidator; + + @IsDateString() + postDate!: string; + + @IsString() + @IsNotEmpty() + location!: string; + + @IsNumber() + @Min(0) + numApplicants!: number; + + @IsNumber() + @Min(1) + numAccepted!: number; + + @IsEnum(JobStatus) + jobStatus!: JobStatus; + + @IsEnum(WorkType) + workType!: WorkType; + + @ValidateNested() + @Type(() => TestPayRangeValidator) + payRange!: TestPayRangeValidator; + + @ValidateNested() + @Type(() => TestHoursValidator) + hours!: TestHoursValidator; + + @IsDateString() + deadline!: string; + + @IsArray() + @IsString({ each: true }) + skillsTags!: string[]; + + @IsOptional() + @IsString() + @IsUrl() + externalApplicationLink?: string; + + @IsString() + @IsNotEmpty() + about!: string; + + @IsString() + @IsNotEmpty() + qualifications!: string; + + @IsString() + @IsNotEmpty() + responsibilities!: string; + + @IsDateString() + entryDate!: string; +} + +export { + TestJobPostingValidator, + TestPayRangeValidator, + TestHoursValidator, + TestCompanyRefValidator +}; diff --git a/test-job-postings.js b/test-job-postings.js new file mode 100644 index 0000000..e69de29 diff --git a/test-job-postings.sh b/test-job-postings.sh new file mode 100644 index 0000000..e69de29