test(shared): add 215 schema validation tests covering all 17 Zod schemas
Phase 3a: raises shared schema coverage from 5.5% to ~95%. Tests cover valid roundtrips, invalid rejection, edge cases for refinements, defaults, date coercion, and the generateDynamicZodSchema runtime builder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,844 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CreateBlueprintSchema,
|
||||||
|
UpdateBlueprintSchema,
|
||||||
|
BlueprintFieldDefinitionSchema,
|
||||||
|
generateDynamicZodSchema,
|
||||||
|
} from "../schemas/blueprint.schema.js";
|
||||||
|
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "../schemas/org-unit.schema.js";
|
||||||
|
import {
|
||||||
|
CreateManagementLevelGroupSchema,
|
||||||
|
UpdateManagementLevelGroupSchema,
|
||||||
|
CreateManagementLevelSchema,
|
||||||
|
UpdateManagementLevelSchema,
|
||||||
|
} from "../schemas/management-level.schema.js";
|
||||||
|
import {
|
||||||
|
CreateUtilizationCategorySchema,
|
||||||
|
UpdateUtilizationCategorySchema,
|
||||||
|
} from "../schemas/utilization-category.schema.js";
|
||||||
|
import {
|
||||||
|
CreateHolidayCalendarSchema,
|
||||||
|
UpdateHolidayCalendarSchema,
|
||||||
|
CreateHolidayCalendarEntrySchema,
|
||||||
|
UpdateHolidayCalendarEntrySchema,
|
||||||
|
PreviewResolvedHolidaysSchema,
|
||||||
|
} from "../schemas/holiday-calendar.schema.js";
|
||||||
|
import {
|
||||||
|
CreateCalculationRuleSchema,
|
||||||
|
UpdateCalculationRuleSchema,
|
||||||
|
} from "../schemas/calculation-rules.schema.js";
|
||||||
|
import { BlueprintTarget, FieldType } from "../types/enums.js";
|
||||||
|
|
||||||
|
// ─── BlueprintFieldDefinitionSchema ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("BlueprintFieldDefinitionSchema", () => {
|
||||||
|
const validField = {
|
||||||
|
id: "field-1",
|
||||||
|
label: "Project Name",
|
||||||
|
key: "project_name",
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a minimal valid field definition", () => {
|
||||||
|
const result = BlueprintFieldDefinitionSchema.parse(validField);
|
||||||
|
expect(result.id).toBe("field-1");
|
||||||
|
expect(result.required).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a full field with options and validation", () => {
|
||||||
|
const result = BlueprintFieldDefinitionSchema.parse({
|
||||||
|
...validField,
|
||||||
|
required: true,
|
||||||
|
description: "The name of the project",
|
||||||
|
placeholder: "Enter name…",
|
||||||
|
defaultValue: "Untitled",
|
||||||
|
options: [{ value: "a", label: "Option A", color: "#ff0000" }],
|
||||||
|
validation: { minLength: 2, maxLength: 50 },
|
||||||
|
group: "General",
|
||||||
|
});
|
||||||
|
expect(result.required).toBe(true);
|
||||||
|
expect(result.options).toHaveLength(1);
|
||||||
|
expect(result.validation?.maxLength).toBe(50);
|
||||||
|
expect(result.group).toBe("General");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects key that does not match snake_case pattern", () => {
|
||||||
|
expect(() =>
|
||||||
|
BlueprintFieldDefinitionSchema.parse({ ...validField, key: "Project-Name" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects key starting with a digit", () => {
|
||||||
|
expect(() =>
|
||||||
|
BlueprintFieldDefinitionSchema.parse({ ...validField, key: "1bad_key" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative order value", () => {
|
||||||
|
expect(() => BlueprintFieldDefinitionSchema.parse({ ...validField, order: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects label longer than 200 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
BlueprintFieldDefinitionSchema.parse({ ...validField, label: "x".repeat(201) }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateBlueprintSchema ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateBlueprintSchema", () => {
|
||||||
|
it("accepts minimal valid input and applies defaults", () => {
|
||||||
|
const result = CreateBlueprintSchema.parse({
|
||||||
|
name: "Project Template",
|
||||||
|
target: BlueprintTarget.PROJECT,
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("Project Template");
|
||||||
|
expect(result.target).toBe("PROJECT");
|
||||||
|
expect(result.fieldDefs).toEqual([]);
|
||||||
|
expect(result.defaults).toEqual({});
|
||||||
|
expect(result.validationRules).toEqual([]);
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input with fieldDefs and validationRules", () => {
|
||||||
|
const result = CreateBlueprintSchema.parse({
|
||||||
|
name: "Resource Blueprint",
|
||||||
|
target: BlueprintTarget.RESOURCE,
|
||||||
|
description: "Used for all 3D artists",
|
||||||
|
fieldDefs: [
|
||||||
|
{
|
||||||
|
id: "f1",
|
||||||
|
label: "Skill Level",
|
||||||
|
key: "skill_level",
|
||||||
|
type: FieldType.SELECT,
|
||||||
|
order: 0,
|
||||||
|
options: [
|
||||||
|
{ value: "junior", label: "Junior" },
|
||||||
|
{ value: "senior", label: "Senior" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaults: { skill_level: "junior" },
|
||||||
|
validationRules: [{ field: "skill_level", rule: "required_if", params: { other: "active" } }],
|
||||||
|
});
|
||||||
|
expect(result.fieldDefs).toHaveLength(1);
|
||||||
|
expect(result.defaults.skill_level).toBe("junior");
|
||||||
|
expect(result.validationRules[0]?.rule).toBe("required_if");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateBlueprintSchema.parse({ name: "", target: BlueprintTarget.PROJECT }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 200 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateBlueprintSchema.parse({ name: "x".repeat(201), target: BlueprintTarget.PROJECT }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid BlueprintTarget enum value", () => {
|
||||||
|
expect(() => CreateBlueprintSchema.parse({ name: "Test", target: "TEAM" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateBlueprintSchema ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateBlueprintSchema", () => {
|
||||||
|
it("accepts an empty object (all fields optional)", () => {
|
||||||
|
const result = UpdateBlueprintSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.target).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a partial update with only name", () => {
|
||||||
|
const result = UpdateBlueprintSchema.parse({ name: "Updated Name" });
|
||||||
|
expect(result.name).toBe("Updated Name");
|
||||||
|
expect(result.fieldDefs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a partial update with only target", () => {
|
||||||
|
const result = UpdateBlueprintSchema.parse({ target: BlueprintTarget.RESOURCE });
|
||||||
|
expect(result.target).toBe("RESOURCE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid target even in partial update", () => {
|
||||||
|
expect(() => UpdateBlueprintSchema.parse({ target: "INVALID" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── generateDynamicZodSchema ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("generateDynamicZodSchema", () => {
|
||||||
|
it("generates a TEXT field as optional string by default", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{ id: "f1", label: "Title", key: "title", type: FieldType.TEXT, order: 0, required: false },
|
||||||
|
]);
|
||||||
|
const result = schema.parse({});
|
||||||
|
expect(result.title).toBeUndefined();
|
||||||
|
const withValue = schema.parse({ title: "Hello" });
|
||||||
|
expect(withValue.title).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a TEXT field as required when required=true", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{ id: "f1", label: "Title", key: "title", type: FieldType.TEXT, order: 0, required: true },
|
||||||
|
]);
|
||||||
|
expect(() => schema.parse({})).toThrow();
|
||||||
|
expect(schema.parse({ title: "Hello" }).title).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a NUMBER field with min/max validation", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f2",
|
||||||
|
label: "Rating",
|
||||||
|
key: "rating",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
validation: { min: 1, max: 10 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ rating: 5 }).rating).toBe(5);
|
||||||
|
expect(() => schema.parse({ rating: 0 })).toThrow();
|
||||||
|
expect(() => schema.parse({ rating: 11 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a BOOLEAN field", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f3",
|
||||||
|
label: "Active",
|
||||||
|
key: "is_active",
|
||||||
|
type: FieldType.BOOLEAN,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ is_active: true }).is_active).toBe(true);
|
||||||
|
expect(schema.parse({ is_active: false }).is_active).toBe(false);
|
||||||
|
expect(() => schema.parse({ is_active: "yes" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a DATE field using coerce", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f4",
|
||||||
|
label: "Start Date",
|
||||||
|
key: "start_date",
|
||||||
|
type: FieldType.DATE,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = schema.parse({ start_date: "2026-01-15" });
|
||||||
|
expect(result.start_date).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a SELECT field restricted to defined option values", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f5",
|
||||||
|
label: "Status",
|
||||||
|
key: "status",
|
||||||
|
type: FieldType.SELECT,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "draft", label: "Draft" },
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ status: "draft" }).status).toBe("draft");
|
||||||
|
expect(() => schema.parse({ status: "unknown" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a MULTI_SELECT field as an array of valid enum values", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f6",
|
||||||
|
label: "Tags",
|
||||||
|
key: "tags",
|
||||||
|
type: FieldType.MULTI_SELECT,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "cg", label: "CG" },
|
||||||
|
{ value: "vfx", label: "VFX" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ tags: ["cg", "vfx"] }).tags).toEqual(["cg", "vfx"]);
|
||||||
|
expect(() => schema.parse({ tags: ["cg", "invalid"] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates an EMAIL field that validates email format", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{ id: "f7", label: "Email", key: "email", type: FieldType.EMAIL, order: 0, required: true },
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ email: "user@example.com" }).email).toBe("user@example.com");
|
||||||
|
expect(() => schema.parse({ email: "not-an-email" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a URL field that validates URL format", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{ id: "f8", label: "Website", key: "website", type: FieldType.URL, order: 0, required: true },
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ website: "https://example.com" }).website).toBe("https://example.com");
|
||||||
|
expect(() => schema.parse({ website: "not-a-url" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a TEXT field with minLength/maxLength validation", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{
|
||||||
|
id: "f9",
|
||||||
|
label: "Code",
|
||||||
|
key: "code",
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
order: 0,
|
||||||
|
required: true,
|
||||||
|
validation: { minLength: 3, maxLength: 8 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ code: "ABC" }).code).toBe("ABC");
|
||||||
|
expect(() => schema.parse({ code: "AB" })).toThrow();
|
||||||
|
expect(() => schema.parse({ code: "TOOLONGCODE" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a SELECT field with no options as a plain string", () => {
|
||||||
|
const schema = generateDynamicZodSchema([
|
||||||
|
{ id: "f10", label: "Tag", key: "tag", type: FieldType.SELECT, order: 0, required: true },
|
||||||
|
]);
|
||||||
|
expect(schema.parse({ tag: "anything" }).tag).toBe("anything");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateOrgUnitSchema ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateOrgUnitSchema", () => {
|
||||||
|
it("accepts valid minimal input with defaults", () => {
|
||||||
|
const result = CreateOrgUnitSchema.parse({ name: "Studio Munich", level: 5 });
|
||||||
|
expect(result.name).toBe("Studio Munich");
|
||||||
|
expect(result.level).toBe(5);
|
||||||
|
expect(result.sortOrder).toBe(0);
|
||||||
|
expect(result.shortName).toBeUndefined();
|
||||||
|
expect(result.parentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input with all optional fields", () => {
|
||||||
|
const result = CreateOrgUnitSchema.parse({
|
||||||
|
name: "Department VFX",
|
||||||
|
shortName: "VFX",
|
||||||
|
level: 6,
|
||||||
|
parentId: "parent-1",
|
||||||
|
sortOrder: 3,
|
||||||
|
});
|
||||||
|
expect(result.shortName).toBe("VFX");
|
||||||
|
expect(result.parentId).toBe("parent-1");
|
||||||
|
expect(result.sortOrder).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level below 5", () => {
|
||||||
|
expect(() => CreateOrgUnitSchema.parse({ name: "Test", level: 4 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects level above 7", () => {
|
||||||
|
expect(() => CreateOrgUnitSchema.parse({ name: "Test", level: 8 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shortName longer than 50 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateOrgUnitSchema.parse({ name: "Test", level: 5, shortName: "x".repeat(51) }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateOrgUnitSchema ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateOrgUnitSchema", () => {
|
||||||
|
it("accepts empty object (all optional)", () => {
|
||||||
|
const result = UpdateOrgUnitSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.isActive).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable shortName to clear the field", () => {
|
||||||
|
const result = UpdateOrgUnitSchema.parse({ shortName: null });
|
||||||
|
expect(result.shortName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable parentId to unset parent", () => {
|
||||||
|
const result = UpdateOrgUnitSchema.parse({ parentId: null });
|
||||||
|
expect(result.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts isActive flag", () => {
|
||||||
|
const result = UpdateOrgUnitSchema.parse({ isActive: false });
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 200 characters", () => {
|
||||||
|
expect(() => UpdateOrgUnitSchema.parse({ name: "x".repeat(201) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateManagementLevelGroupSchema ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateManagementLevelGroupSchema", () => {
|
||||||
|
it("accepts valid input with defaults", () => {
|
||||||
|
const result = CreateManagementLevelGroupSchema.parse({
|
||||||
|
name: "Senior Management",
|
||||||
|
targetPercentage: 0.15,
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("Senior Management");
|
||||||
|
expect(result.targetPercentage).toBe(0.15);
|
||||||
|
expect(result.sortOrder).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts boundary values for targetPercentage (0 and 1)", () => {
|
||||||
|
expect(
|
||||||
|
CreateManagementLevelGroupSchema.parse({ name: "A", targetPercentage: 0 }).targetPercentage,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
CreateManagementLevelGroupSchema.parse({ name: "B", targetPercentage: 1 }).targetPercentage,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects targetPercentage above 1", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateManagementLevelGroupSchema.parse({ name: "Test", targetPercentage: 1.01 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects targetPercentage below 0", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateManagementLevelGroupSchema.parse({ name: "Test", targetPercentage: -0.01 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateManagementLevelGroupSchema.parse({ name: "", targetPercentage: 0.1 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateManagementLevelGroupSchema ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateManagementLevelGroupSchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const result = UpdateManagementLevelGroupSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.targetPercentage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only sortOrder", () => {
|
||||||
|
const result = UpdateManagementLevelGroupSchema.parse({ sortOrder: 5 });
|
||||||
|
expect(result.sortOrder).toBe(5);
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects targetPercentage out of range in partial update", () => {
|
||||||
|
expect(() => UpdateManagementLevelGroupSchema.parse({ targetPercentage: 2 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateManagementLevelSchema ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateManagementLevelSchema", () => {
|
||||||
|
it("accepts valid input", () => {
|
||||||
|
const result = CreateManagementLevelSchema.parse({ name: "Director", groupId: "grp-1" });
|
||||||
|
expect(result.name).toBe("Director");
|
||||||
|
expect(result.groupId).toBe("grp-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() => CreateManagementLevelSchema.parse({ name: "", groupId: "grp-1" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing groupId", () => {
|
||||||
|
expect(() => CreateManagementLevelSchema.parse({ name: "Director" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 100 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateManagementLevelSchema.parse({ name: "x".repeat(101), groupId: "grp-1" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateManagementLevelSchema ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateManagementLevelSchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const result = UpdateManagementLevelSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.groupId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only groupId", () => {
|
||||||
|
const result = UpdateManagementLevelSchema.parse({ groupId: "grp-2" });
|
||||||
|
expect(result.groupId).toBe("grp-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name in partial update", () => {
|
||||||
|
expect(() => UpdateManagementLevelSchema.parse({ name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateUtilizationCategorySchema ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateUtilizationCategorySchema", () => {
|
||||||
|
it("accepts minimal valid input with defaults", () => {
|
||||||
|
const result = CreateUtilizationCategorySchema.parse({ code: "PROD", name: "Production" });
|
||||||
|
expect(result.code).toBe("PROD");
|
||||||
|
expect(result.name).toBe("Production");
|
||||||
|
expect(result.sortOrder).toBe(0);
|
||||||
|
expect(result.isDefault).toBe(false);
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input including description", () => {
|
||||||
|
const result = CreateUtilizationCategorySchema.parse({
|
||||||
|
code: "SICK",
|
||||||
|
name: "Sick Leave",
|
||||||
|
description: "Covers illness absence",
|
||||||
|
sortOrder: 2,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(result.isDefault).toBe(true);
|
||||||
|
expect(result.description).toBe("Covers illness absence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects code longer than 20 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateUtilizationCategorySchema.parse({ code: "x".repeat(21), name: "Test" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects description longer than 500 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateUtilizationCategorySchema.parse({
|
||||||
|
code: "ABC",
|
||||||
|
name: "Test",
|
||||||
|
description: "x".repeat(501),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty code", () => {
|
||||||
|
expect(() => CreateUtilizationCategorySchema.parse({ code: "", name: "Test" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateUtilizationCategorySchema ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateUtilizationCategorySchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const result = UpdateUtilizationCategorySchema.parse({});
|
||||||
|
expect(result.code).toBeUndefined();
|
||||||
|
expect(result.isDefault).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable description to clear the field", () => {
|
||||||
|
const result = UpdateUtilizationCategorySchema.parse({ description: null });
|
||||||
|
expect(result.description).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts isActive flag", () => {
|
||||||
|
const result = UpdateUtilizationCategorySchema.parse({ isActive: false });
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects code longer than 20 characters in partial update", () => {
|
||||||
|
expect(() => UpdateUtilizationCategorySchema.parse({ code: "x".repeat(21) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateHolidayCalendarSchema ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateHolidayCalendarSchema", () => {
|
||||||
|
it("accepts valid COUNTRY-scope calendar", () => {
|
||||||
|
const result = CreateHolidayCalendarSchema.parse({
|
||||||
|
name: "Germany Public Holidays",
|
||||||
|
scopeType: "COUNTRY",
|
||||||
|
countryId: "DE",
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("Germany Public Holidays");
|
||||||
|
expect(result.scopeType).toBe("COUNTRY");
|
||||||
|
expect(result.stateCode).toBeUndefined();
|
||||||
|
expect(result.priority).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid STATE-scope calendar with stateCode", () => {
|
||||||
|
const result = CreateHolidayCalendarSchema.parse({
|
||||||
|
name: "Bavaria Holidays",
|
||||||
|
scopeType: "STATE",
|
||||||
|
countryId: "DE",
|
||||||
|
stateCode: "BY",
|
||||||
|
priority: 10,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(result.stateCode).toBe("BY");
|
||||||
|
expect(result.priority).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid scopeType", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarSchema.parse({ name: "Test", scopeType: "REGION", countryId: "DE" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects priority below -100", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarSchema.parse({
|
||||||
|
name: "Test",
|
||||||
|
scopeType: "COUNTRY",
|
||||||
|
countryId: "DE",
|
||||||
|
priority: -101,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects priority above 100", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarSchema.parse({
|
||||||
|
name: "Test",
|
||||||
|
scopeType: "COUNTRY",
|
||||||
|
countryId: "DE",
|
||||||
|
priority: 101,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stateCode longer than 16 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarSchema.parse({
|
||||||
|
name: "Test",
|
||||||
|
scopeType: "STATE",
|
||||||
|
countryId: "DE",
|
||||||
|
stateCode: "x".repeat(17),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateHolidayCalendarSchema ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateHolidayCalendarSchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const result = UpdateHolidayCalendarSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.stateCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable stateCode to clear it", () => {
|
||||||
|
const result = UpdateHolidayCalendarSchema.parse({ stateCode: null });
|
||||||
|
expect(result.stateCode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with name and isActive", () => {
|
||||||
|
const result = UpdateHolidayCalendarSchema.parse({ name: "Updated Calendar", isActive: false });
|
||||||
|
expect(result.name).toBe("Updated Calendar");
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 120 characters", () => {
|
||||||
|
expect(() => UpdateHolidayCalendarSchema.parse({ name: "x".repeat(121) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateHolidayCalendarEntrySchema ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateHolidayCalendarEntrySchema", () => {
|
||||||
|
it("accepts valid entry with date coercion from string", () => {
|
||||||
|
const result = CreateHolidayCalendarEntrySchema.parse({
|
||||||
|
holidayCalendarId: "cal-1",
|
||||||
|
date: "2026-12-25",
|
||||||
|
name: "Christmas Day",
|
||||||
|
});
|
||||||
|
expect(result.holidayCalendarId).toBe("cal-1");
|
||||||
|
expect(result.date).toBeInstanceOf(Date);
|
||||||
|
expect(result.name).toBe("Christmas Day");
|
||||||
|
expect(result.isRecurringAnnual).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full entry with all optional fields", () => {
|
||||||
|
const result = CreateHolidayCalendarEntrySchema.parse({
|
||||||
|
holidayCalendarId: "cal-1",
|
||||||
|
date: "2026-10-03",
|
||||||
|
name: "German Unity Day",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "Official Federal Calendar",
|
||||||
|
});
|
||||||
|
expect(result.isRecurringAnnual).toBe(true);
|
||||||
|
expect(result.source).toBe("Official Federal Calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarEntrySchema.parse({
|
||||||
|
holidayCalendarId: "cal-1",
|
||||||
|
date: "2026-01-01",
|
||||||
|
name: "",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing holidayCalendarId", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateHolidayCalendarEntrySchema.parse({ date: "2026-01-01", name: "New Year" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateHolidayCalendarEntrySchema ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateHolidayCalendarEntrySchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const result = UpdateHolidayCalendarEntrySchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.date).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with date coercion", () => {
|
||||||
|
const result = UpdateHolidayCalendarEntrySchema.parse({ date: "2027-01-01" });
|
||||||
|
expect(result.date).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable source to clear it", () => {
|
||||||
|
const result = UpdateHolidayCalendarEntrySchema.parse({ source: null });
|
||||||
|
expect(result.source).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PreviewResolvedHolidaysSchema ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("PreviewResolvedHolidaysSchema", () => {
|
||||||
|
it("accepts valid input with required fields", () => {
|
||||||
|
const result = PreviewResolvedHolidaysSchema.parse({ countryId: "DE", year: 2026 });
|
||||||
|
expect(result.countryId).toBe("DE");
|
||||||
|
expect(result.year).toBe(2026);
|
||||||
|
expect(result.stateCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts input with optional stateCode and metroCityId", () => {
|
||||||
|
const result = PreviewResolvedHolidaysSchema.parse({
|
||||||
|
countryId: "DE",
|
||||||
|
stateCode: "BY",
|
||||||
|
metroCityId: "city-1",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(result.stateCode).toBe("BY");
|
||||||
|
expect(result.metroCityId).toBe("city-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects year below 2000", () => {
|
||||||
|
expect(() => PreviewResolvedHolidaysSchema.parse({ countryId: "DE", year: 1999 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects year above 2100", () => {
|
||||||
|
expect(() => PreviewResolvedHolidaysSchema.parse({ countryId: "DE", year: 2101 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer year", () => {
|
||||||
|
expect(() => PreviewResolvedHolidaysSchema.parse({ countryId: "DE", year: 2026.5 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateCalculationRuleSchema ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateCalculationRuleSchema", () => {
|
||||||
|
const validRule = {
|
||||||
|
name: "Public Holiday Charge Rule",
|
||||||
|
triggerType: "PUBLIC_HOLIDAY",
|
||||||
|
costEffect: "CHARGE",
|
||||||
|
chargeabilityEffect: "COUNT",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal valid input with defaults", () => {
|
||||||
|
const result = CreateCalculationRuleSchema.parse(validRule);
|
||||||
|
expect(result.name).toBe("Public Holiday Charge Rule");
|
||||||
|
expect(result.triggerType).toBe("PUBLIC_HOLIDAY");
|
||||||
|
expect(result.costEffect).toBe("CHARGE");
|
||||||
|
expect(result.chargeabilityEffect).toBe("COUNT");
|
||||||
|
expect(result.priority).toBe(0);
|
||||||
|
expect(result.isActive).toBe(true);
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input with all optional fields", () => {
|
||||||
|
const result = CreateCalculationRuleSchema.parse({
|
||||||
|
...validRule,
|
||||||
|
description: "Charges client on public holidays",
|
||||||
|
triggerType: "SICK",
|
||||||
|
costEffect: "REDUCE",
|
||||||
|
costReductionPercent: 50,
|
||||||
|
projectId: "proj-1",
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
chargeabilityEffect: "SKIP",
|
||||||
|
priority: 100,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
expect(result.costReductionPercent).toBe(50);
|
||||||
|
expect(result.triggerType).toBe("SICK");
|
||||||
|
expect(result.chargeabilityEffect).toBe("SKIP");
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid triggerType", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateCalculationRuleSchema.parse({ ...validRule, triggerType: "OVERTIME" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects costReductionPercent above 100", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateCalculationRuleSchema.parse({ ...validRule, costReductionPercent: 101 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects priority above 1000", () => {
|
||||||
|
expect(() => CreateCalculationRuleSchema.parse({ ...validRule, priority: 1001 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects priority below 0", () => {
|
||||||
|
expect(() => CreateCalculationRuleSchema.parse({ ...validRule, priority: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateCalculationRuleSchema ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateCalculationRuleSchema", () => {
|
||||||
|
it("accepts minimal input with only required id", () => {
|
||||||
|
const result = UpdateCalculationRuleSchema.parse({ id: "rule-1" });
|
||||||
|
expect(result.id).toBe("rule-1");
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.triggerType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with name and priority", () => {
|
||||||
|
const result = UpdateCalculationRuleSchema.parse({
|
||||||
|
id: "rule-1",
|
||||||
|
name: "Updated Rule",
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("Updated Rule");
|
||||||
|
expect(result.priority).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires id to be present", () => {
|
||||||
|
expect(() => UpdateCalculationRuleSchema.parse({ name: "Some Rule" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid costEffect in partial update", () => {
|
||||||
|
expect(() =>
|
||||||
|
UpdateCalculationRuleSchema.parse({ id: "rule-1", costEffect: "IGNORE" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
WeekdayAvailabilitySchema,
|
||||||
|
SkillEntrySchema,
|
||||||
|
CreateResourceSchema,
|
||||||
|
UpdateResourceSchema,
|
||||||
|
} from "../schemas/resource.schema.js";
|
||||||
|
import { CreateClientSchema, UpdateClientSchema } from "../schemas/client.schema.js";
|
||||||
|
import { CreateRoleSchema, UpdateRoleSchema, ResourceRoleSchema } from "../schemas/role.schema.js";
|
||||||
|
import { ResourceType } from "../types/enums.js";
|
||||||
|
|
||||||
|
// ─── WeekdayAvailabilitySchema ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("WeekdayAvailabilitySchema", () => {
|
||||||
|
it("accepts valid weekday-only input", () => {
|
||||||
|
const result = WeekdayAvailabilitySchema.parse({
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
});
|
||||||
|
expect(result.monday).toBe(8);
|
||||||
|
expect(result.saturday).toBeUndefined();
|
||||||
|
expect(result.sunday).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input including weekend hours", () => {
|
||||||
|
const result = WeekdayAvailabilitySchema.parse({
|
||||||
|
monday: 6,
|
||||||
|
tuesday: 6,
|
||||||
|
wednesday: 6,
|
||||||
|
thursday: 6,
|
||||||
|
friday: 6,
|
||||||
|
saturday: 4,
|
||||||
|
sunday: 0,
|
||||||
|
});
|
||||||
|
expect(result.saturday).toBe(4);
|
||||||
|
expect(result.sunday).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts boundary values (0 and 24)", () => {
|
||||||
|
const result = WeekdayAvailabilitySchema.parse({
|
||||||
|
monday: 0,
|
||||||
|
tuesday: 24,
|
||||||
|
wednesday: 0,
|
||||||
|
thursday: 24,
|
||||||
|
friday: 0,
|
||||||
|
});
|
||||||
|
expect(result.monday).toBe(0);
|
||||||
|
expect(result.tuesday).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hours above 24", () => {
|
||||||
|
expect(() =>
|
||||||
|
WeekdayAvailabilitySchema.parse({
|
||||||
|
monday: 25,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hours below 0", () => {
|
||||||
|
expect(() =>
|
||||||
|
WeekdayAvailabilitySchema.parse({
|
||||||
|
monday: -1,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SkillEntrySchema ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("SkillEntrySchema", () => {
|
||||||
|
it("accepts minimal valid skill entry", () => {
|
||||||
|
const result = SkillEntrySchema.parse({ skill: "Houdini", proficiency: 3 });
|
||||||
|
expect(result.skill).toBe("Houdini");
|
||||||
|
expect(result.proficiency).toBe(3);
|
||||||
|
expect(result.category).toBeUndefined();
|
||||||
|
expect(result.certified).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full skill entry with all optional fields", () => {
|
||||||
|
const result = SkillEntrySchema.parse({
|
||||||
|
skill: "Maya",
|
||||||
|
category: "3D Modeling",
|
||||||
|
proficiency: 5,
|
||||||
|
yearsExperience: 7,
|
||||||
|
certified: true,
|
||||||
|
isMainSkill: true,
|
||||||
|
});
|
||||||
|
expect(result.category).toBe("3D Modeling");
|
||||||
|
expect(result.yearsExperience).toBe(7);
|
||||||
|
expect(result.certified).toBe(true);
|
||||||
|
expect(result.isMainSkill).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid proficiency literals (1–5)", () => {
|
||||||
|
for (const level of [1, 2, 3, 4, 5] as const) {
|
||||||
|
const result = SkillEntrySchema.parse({ skill: "Test", proficiency: level });
|
||||||
|
expect(result.proficiency).toBe(level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects proficiency outside the 1–5 range", () => {
|
||||||
|
expect(() => SkillEntrySchema.parse({ skill: "Test", proficiency: 0 })).toThrow();
|
||||||
|
expect(() => SkillEntrySchema.parse({ skill: "Test", proficiency: 6 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty skill name", () => {
|
||||||
|
expect(() => SkillEntrySchema.parse({ skill: "", proficiency: 3 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects yearsExperience above 50", () => {
|
||||||
|
expect(() =>
|
||||||
|
SkillEntrySchema.parse({ skill: "Nuke", proficiency: 2, yearsExperience: 51 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateResourceSchema ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateResourceSchema", () => {
|
||||||
|
const minimalResource = {
|
||||||
|
eid: "EMP-001",
|
||||||
|
displayName: "Jane Smith",
|
||||||
|
email: "jane.smith@example.com",
|
||||||
|
lcrCents: 6000,
|
||||||
|
ucrCents: 4000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal valid resource and applies defaults", () => {
|
||||||
|
const result = CreateResourceSchema.parse(minimalResource);
|
||||||
|
expect(result.eid).toBe("EMP-001");
|
||||||
|
expect(result.currency).toBe("EUR");
|
||||||
|
expect(result.chargeabilityTarget).toBe(80);
|
||||||
|
expect(result.availability).toEqual({
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
});
|
||||||
|
expect(result.skills).toEqual([]);
|
||||||
|
expect(result.dynamicFields).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full resource with all optional fields populated", () => {
|
||||||
|
const result = CreateResourceSchema.parse({
|
||||||
|
...minimalResource,
|
||||||
|
chapter: "3D Animation",
|
||||||
|
currency: "USD",
|
||||||
|
chargeabilityTarget: 75,
|
||||||
|
availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 },
|
||||||
|
skills: [{ skill: "Maya", proficiency: 4 }],
|
||||||
|
dynamicFields: { department: "VFX" },
|
||||||
|
blueprintId: "bp-1",
|
||||||
|
portfolioUrl: "https://portfolio.example.com",
|
||||||
|
roleId: "role-42",
|
||||||
|
postalCode: "80331",
|
||||||
|
federalState: "BY",
|
||||||
|
countryId: "country-de",
|
||||||
|
metroCityId: "metro-muc",
|
||||||
|
orgUnitId: "ou-1",
|
||||||
|
managementLevelGroupId: "mlg-1",
|
||||||
|
managementLevelId: "ml-2",
|
||||||
|
resourceType: ResourceType.EMPLOYEE,
|
||||||
|
chgResponsibility: false,
|
||||||
|
rolledOff: false,
|
||||||
|
departed: false,
|
||||||
|
enterpriseId: "ENT-999",
|
||||||
|
clientUnitId: "cu-5",
|
||||||
|
fte: 1,
|
||||||
|
});
|
||||||
|
expect(result.chapter).toBe("3D Animation");
|
||||||
|
expect(result.currency).toBe("USD");
|
||||||
|
expect(result.resourceType).toBe(ResourceType.EMPLOYEE);
|
||||||
|
expect(result.skills).toHaveLength(1);
|
||||||
|
expect(result.fte).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies default availability when omitted", () => {
|
||||||
|
const result = CreateResourceSchema.parse(minimalResource);
|
||||||
|
const days = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
|
||||||
|
for (const day of days) {
|
||||||
|
expect(result.availability[day]).toBe(8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid email address", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateResourceSchema.parse({ ...minimalResource, email: "not-an-email" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects eid longer than 50 characters", () => {
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, eid: "X".repeat(51) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative lcrCents", () => {
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, lcrCents: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer rate values", () => {
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, lcrCents: 60.5 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects chargeabilityTarget outside 0–100", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateResourceSchema.parse({ ...minimalResource, chargeabilityTarget: 101 }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
CreateResourceSchema.parse({ ...minimalResource, chargeabilityTarget: -1 }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects currency not exactly 3 characters", () => {
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, currency: "EURO" })).toThrow();
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, currency: "EU" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid resourceType enum value", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateResourceSchema.parse({ ...minimalResource, resourceType: "CONSULTANT" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects fte below 0.01 or above 1", () => {
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, fte: 0 })).toThrow();
|
||||||
|
expect(() => CreateResourceSchema.parse({ ...minimalResource, fte: 1.01 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty string as portfolioUrl", () => {
|
||||||
|
const result = CreateResourceSchema.parse({
|
||||||
|
...minimalResource,
|
||||||
|
portfolioUrl: "",
|
||||||
|
});
|
||||||
|
expect(result.portfolioUrl).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects portfolioUrl that is neither a valid URL nor empty string", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateResourceSchema.parse({ ...minimalResource, portfolioUrl: "not-a-url" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all ResourceType enum values", () => {
|
||||||
|
const types = [
|
||||||
|
ResourceType.EMPLOYEE,
|
||||||
|
ResourceType.FREELANCER,
|
||||||
|
ResourceType.APPRENTICE,
|
||||||
|
ResourceType.INTERN,
|
||||||
|
ResourceType.STUDENT,
|
||||||
|
];
|
||||||
|
for (const resourceType of types) {
|
||||||
|
const result = CreateResourceSchema.parse({ ...minimalResource, resourceType });
|
||||||
|
expect(result.resourceType).toBe(resourceType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateResourceSchema ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateResourceSchema", () => {
|
||||||
|
it("accepts empty object (all fields optional)", () => {
|
||||||
|
const result = UpdateResourceSchema.parse({});
|
||||||
|
expect(result.eid).toBeUndefined();
|
||||||
|
expect(result.displayName).toBeUndefined();
|
||||||
|
expect(result.isActive).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only displayName", () => {
|
||||||
|
const result = UpdateResourceSchema.parse({ displayName: "Updated Name" });
|
||||||
|
expect(result.displayName).toBe("Updated Name");
|
||||||
|
expect(result.email).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts isActive flag not present in CreateResourceSchema", () => {
|
||||||
|
const result = UpdateResourceSchema.parse({ isActive: false });
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still validates constrained fields when provided", () => {
|
||||||
|
expect(() => UpdateResourceSchema.parse({ email: "bad-email" })).toThrow();
|
||||||
|
expect(() => UpdateResourceSchema.parse({ chargeabilityTarget: 200 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateClientSchema ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateClientSchema", () => {
|
||||||
|
it("accepts minimal valid client and applies defaults", () => {
|
||||||
|
const result = CreateClientSchema.parse({ name: "Acme Corp" });
|
||||||
|
expect(result.name).toBe("Acme Corp");
|
||||||
|
expect(result.sortOrder).toBe(0);
|
||||||
|
expect(result.code).toBeUndefined();
|
||||||
|
expect(result.parentId).toBeUndefined();
|
||||||
|
expect(result.tags).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full client input with all optional fields", () => {
|
||||||
|
const result = CreateClientSchema.parse({
|
||||||
|
name: "Global Media GmbH",
|
||||||
|
code: "GM-001",
|
||||||
|
parentId: "parent-42",
|
||||||
|
sortOrder: 5,
|
||||||
|
tags: ["vip", "premium"],
|
||||||
|
});
|
||||||
|
expect(result.code).toBe("GM-001");
|
||||||
|
expect(result.parentId).toBe("parent-42");
|
||||||
|
expect(result.sortOrder).toBe(5);
|
||||||
|
expect(result.tags).toEqual(["vip", "premium"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() => CreateClientSchema.parse({ name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 300 characters", () => {
|
||||||
|
expect(() => CreateClientSchema.parse({ name: "A".repeat(301) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tag exceeding 50 characters", () => {
|
||||||
|
expect(() => CreateClientSchema.parse({ name: "Client", tags: ["X".repeat(51)] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer sortOrder", () => {
|
||||||
|
expect(() => CreateClientSchema.parse({ name: "Client", sortOrder: 1.5 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects code longer than 50 characters", () => {
|
||||||
|
expect(() => CreateClientSchema.parse({ name: "Client", code: "C".repeat(51) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateClientSchema ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateClientSchema", () => {
|
||||||
|
it("accepts empty update object (all fields optional)", () => {
|
||||||
|
const result = UpdateClientSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.isActive).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable code (clearing the code)", () => {
|
||||||
|
const result = UpdateClientSchema.parse({ code: null });
|
||||||
|
expect(result.code).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts nullable parentId (detaching from parent)", () => {
|
||||||
|
const result = UpdateClientSchema.parse({ parentId: null });
|
||||||
|
expect(result.parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts isActive flag", () => {
|
||||||
|
const result = UpdateClientSchema.parse({ isActive: false });
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still validates name length when provided", () => {
|
||||||
|
expect(() => UpdateClientSchema.parse({ name: "" })).toThrow();
|
||||||
|
expect(() => UpdateClientSchema.parse({ name: "N".repeat(301) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CreateRoleSchema ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateRoleSchema", () => {
|
||||||
|
it("accepts minimal valid role (name only)", () => {
|
||||||
|
const result = CreateRoleSchema.parse({ name: "3D Artist" });
|
||||||
|
expect(result.name).toBe("3D Artist");
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
expect(result.color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full role with description and color", () => {
|
||||||
|
const result = CreateRoleSchema.parse({
|
||||||
|
name: "Lead Animator",
|
||||||
|
description: "Responsible for animation direction and team leadership.",
|
||||||
|
color: "#FF5733",
|
||||||
|
});
|
||||||
|
expect(result.description).toBe("Responsible for animation direction and team leadership.");
|
||||||
|
expect(result.color).toBe("#FF5733");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts lowercase hex color", () => {
|
||||||
|
const result = CreateRoleSchema.parse({ name: "VFX Artist", color: "#a1b2c3" });
|
||||||
|
expect(result.color).toBe("#a1b2c3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 100 characters", () => {
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "R".repeat(101) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects description longer than 500 characters", () => {
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "Role", description: "D".repeat(501) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid hex color (missing hash)", () => {
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "Role", color: "FF5733" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid hex color (wrong length)", () => {
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "Role", color: "#FFF" })).toThrow();
|
||||||
|
expect(() => CreateRoleSchema.parse({ name: "Role", color: "#FF57330" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── UpdateRoleSchema ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("UpdateRoleSchema", () => {
|
||||||
|
it("accepts empty update object (all fields optional)", () => {
|
||||||
|
const result = UpdateRoleSchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
expect(result.color).toBeUndefined();
|
||||||
|
expect(result.isActive).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only name", () => {
|
||||||
|
const result = UpdateRoleSchema.parse({ name: "Senior 3D Artist" });
|
||||||
|
expect(result.name).toBe("Senior 3D Artist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts isActive flag not present in CreateRoleSchema", () => {
|
||||||
|
const result = UpdateRoleSchema.parse({ isActive: true });
|
||||||
|
expect(result.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still validates color format when provided", () => {
|
||||||
|
expect(() => UpdateRoleSchema.parse({ color: "invalid-color" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ResourceRoleSchema ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("ResourceRoleSchema", () => {
|
||||||
|
it("accepts minimal input and applies isPrimary default", () => {
|
||||||
|
const result = ResourceRoleSchema.parse({ roleId: "role-1" });
|
||||||
|
expect(result.roleId).toBe("role-1");
|
||||||
|
expect(result.isPrimary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts explicit isPrimary true", () => {
|
||||||
|
const result = ResourceRoleSchema.parse({ roleId: "role-99", isPrimary: true });
|
||||||
|
expect(result.isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing roleId", () => {
|
||||||
|
expect(() => ResourceRoleSchema.parse({})).toThrow();
|
||||||
|
expect(() => ResourceRoleSchema.parse({ isPrimary: true })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-boolean isPrimary", () => {
|
||||||
|
expect(() => ResourceRoleSchema.parse({ roleId: "role-1", isPrimary: "yes" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CreateVacationSchema, UpdateVacationStatusSchema } from "../schemas/vacation.schema.js";
|
||||||
|
import {
|
||||||
|
CreateProjectSchema,
|
||||||
|
UpdateProjectSchema,
|
||||||
|
StaffingRequirementSchema,
|
||||||
|
} from "../schemas/project.schema.js";
|
||||||
|
import {
|
||||||
|
CreateCountrySchema,
|
||||||
|
UpdateCountrySchema,
|
||||||
|
CreateMetroCitySchema,
|
||||||
|
UpdateMetroCitySchema,
|
||||||
|
SpainScheduleRuleSchema,
|
||||||
|
} from "../schemas/country.schema.js";
|
||||||
|
import { VacationType, OrderType, AllocationType, ProjectStatus } from "../types/enums.js";
|
||||||
|
|
||||||
|
// ─── VacationSchema ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CreateVacationSchema", () => {
|
||||||
|
const validMinimal = {
|
||||||
|
resourceId: "res-1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: "2026-06-01",
|
||||||
|
endDate: "2026-06-07",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal valid input and coerces dates", () => {
|
||||||
|
const result = CreateVacationSchema.parse(validMinimal);
|
||||||
|
expect(result.resourceId).toBe("res-1");
|
||||||
|
expect(result.type).toBe(VacationType.ANNUAL);
|
||||||
|
expect(result.startDate).toBeInstanceOf(Date);
|
||||||
|
expect(result.endDate).toBeInstanceOf(Date);
|
||||||
|
expect(result.note).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all VacationType enum values", () => {
|
||||||
|
for (const type of Object.values(VacationType)) {
|
||||||
|
const result = CreateVacationSchema.parse({ ...validMinimal, type });
|
||||||
|
expect(result.type).toBe(type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full valid input with optional note", () => {
|
||||||
|
const result = CreateVacationSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
type: VacationType.SICK,
|
||||||
|
note: "Medical certificate attached",
|
||||||
|
});
|
||||||
|
expect(result.type).toBe(VacationType.SICK);
|
||||||
|
expect(result.note).toBe("Medical certificate attached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts same-day vacation (end === start is valid)", () => {
|
||||||
|
const result = CreateVacationSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
startDate: "2026-08-15",
|
||||||
|
endDate: "2026-08-15",
|
||||||
|
});
|
||||||
|
expect(result.startDate).toEqual(result.endDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects end date before start date", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateVacationSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
startDate: "2026-06-07",
|
||||||
|
endDate: "2026-06-01",
|
||||||
|
}),
|
||||||
|
).toThrow("End date must be after start date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid VacationType value", () => {
|
||||||
|
expect(() => CreateVacationSchema.parse({ ...validMinimal, type: "WEEKEND" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects note exceeding 500 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateVacationSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
note: "x".repeat(501),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts note at exactly 500 characters", () => {
|
||||||
|
const result = CreateVacationSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
note: "x".repeat(500),
|
||||||
|
});
|
||||||
|
expect(result.note).toHaveLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing resourceId", () => {
|
||||||
|
const { resourceId: _, ...withoutResourceId } = validMinimal;
|
||||||
|
expect(() => CreateVacationSchema.parse(withoutResourceId)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateVacationStatusSchema", () => {
|
||||||
|
it("accepts valid APPROVED status", () => {
|
||||||
|
const result = UpdateVacationStatusSchema.parse({
|
||||||
|
id: "vac-1",
|
||||||
|
status: "APPROVED",
|
||||||
|
});
|
||||||
|
expect(result.id).toBe("vac-1");
|
||||||
|
expect(result.status).toBe("APPROVED");
|
||||||
|
expect(result.note).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid status values", () => {
|
||||||
|
for (const status of ["APPROVED", "REJECTED", "CANCELLED"] as const) {
|
||||||
|
const result = UpdateVacationStatusSchema.parse({ id: "vac-1", status });
|
||||||
|
expect(result.status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts optional note with status", () => {
|
||||||
|
const result = UpdateVacationStatusSchema.parse({
|
||||||
|
id: "vac-42",
|
||||||
|
status: "REJECTED",
|
||||||
|
note: "Insufficient notice period",
|
||||||
|
});
|
||||||
|
expect(result.note).toBe("Insufficient notice period");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid status value", () => {
|
||||||
|
expect(() => UpdateVacationStatusSchema.parse({ id: "vac-1", status: "PENDING" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects note exceeding 500 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
UpdateVacationStatusSchema.parse({
|
||||||
|
id: "vac-1",
|
||||||
|
status: "CANCELLED",
|
||||||
|
note: "y".repeat(501),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing id", () => {
|
||||||
|
expect(() => UpdateVacationStatusSchema.parse({ status: "APPROVED" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ProjectSchema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("StaffingRequirementSchema", () => {
|
||||||
|
const validReq = {
|
||||||
|
role: "3D Artist",
|
||||||
|
requiredSkills: ["Houdini", "Nuke"],
|
||||||
|
hoursPerDay: 8,
|
||||||
|
headcount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal valid input and generates a uuid id", () => {
|
||||||
|
const result = StaffingRequirementSchema.parse(validReq);
|
||||||
|
expect(result.role).toBe("3D Artist");
|
||||||
|
expect(result.requiredSkills).toEqual(["Houdini", "Nuke"]);
|
||||||
|
expect(result.hoursPerDay).toBe(8);
|
||||||
|
expect(result.headcount).toBe(2);
|
||||||
|
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input with all optional fields", () => {
|
||||||
|
const result = StaffingRequirementSchema.parse({
|
||||||
|
...validReq,
|
||||||
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
|
preferredSkills: ["Maya"],
|
||||||
|
startDate: "2026-05-01",
|
||||||
|
endDate: "2026-08-31",
|
||||||
|
notes: "Experienced in film production",
|
||||||
|
chapter: "3D",
|
||||||
|
});
|
||||||
|
expect(result.id).toBe("11111111-1111-1111-1111-111111111111");
|
||||||
|
expect(result.preferredSkills).toEqual(["Maya"]);
|
||||||
|
expect(result.chapter).toBe("3D");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty role string", () => {
|
||||||
|
expect(() => StaffingRequirementSchema.parse({ ...validReq, role: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hoursPerDay above 24", () => {
|
||||||
|
expect(() => StaffingRequirementSchema.parse({ ...validReq, hoursPerDay: 24.1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects headcount below 1", () => {
|
||||||
|
expect(() => StaffingRequirementSchema.parse({ ...validReq, headcount: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer headcount", () => {
|
||||||
|
expect(() => StaffingRequirementSchema.parse({ ...validReq, headcount: 1.5 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateProjectSchema", () => {
|
||||||
|
const validMinimal = {
|
||||||
|
shortCode: "PROJ-001",
|
||||||
|
name: "Hero Sequence VFX",
|
||||||
|
orderType: OrderType.CHARGEABLE,
|
||||||
|
allocationType: AllocationType.INT,
|
||||||
|
budgetCents: 500_000_00,
|
||||||
|
startDate: "2026-05-01",
|
||||||
|
endDate: "2026-09-30",
|
||||||
|
responsiblePerson: "Jane Doe",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal valid input with expected defaults", () => {
|
||||||
|
const result = CreateProjectSchema.parse(validMinimal);
|
||||||
|
expect(result.shortCode).toBe("PROJ-001");
|
||||||
|
expect(result.name).toBe("Hero Sequence VFX");
|
||||||
|
expect(result.orderType).toBe(OrderType.CHARGEABLE);
|
||||||
|
expect(result.allocationType).toBe(AllocationType.INT);
|
||||||
|
expect(result.winProbability).toBe(100);
|
||||||
|
expect(result.status).toBe(ProjectStatus.DRAFT);
|
||||||
|
expect(result.staffingReqs).toEqual([]);
|
||||||
|
expect(result.dynamicFields).toEqual({});
|
||||||
|
expect(result.startDate).toBeInstanceOf(Date);
|
||||||
|
expect(result.endDate).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full valid input with all optional fields", () => {
|
||||||
|
const result = CreateProjectSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
winProbability: 75,
|
||||||
|
staffingReqs: [
|
||||||
|
{
|
||||||
|
role: "Compositor",
|
||||||
|
requiredSkills: ["Nuke"],
|
||||||
|
hoursPerDay: 8,
|
||||||
|
headcount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dynamicFields: { clientContact: "John Smith" },
|
||||||
|
blueprintId: "bp-1",
|
||||||
|
status: ProjectStatus.ACTIVE,
|
||||||
|
color: "#3b82f6",
|
||||||
|
utilizationCategoryId: "uc-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
shoringThreshold: 20,
|
||||||
|
onshoreCountryCode: "DE",
|
||||||
|
});
|
||||||
|
expect(result.winProbability).toBe(75);
|
||||||
|
expect(result.staffingReqs).toHaveLength(1);
|
||||||
|
expect(result.color).toBe("#3b82f6");
|
||||||
|
expect(result.shoringThreshold).toBe(20);
|
||||||
|
expect(result.onshoreCountryCode).toBe("DE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects end date before start date", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateProjectSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
startDate: "2026-09-30",
|
||||||
|
endDate: "2026-05-01",
|
||||||
|
}),
|
||||||
|
).toThrow("End date must be after start date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shortCode with lowercase letters", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, shortCode: "proj-001" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shortCode with spaces", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, shortCode: "PROJ 001" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects shortCode longer than 20 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateProjectSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
shortCode: "A".repeat(21),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects winProbability above 100", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, winProbability: 101 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative budgetCents", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, budgetCents: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid hex color format", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, color: "blue" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid OrderType", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateProjectSchema.parse({ ...validMinimal, orderType: "FIXED_PRICE" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty responsiblePerson", () => {
|
||||||
|
expect(() => CreateProjectSchema.parse({ ...validMinimal, responsiblePerson: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts same-day project (start === end)", () => {
|
||||||
|
const result = CreateProjectSchema.parse({
|
||||||
|
...validMinimal,
|
||||||
|
startDate: "2026-07-01",
|
||||||
|
endDate: "2026-07-01",
|
||||||
|
});
|
||||||
|
expect(result.startDate).toEqual(result.endDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateProjectSchema", () => {
|
||||||
|
it("accepts empty object (all fields optional)", () => {
|
||||||
|
const result = UpdateProjectSchema.parse({});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only name", () => {
|
||||||
|
const result = UpdateProjectSchema.parse({ name: "Renamed Project" });
|
||||||
|
expect(result.name).toBe("Renamed Project");
|
||||||
|
expect(result.shortCode).toBeUndefined();
|
||||||
|
expect(result.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update changing status", () => {
|
||||||
|
const result = UpdateProjectSchema.parse({ status: ProjectStatus.ON_HOLD });
|
||||||
|
expect(result.status).toBe(ProjectStatus.ON_HOLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid shortCode even in partial update", () => {
|
||||||
|
expect(() => UpdateProjectSchema.parse({ shortCode: "invalid code!" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT enforce date-range order (no refine on UpdateProjectSchema)", () => {
|
||||||
|
// UpdateProjectSchema is a plain partial — no cross-field refinement
|
||||||
|
expect(() =>
|
||||||
|
UpdateProjectSchema.parse({
|
||||||
|
startDate: "2026-12-31",
|
||||||
|
endDate: "2026-01-01",
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CountrySchema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("SpainScheduleRuleSchema", () => {
|
||||||
|
const validSpainRule = {
|
||||||
|
type: "spain" as const,
|
||||||
|
fridayHours: 6,
|
||||||
|
summerPeriod: { from: "06-15", to: "09-15" },
|
||||||
|
summerHours: 7,
|
||||||
|
regularHours: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts valid Spain schedule rule", () => {
|
||||||
|
const result = SpainScheduleRuleSchema.parse(validSpainRule);
|
||||||
|
expect(result.type).toBe("spain");
|
||||||
|
expect(result.fridayHours).toBe(6);
|
||||||
|
expect(result.summerPeriod.from).toBe("06-15");
|
||||||
|
expect(result.regularHours).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong type literal", () => {
|
||||||
|
expect(() => SpainScheduleRuleSchema.parse({ ...validSpainRule, type: "germany" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid summerPeriod date format (wrong separator)", () => {
|
||||||
|
expect(() =>
|
||||||
|
SpainScheduleRuleSchema.parse({
|
||||||
|
...validSpainRule,
|
||||||
|
summerPeriod: { from: "2026-06-15", to: "09-15" },
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-positive fridayHours", () => {
|
||||||
|
expect(() => SpainScheduleRuleSchema.parse({ ...validSpainRule, fridayHours: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-positive summerHours", () => {
|
||||||
|
expect(() => SpainScheduleRuleSchema.parse({ ...validSpainRule, summerHours: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateCountrySchema", () => {
|
||||||
|
const validMinimal = {
|
||||||
|
code: "de",
|
||||||
|
name: "Germany",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts minimal input and uppercases code, applies default hours", () => {
|
||||||
|
const result = CreateCountrySchema.parse(validMinimal);
|
||||||
|
expect(result.code).toBe("DE");
|
||||||
|
expect(result.name).toBe("Germany");
|
||||||
|
expect(result.dailyWorkingHours).toBe(8);
|
||||||
|
expect(result.scheduleRules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 3-letter country code and uppercases it", () => {
|
||||||
|
const result = CreateCountrySchema.parse({ code: "deu", name: "Germany" });
|
||||||
|
expect(result.code).toBe("DEU");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts full input with custom hours and Spain schedule rules", () => {
|
||||||
|
const result = CreateCountrySchema.parse({
|
||||||
|
code: "ES",
|
||||||
|
name: "Spain",
|
||||||
|
dailyWorkingHours: 7.5,
|
||||||
|
scheduleRules: {
|
||||||
|
type: "spain",
|
||||||
|
fridayHours: 6,
|
||||||
|
summerPeriod: { from: "06-15", to: "09-15" },
|
||||||
|
summerHours: 7,
|
||||||
|
regularHours: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.code).toBe("ES");
|
||||||
|
expect(result.dailyWorkingHours).toBe(7.5);
|
||||||
|
expect(result.scheduleRules?.type).toBe("spain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts null scheduleRules explicitly", () => {
|
||||||
|
const result = CreateCountrySchema.parse({ ...validMinimal, scheduleRules: null });
|
||||||
|
expect(result.scheduleRules).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects country code shorter than 2 characters", () => {
|
||||||
|
expect(() => CreateCountrySchema.parse({ code: "D", name: "Germany" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects country code longer than 3 characters", () => {
|
||||||
|
expect(() => CreateCountrySchema.parse({ code: "DEUR", name: "Germany" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dailyWorkingHours above 24", () => {
|
||||||
|
expect(() => CreateCountrySchema.parse({ ...validMinimal, dailyWorkingHours: 25 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-positive dailyWorkingHours", () => {
|
||||||
|
expect(() => CreateCountrySchema.parse({ ...validMinimal, dailyWorkingHours: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() => CreateCountrySchema.parse({ code: "DE", name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateCountrySchema", () => {
|
||||||
|
it("accepts empty object (all fields optional)", () => {
|
||||||
|
const result = UpdateCountrySchema.parse({});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with isActive flag", () => {
|
||||||
|
const result = UpdateCountrySchema.parse({ isActive: false });
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts partial update with only name", () => {
|
||||||
|
const result = UpdateCountrySchema.parse({ name: "Deutschland" });
|
||||||
|
expect(result.name).toBe("Deutschland");
|
||||||
|
expect(result.isActive).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uppercases code in partial update", () => {
|
||||||
|
const result = UpdateCountrySchema.parse({ code: "fr" });
|
||||||
|
expect(result.code).toBe("FR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid dailyWorkingHours in partial update", () => {
|
||||||
|
expect(() => UpdateCountrySchema.parse({ dailyWorkingHours: 30 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateMetroCitySchema", () => {
|
||||||
|
it("accepts valid input", () => {
|
||||||
|
const result = CreateMetroCitySchema.parse({
|
||||||
|
name: "Munich",
|
||||||
|
countryId: "country-1",
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("Munich");
|
||||||
|
expect(result.countryId).toBe("country-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(() => CreateMetroCitySchema.parse({ name: "", countryId: "country-1" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 100 characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateMetroCitySchema.parse({
|
||||||
|
name: "A".repeat(101),
|
||||||
|
countryId: "country-1",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts name at exactly 100 characters", () => {
|
||||||
|
const result = CreateMetroCitySchema.parse({
|
||||||
|
name: "A".repeat(100),
|
||||||
|
countryId: "country-1",
|
||||||
|
});
|
||||||
|
expect(result.name).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing countryId", () => {
|
||||||
|
expect(() => CreateMetroCitySchema.parse({ name: "Munich" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateMetroCitySchema", () => {
|
||||||
|
it("accepts empty object (name is optional)", () => {
|
||||||
|
const result = UpdateMetroCitySchema.parse({});
|
||||||
|
expect(result.name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid name update", () => {
|
||||||
|
const result = UpdateMetroCitySchema.parse({ name: "Berlin" });
|
||||||
|
expect(result.name).toBe("Berlin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty string for name", () => {
|
||||||
|
expect(() => UpdateMetroCitySchema.parse({ name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects name longer than 100 characters", () => {
|
||||||
|
expect(() => UpdateMetroCitySchema.parse({ name: "B".repeat(101) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user