d0926601ea
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>
468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
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();
|
||
});
|
||
});
|