UNPKG

model-validator-ts

Version:

[![npm version](https://img.shields.io/npm/v/model-validator-ts.svg)](https://www.npmjs.com/package/model-validator-ts)

669 lines 27.9 kB
import { test, expect, describe, assert, expectTypeOf } from "vitest"; import { z } from "zod"; import { buildValidator } from "./index.js"; describe("Schema Validation", () => { test("schema validation works with basic types", async () => { const schema = z.object({ name: z.string().min(3), age: z.number().min(18), }); const validator = buildValidator().input(schema); // Test with invalid input (name too short) const result1 = await validator.validate({ name: "ab", age: 20, }); assert(!result1.success); expect(result1.errors.firstError("name")).toContain("3"); // Test with invalid input (age too low) const result2 = await validator.validate({ name: "John", age: 17, }); assert(!result2.success); expect(result2.errors.firstError("age")).toContain("18"); // Test with valid input const result3 = await validator.validate({ name: "John", age: 25, }); assert(result3.success); expect(result3.value).toEqual({ name: "John", age: 25, }); }); }); describe("Validator dependenceis", () => { const userRegistrationSchema = z.object({ email: z.string().email(), name: z.string().min(2), age: z.number().min(18), }); test("Validator with no deps can be called with validate", async () => { const validator = buildValidator().input(userRegistrationSchema); expect(validator["~unsafeInternals"]).toMatchObject({ contextRules: expect.any(Array), schema: userRegistrationSchema, deps: undefined, depsStatus: "not-required", }); const result = await validator.validate({ email: "john@example.com", name: "John Doe", age: 25, }); assert(result.success); expect(result.value).toEqual({ email: "john@example.com", name: "John Doe", age: 25, }); }); test("Validator with deps can only be called after providing deps", async () => { const validator = buildValidator().input(userRegistrationSchema).$deps(); expect(validator["~unsafeInternals"]).toMatchObject({ schema: userRegistrationSchema, deps: undefined, depsStatus: "required", }); expect(() => // @ts-expect-error: Validate is not available at the type level, this is correct // but we also want to test runtime behavior and that's an error being thrown because // the method is actually there but should not be called validator.validate({ email: "john@example.com", name: "John Doe", age: 25, })).toThrow("Deps should be provided before calling validate"); }); test("Validator with deps can be called after providing deps", async () => { const validator = buildValidator().input(userRegistrationSchema).$deps(); expect(validator["~unsafeInternals"]).toMatchObject({ schema: userRegistrationSchema, deps: undefined, depsStatus: "required", }); const deps = { fakeService: { foo: "bar" } }; const validatorWithDeps = validator.provide(deps); expect(validatorWithDeps["~unsafeInternals"]).toMatchObject({ schema: userRegistrationSchema, deps, depsStatus: "passed", }); const result = await validatorWithDeps.validate({ email: "john@example.com", name: "John Doe", age: 25, }); assert(result.success); expect(result.value).toEqual({ email: "john@example.com", name: "John Doe", age: 25, }); }); }); describe("Performance", () => { test("should reuse the same instance when chaining methods", () => { const validator = buildValidator(); const withInput = validator.input(z.object({ test: z.string() })); const withDeps = withInput.$deps(); const withRule = withDeps.rule({ fn: () => { }, }); const withProvide = withDeps.provide({ service: "test" }); // All should be the same underlying object instance expect(validator).toBe(withInput); expect(validator).toBe(withDeps); expect(validator).toBe(withRule); expect(validator).toBe(withProvide); }); }); describe("Context Passing & Rule Chain", () => { test("can pass context between rules", async () => { const schema = z.object({ hello: z.string().min(2), name: z.string().min(2), age: z.number(), }); const validator = buildValidator() .input(schema) .rule({ fn: async (args) => { return { context: { message: `Hello ${args.data.name}` } }; }, }) .rule({ fn: async (args) => { return { context: { message: `${args.context.message}. You are ${args.data.age >= 18 ? "an adult" : "a minor"}`, }, }; }, }) .rule({ fn: async (args) => { return { context: { isAdult: args.data.age >= 18, }, }; }, }) .rule({ fn: async (args) => { expect(args.context).toEqual({ message: "Hello John Doe. You are an adult", isAdult: true, }); }, }); const result = await validator.validate({ hello: "Hello", name: "John Doe", age: 25, }); assert(result.success); expect(result.context).toEqual({ message: "Hello John Doe. You are an adult", isAdult: true, }); }); }); describe("Command API", () => { const paymentSchema = z.object({ sourceAccount: z.string().min(2), targetAccount: z.string().min(2), amount: z.number().min(1), }); const paymentService = { executeTransfer: async (sourceAccount, targetAccount, amount) => { if (sourceAccount === "blacklisted") { throw new Error("Source account is blacklisted"); } if (targetAccount === "blacklisted") { throw new Error("Target account is blacklisted"); } return { success: true, transactionId: "123" }; }, getAccountBalance: async (account) => { if (account === "no-funds") { return { balance: 0 }; } return { balance: 1000 }; }, }; const validator = buildValidator() .input(paymentSchema) .rule({ fn: async (args) => { const balance = await paymentService.getAccountBalance(args.data.sourceAccount); if (balance.balance < args.data.amount) { args.bag.addError("amount", "Insufficient funds"); } }, }); test("Can validate and execute a command", async () => { expect(validator["~unsafeInternals"]).toMatchObject({ schema: paymentSchema, deps: undefined, depsStatus: "not-required", }); const command = validator.command({ execute: async (args) => { return await paymentService .executeTransfer(args.data.sourceAccount, args.data.targetAccount, args.data.amount) .catch((error) => { args.bag.addGlobalError(error instanceof Error ? error.message : "Unknown error"); }); }, }); const result = await command.run({ sourceAccount: "123", targetAccount: "456", amount: 100, }); assert(result.success); expect(result.result).toEqual({ success: true, transactionId: "123", }); const result2 = await command.run({ sourceAccount: "blacklisted", targetAccount: "456", amount: 100, }); assert(!result2.success); expect(result2.errors.global).toBe("Source account is blacklisted"); }); test("Command deps tracks if they have been provided", async () => { const command = buildValidator() .input(z.object({ name: z.string().min(1) })) .$deps() .command({ execute: async (args) => { return { data: args.data, foo: args.deps.fakeService.foo, }; }, }); // Tracked at the type level expectTypeOf(command.run).toBeNever(); expectTypeOf(command.runShape).toBeNever(); // Without providing deps, the command should throw an error await expect( // @ts-expect-error: Method is still there, just should not be called async () => command.run({ name: "John Doe" })).rejects.toThrow("Deps should be provided before calling run"); // runShape should also not be available without providing deps await expect( // @ts-expect-error: Method is still there, just should not be called async () => command.runShape({ name: "John Doe" })).rejects.toThrow("Deps should be provided before calling runShape"); const commandWithDeps = command.provide({ fakeService: { foo: "bar", bar: "foo" }, }); expectTypeOf(commandWithDeps.run).not.toBeNever(); expectTypeOf(commandWithDeps.runShape).not.toBeNever(); const result = await commandWithDeps.run({ name: "John Doe" }); assert(result.success); expect(result.result).toMatchObject({ data: { name: "John Doe" }, foo: "bar", }); const result2 = await commandWithDeps.runShape({ name: "John Doe" }); assert(result2.success); expect(result2.result).toMatchObject({ data: { name: "John Doe" }, foo: "bar", }); }); }); describe("Real-world Validation Examples", () => { const userRepository = { users: [ { id: "user-123", email: "existing@example.com", createdAt: new Date() }, { id: "user-456", email: "newemail@example.com", createdAt: new Date() }, ], blacklistedDomains: ["spam.com", "blocked.net"], blacklistedEmails: ["admin@badactor.com"], findUserByEmail: async (email) => { return userRepository.users.find((user) => user.email === email) || null; }, findUserById: async (id) => { return userRepository.users.find((user) => user.id === id) || null; }, isEmailBlacklisted: async (email) => { if (userRepository.blacklistedEmails.includes(email)) return true; const domain = email.split("@")[1]; if (!domain) return false; return userRepository.blacklistedDomains.includes(domain); }, changeEmail: async (userId, newEmail) => { const user = await userRepository.findUserById(userId); if (!user) throw new Error("User not found"); user.email = newEmail; return user; }, createUser: async (userData) => { const newUser = { id: `user-${userRepository.users.length + 1}`, ...userData, createdAt: new Date(), }; userRepository.users.push(newUser); return newUser; }, }; describe("User Registration", () => { const userRegistrationSchema = z.object({ email: z.string().email(), name: z.string().min(2), age: z.number().min(18), }); test("should detect duplicate email during validation", async () => { const userRegistrationValidator = buildValidator() .input(userRegistrationSchema) .$deps() .rule({ id: "duplicate-email-check", description: "Check for duplicate email", fn: async (args) => { const existingUser = await args.deps.userRepository.findUserByEmail(args.data.email); if (existingUser) { args.bag.addError("email", "Email already exists"); } }, }) .provide({ userRepository }); // Test with existing email const result1 = await userRegistrationValidator.validate({ email: "existing@example.com", name: "John Doe", age: 25, }); assert(!result1.success); expect(result1.errors.firstError("email")).toBe("Email already exists"); expect(result1.rule?.id).toBe("duplicate-email-check"); expect(result1.rule?.description).toBe("Check for duplicate email"); // Test with new email const result2 = await userRegistrationValidator.validate({ email: "new@example.com", name: "Jane Doe", age: 30, }); assert(result2.success); expect(result2.value.email).toBe("new@example.com"); }); test("should detect blacklisted email during validation", async () => { const userRegistrationValidator = buildValidator() .input(userRegistrationSchema) .$deps() .rule({ id: "blacklist-check", description: "Check for blacklisted email", fn: async (args) => { const isBlacklisted = await args.deps.userRepository.isEmailBlacklisted(args.data.email); if (isBlacklisted) { args.bag.addError("email", "Email domain is not allowed"); } }, }) .provide({ userRepository }); // Test with blacklisted domain const result1 = await userRegistrationValidator.validate({ email: "user@spam.com", name: "John Doe", age: 25, }); assert(!result1.success); expect(result1.errors.firstError("email")).toBe("Email domain is not allowed"); expect(result1.rule?.id).toBe("blacklist-check"); expect(result1.rule?.description).toBe("Check for blacklisted email"); // Test with blacklisted specific email const result2 = await userRegistrationValidator.validate({ email: "admin@badactor.com", name: "Jane Doe", age: 30, }); assert(!result2.success); expect(result2.errors.firstError("email")).toBe("Email domain is not allowed"); // Test with allowed email const result3 = await userRegistrationValidator.validate({ email: "user@gooddomain.com", name: "Bob Smith", age: 28, }); assert(result3.success); expect(result3.value.email).toBe("user@gooddomain.com"); }); test("should run all validation rules and combine errors", async () => { const userRegistrationValidator = buildValidator() .input(userRegistrationSchema) .$deps() .rule({ description: "Check for duplicate email", fn: async (args) => { const existingUser = await args.deps.userRepository.findUserByEmail(args.data.email); if (existingUser) { args.bag.addError("email", "Email already exists"); } }, }) .rule({ description: "Check for blacklisted email", fn: async (args) => { const isBlacklisted = await args.deps.userRepository.isEmailBlacklisted(args.data.email); if (isBlacklisted) { args.bag.addError("email", "Email domain is not allowed"); } }, }) .provide({ userRepository }); // Test with existing blacklisted email - should get both errors const result = await userRegistrationValidator.validate({ email: "existing@example.com", // This email exists in our mock name: "John Doe", age: 25, }); assert(!result.success); expect(result.errors.firstError("email")).toBe("Email already exists"); }); test("user registration command with full validation", async () => { const userRegistrationCommand = buildValidator() .input(userRegistrationSchema) .$deps() .rule({ id: "command-duplicate-check", description: "Check for duplicate email", fn: async (args) => { const existingUser = await args.deps.userRepository.findUserByEmail(args.data.email); if (existingUser) { args.bag.addError("email", "Email already exists"); } }, }) .rule({ id: "command-blacklist-check", description: "Check for blacklisted email", fn: async (args) => { const isBlacklisted = await args.deps.userRepository.isEmailBlacklisted(args.data.email); if (isBlacklisted) { args.bag.addError("email", "Email domain is not allowed"); } }, }) .command({ execute: async (args) => { return await args.deps.userRepository.createUser(args.data); }, }); // Test successful registration const result1 = await userRegistrationCommand .provide({ userRepository }) .run({ email: "success@example.com", name: "John Doe", age: 25, }); assert(result1.success); expect(result1.result).toMatchObject({ id: expect.stringMatching(/^user-\d+$/), email: "success@example.com", name: "John Doe", age: 25, createdAt: expect.any(Date), }); // Test failed registration due to duplicate email const result2 = await userRegistrationCommand .provide({ userRepository }) .run({ email: "existing@example.com", name: "Jane Doe", age: 30, }); assert(!result2.success); expect(result2.step).toBe("validation"); expect(result2.errors.firstError("email")).toBe("Email already exists"); expect(result2.rule?.id).toBe("command-duplicate-check"); expect(result2.rule?.description).toBe("Check for duplicate email"); // Test failed registration due to blacklisted email const result3 = await userRegistrationCommand .provide({ userRepository }) .run({ email: "bad@spam.com", name: "Bob Smith", age: 28, }); assert(!result3.success); expect(result3.step).toBe("validation"); expect(result3.errors.firstError("email")).toBe("Email domain is not allowed"); expect(result3.rule?.id).toBe("command-blacklist-check"); expect(result3.rule?.description).toBe("Check for blacklisted email"); }); }); describe("Money Transfer", () => { test("command provides error bag to allow failures at execution time", async () => { const transferMoneySchema = z.object({ fromAccount: z.string(), toAccount: z.string(), amount: z.number().positive(), }); // Mock external bank service const externalBankService = { checkAccountBalance: async (account) => { if (account === "insufficient-funds") return 50; // Less than transfer amount return 1000; // Sufficient funds }, validateAccountStatus: async (account) => { if (account === "closed-account") throw new Error("Account is closed"); if (account === "suspended-account") throw new Error("Account is suspended"); if (account === "frozen-account") throw new Error("Account is frozen"); return true; }, executeTransfer: async (from, to, amount) => { if (from === "fails-in-transfer") throw new Error("Failed in transfer"); // This would make the actual API call to the bank return { transactionId: `ext-txn-${Date.now()}`, status: "completed", from, to, amount, }; }, }; const transferCommand = buildValidator() .input(transferMoneySchema) .$deps() .rule({ id: "no-self-transfer", description: "Validate no transfer to same account", fn: async (args) => { // Business rule: Cannot transfer to same account if (args.data.fromAccount === args.data.toAccount) { args.bag.addError("toAccount", "Cannot transfer to same account"); } }, }) .rule({ id: "account-status-check", description: "Validate account status", fn: async (args) => { // Validate account status await args.deps.externalBankService .validateAccountStatus(args.data.fromAccount) .catch((_error) => { args.bag.addError("fromAccount", "Account is not in a valid state to transfer"); }); await args.deps.externalBankService .validateAccountStatus(args.data.toAccount) .catch((_error) => { args.bag.addError("toAccount", "Account is not in a valid state to transfer"); }); }, }) .rule({ id: "balance-check", description: "Check if from account has sufficient balance", fn: async (args) => { const fromBalance = await args.deps.externalBankService.checkAccountBalance(args.data.fromAccount); if (fromBalance < args.data.amount) { args.bag.addError("amount", "Insufficient funds"); } }, }) .command({ execute: async (args) => { try { // Execute the external transfer const result = await args.deps.externalBankService.executeTransfer(args.data.fromAccount, args.data.toAccount, args.data.amount); return result; } catch (error) { // External service failed unexpectedly return args.bag.addGlobalError(`External service error: ${error instanceof Error ? error.message : "Unknown error"}`); } }, }); // Test validation failure (business rule violation) - step should be "validation" const result1 = await transferCommand .provide({ externalBankService }) .run({ fromAccount: "account-123", toAccount: "account-123", // Same account - violates business rule amount: 100, }); assert(!result1.success); expect(result1.step).toBe("validation"); expect(result1.errors.firstError("toAccount")).toContain("Cannot transfer to same account"); expect(result1.rule).toMatchObject({ id: "no-self-transfer", description: "Validate no transfer to same account", }); // Test execution failure (insufficient funds) - step should be "validation" const result2 = await transferCommand .provide({ externalBankService }) .run({ fromAccount: "insufficient-funds", toAccount: "account-456", amount: 100, }); assert(!result2.success); expect(result2.step).toBe("validation"); expect(result2.errors.firstError("amount")).toContain("Insufficient funds"); expect(result2.rule).toMatchObject({ id: "balance-check", description: "Check if from account has sufficient balance", }); // Test execution failure (frozen account) - step should be "validation" const result3 = await transferCommand .provide({ externalBankService }) .run({ fromAccount: "frozen-account", toAccount: "account-456", amount: 100, }); assert(!result3.success); expect(result3.step).toBe("validation"); expect(result3.errors.firstError("fromAccount")).toContain("Account is not in a valid state to transfer"); expect(result3.rule).toMatchObject({ id: "account-status-check", description: "Validate account status", }); // Test execution failure (failed in transfer) - step should be "execution" const result4 = await transferCommand .provide({ externalBankService }) .run({ fromAccount: "fails-in-transfer", toAccount: "account-456", amount: 100, }); assert(!result4.success); expect(result4.step).toBe("execution"); expect(result4.errors.global).toContain("Failed in transfer"); expect(result4.rule).toBeUndefined(); // Test successful transfer const result5 = await transferCommand .provide({ externalBankService }) .run({ fromAccount: "account-456", toAccount: "account-789", amount: 50, }); assert(result5.success); expect(result5.result.status).toBe("completed"); expect(result5.result.amount).toBe(50); }); }); }); //# sourceMappingURL=test.spec.js.map