From d0926601ea95c6760e84dcb82ad323cacf67981e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 15:47:53 +0200 Subject: [PATCH] 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 --- .../schema-blueprint-orgunit-misc.test.ts | 844 ++++++++++++++++++ .../schema-resource-client-role.test.ts | 467 ++++++++++ .../schema-vacation-project-country.test.ts | 528 +++++++++++ 3 files changed, 1839 insertions(+) create mode 100644 packages/shared/src/__tests__/schema-blueprint-orgunit-misc.test.ts create mode 100644 packages/shared/src/__tests__/schema-resource-client-role.test.ts create mode 100644 packages/shared/src/__tests__/schema-vacation-project-country.test.ts diff --git a/packages/shared/src/__tests__/schema-blueprint-orgunit-misc.test.ts b/packages/shared/src/__tests__/schema-blueprint-orgunit-misc.test.ts new file mode 100644 index 0000000..fe81ed7 --- /dev/null +++ b/packages/shared/src/__tests__/schema-blueprint-orgunit-misc.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/__tests__/schema-resource-client-role.test.ts b/packages/shared/src/__tests__/schema-resource-client-role.test.ts new file mode 100644 index 0000000..48bb755 --- /dev/null +++ b/packages/shared/src/__tests__/schema-resource-client-role.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/__tests__/schema-vacation-project-country.test.ts b/packages/shared/src/__tests__/schema-vacation-project-country.test.ts new file mode 100644 index 0000000..2fc6545 --- /dev/null +++ b/packages/shared/src/__tests__/schema-vacation-project-country.test.ts @@ -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(); + }); +});