diff --git a/apps/api/src/tests/job.test.js b/apps/api/src/tests/job.test.js new file mode 100644 index 0000000000..8be7c43291 --- /dev/null +++ b/apps/api/src/tests/job.test.js @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createJobSchema, updateJobSchema } from "../validators/job.js"; + +// ─── createJobSchema ─── + +test("createJobSchema accepts valid budget range", () => { + const result = createJobSchema.safeParse({ + title: "Build a website", + description: "Need a modern landing page with responsive design", + budgetMin: 100, + budgetMax: 500, + categoryId: "web-development", + skills: ["react", "css"], + }); + assert.equal(result.success, true); + assert.equal(result.data.budgetMin, 100); + assert.equal(result.data.budgetMax, 500); +}); + +test("createJobSchema accepts equal budget min and max", () => { + const result = createJobSchema.safeParse({ + title: "Fixed price job", + description: "This is a fixed price job posting example", + budgetMin: 200, + budgetMax: 200, + categoryId: "design", + }); + assert.equal(result.success, true); +}); + +test("createJobSchema rejects inverted budget range", () => { + const result = createJobSchema.safeParse({ + title: "Bad job", + description: "This job has inverted budget range values", + budgetMin: 500, + budgetMax: 100, + categoryId: "dev", + }); + assert.equal(result.success, false); + assert.ok(result.error.issues.some((i) => i.path.includes("budgetMax"))); +}); + +test("createJobSchema rejects negative budgetMin", () => { + const result = createJobSchema.safeParse({ + title: "Negative min", + description: "Testing negative budget minimum value here", + budgetMin: -10, + budgetMax: 100, + categoryId: "dev", + }); + assert.equal(result.success, false); +}); + +test("createJobSchema rejects NaN budgetMax", () => { + const result = createJobSchema.safeParse({ + title: "NaN test", + description: "Testing NaN budget maximum value in this test", + budgetMin: 100, + budgetMax: NaN, + categoryId: "dev", + }); + assert.equal(result.success, false); +}); + +test("createJobSchema rejects Infinity budgetMin", () => { + const result = createJobSchema.safeParse({ + title: "Infinity test", + description: "Testing infinity budget minimum value here", + budgetMin: Infinity, + budgetMax: 500, + categoryId: "dev", + }); + assert.equal(result.success, false); +}); + +test("createJobSchema rejects missing title", () => { + const result = createJobSchema.safeParse({ + description: "No title provided for this job posting", + budgetMin: 100, + budgetMax: 500, + categoryId: "dev", + }); + assert.equal(result.success, false); +}); + +test("createJobSchema rejects title too short", () => { + const result = createJobSchema.safeParse({ + title: "Hi", + description: "This description is long enough to pass validation", + budgetMin: 100, + budgetMax: 500, + categoryId: "dev", + }); + assert.equal(result.success, false); +}); + +test("createJobSchema defaults skills to empty array", () => { + const result = createJobSchema.safeParse({ + title: "No skills job", + description: "This job posting has no skills specified at all here", + budgetMin: 100, + budgetMax: 500, + categoryId: "dev", + }); + assert.equal(result.success, true); + assert.deepEqual(result.data.skills, []); +}); + +test("createJobSchema rejects empty skill string", () => { + const result = createJobSchema.safeParse({ + title: "Empty skill", + description: "This job has an empty string in skills array field", + budgetMin: 100, + budgetMax: 500, + categoryId: "dev", + skills: ["react", ""], + }); + assert.equal(result.success, false); +}); + +// ─── updateJobSchema ─── + +test("updateJobSchema accepts partial update with only title", () => { + const result = updateJobSchema.safeParse({ title: "New Title" }); + assert.equal(result.success, true); +}); + +test("updateJobSchema accepts partial update with valid budget range", () => { + const result = updateJobSchema.safeParse({ + budgetMin: 200, + budgetMax: 800, + }); + assert.equal(result.success, true); +}); + +test("updateJobSchema rejects inverted budget range when both present", () => { + const result = updateJobSchema.safeParse({ + budgetMin: 500, + budgetMax: 100, + }); + assert.equal(result.success, false); + assert.ok(result.error.issues.some((i) => i.path.includes("budgetMax"))); +}); + +test("updateJobSchema accepts update with only budgetMin", () => { + const result = updateJobSchema.safeParse({ budgetMin: 300 }); + assert.equal(result.success, true); +}); + +test("updateJobSchema accepts update with only budgetMax", () => { + const result = updateJobSchema.safeParse({ budgetMax: 900 }); + assert.equal(result.success, true); +}); + +test("updateJobSchema accepts empty object (no-op update)", () => { + const result = updateJobSchema.safeParse({}); + assert.equal(result.success, true); +}); + +test("updateJobSchema accepts equal budget values", () => { + const result = updateJobSchema.safeParse({ + budgetMin: 250, + budgetMax: 250, + }); + assert.equal(result.success, true); +}); + +test("updateJobSchema rejects negative budgetMin in partial update", () => { + const result = updateJobSchema.safeParse({ budgetMin: -50 }); + assert.equal(result.success, false); +}); diff --git a/apps/api/src/validators/job.js b/apps/api/src/validators/job.js index 5593a844af..508bb966a9 100644 --- a/apps/api/src/validators/job.js +++ b/apps/api/src/validators/job.js @@ -1,12 +1,94 @@ import { z } from "zod"; -export const createJobSchema = z.object({ - title: z.string().min(4), - description: z.string().min(10), - budgetMin: z.number().nonnegative(), - budgetMax: z.number().nonnegative(), - categoryId: z.string().min(1), - skills: z.array(z.string().min(1)).default([]) +/** + * Base job schema without refinements. + * Separated so that `.partial()` can be called on the raw object + * (Zod v4 forbids `.partial()` on refined schemas). + */ +const baseJobFields = z.object({ + title: z.string() + .min(4, "Title must be at least 4 characters") + .max(200, "Title must be at most 200 characters"), + description: z.string() + .min(10, "Description must be at least 10 characters") + .max(5000, "Description must be at most 5000 characters"), + budgetMin: z.number() + .nonnegative("budgetMin must be non-negative") + .finite("budgetMin must be a finite number"), + budgetMax: z.number() + .nonnegative("budgetMax must be non-negative") + .finite("budgetMax must be a finite number"), + categoryId: z.string() + .min(1, "categoryId is required") + .max(100, "categoryId must be at most 100 characters"), + skills: z.array( + z.string() + .min(1, "Skill must be a non-empty string") + .max(50, "Skill must be at most 50 characters") + ).max(20, "Maximum 20 skills allowed").default([]) }); -export const updateJobSchema = createJobSchema.partial(); +/** + * Schema for creating a new job posting. + * + * Validates: + * - title: 4-200 characters + * - description: 10-5000 characters + * - budgetMin: non-negative finite number + * - budgetMax: non-negative finite number, must be >= budgetMin + * - categoryId: non-empty string + * - skills: array of strings (defaults to empty) + * + * @example + * ```js + * const result = createJobSchema.safeParse({ + * title: "Build a website", + * description: "Need a modern landing page with responsive design", + * budgetMin: 100, + * budgetMax: 500, + * categoryId: "web-development", + * skills: ["react", "css"] + * }); + * ``` + */ +export const createJobSchema = baseJobFields.refine( + (data) => data.budgetMax >= data.budgetMin, + { + message: "budgetMax must be greater than or equal to budgetMin", + path: ["budgetMax"], + } +); + +/** + * Schema for updating an existing job posting. + * + * All fields are optional (partial update). + * When both budgetMin and budgetMax are provided, + * budgetMax must be >= budgetMin. + * + * @example + * ```js + * // Valid: update only title + * updateJobSchema.parse({ title: "New Title" }); + * + * // Valid: update both budgets with valid range + * updateJobSchema.parse({ budgetMin: 200, budgetMax: 800 }); + * + * // Invalid: inverted budget range + * updateJobSchema.parse({ budgetMin: 500, budgetMax: 100 }); + * // => throws ZodError + * ``` + */ +export const updateJobSchema = baseJobFields.partial().refine( + (data) => { + // Only validate when both fields are present + if (data.budgetMin !== undefined && data.budgetMax !== undefined) { + return data.budgetMax >= data.budgetMin; + } + return true; + }, + { + message: "budgetMax must be greater than or equal to budgetMin when both are provided", + path: ["budgetMax"], + } +);