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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions apps/api/src/tests/job.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
98 changes: 90 additions & 8 deletions apps/api/src/validators/job.js
Original file line number Diff line number Diff line change
@@ -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"],
}
);
Loading