From 9a44257fafe6b970c648ea205175586be19a6008 Mon Sep 17 00:00:00 2001 From: lukTS Date: Fri, 22 May 2026 17:10:12 +0200 Subject: [PATCH] feat: add config validation with Zod - Zod schemas for PricingRuleConfig and PricingEngineConfig - Validation in constructor and calculate() - Unique rule names enforcement - 15 validation tests Closes #7 --- packages/core/package.json | 16 +++- packages/core/src/engine.ts | 5 +- packages/core/src/index.ts | 3 +- packages/core/src/schemas.ts | 30 +++++++ packages/core/tests/schemas.test.ts | 117 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 11 ++- 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/schemas.ts create mode 100644 packages/core/tests/schemas.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index c8860dd..52edb9b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,13 +11,23 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", "test": "vitest run", "test:watch": "vitest" }, - "keywords": ["pricing", "engine", "calculator", "business-logic"], + "keywords": [ + "pricing", + "engine", + "calculator", + "business-logic" + ], "author": "lukTS", - "license": "MIT" + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + } } diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 69a2709..95aac82 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -1,15 +1,18 @@ +import { CalculationInputSchema, PricingEngineConfigSchema } from './schemas.js'; import type { CalculationInput, CalculationResult, PricingEngineConfig } from './types.js'; export class PricingEngine { private rules: PricingEngineConfig['rules']; constructor(config: PricingEngineConfig) { + PricingEngineConfigSchema.parse(config); this.rules = config.rules; } calculate(input: CalculationInput): CalculationResult { - const rule = this.rules.find((r) => r.name === input.rule); + CalculationInputSchema.parse(input); + const rule = this.rules.find((r) => r.name === input.rule); if (!rule) { throw new Error(`Unknown rule: "${input.rule}"`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 374b542..b7983e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; -export * from './engine.js'; \ No newline at end of file +export * from './engine.js'; +export * from './schemas.js'; \ No newline at end of file diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 0000000..4c2f175 --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const PricingRuleConfigSchema = z.object({ + name: z.string().min(1, 'Rule name is required'), + type: z.string().min(1, 'Rule type is required'), + unitPrice: z.number().positive('unitPrice must be positive'), + unit: z.string().min(1, 'Unit is required'), + minCharge: z.number().positive('minCharge must be positive').optional(), +}); + +export const CalculationDimensionsSchema = z.object({ + width: z.number().positive('Width must be positive'), + height: z.number().positive('Height must be positive'), +}); + +export const CalculationInputSchema = z.object({ + rule: z.string().min(1, 'Rule name is required'), + dimensions: CalculationDimensionsSchema, + quantity: z.number().int().positive('Quantity must be a positive integer'), +}); + +export const PricingEngineConfigSchema = z.object({ + rules: z + .array(PricingRuleConfigSchema) + .min(1, 'At least one rule is required') + .refine( + (rules) => new Set(rules.map((r) => r.name)).size === rules.length, + 'Rule names must be unique', + ), +}); \ No newline at end of file diff --git a/packages/core/tests/schemas.test.ts b/packages/core/tests/schemas.test.ts new file mode 100644 index 0000000..852ce75 --- /dev/null +++ b/packages/core/tests/schemas.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { PricingEngine } from '../src/engine.js'; + +describe('PricingRuleConfigSchema', () => { + it('throws on empty rule name', () => { + expect(() => new PricingEngine({ + rules: [ { name: '', type: 'area', unitPrice: 12.5, unit: 'm2', minCharge: 50 } ] + })).toThrow(); + }); + + it('throws on zero unitPrice', () => { + expect(() => new PricingEngine({ + rules: [ {name: 'test', type: 'area', unitPrice: 0, unit: 'm2', minCharge: 50} ] + })).toThrow(); + }); + + it('throws on negative unitPrice', () => { + expect(() => new PricingEngine({ + rules: [{ name: 'test', type: 'area', unitPrice: -30, unit: 'm2', minCharge: 50 }], + })).toThrow(); + }); + + it('throws on empty unit', () => { + expect(() => new PricingEngine({ + rules: [{ name: 'test', type: 'area', unitPrice: 30, unit: '', minCharge: 50 }] + })).toThrow(); + }); + + it('throws on zero minCharge', () => { + expect(() => new PricingEngine({ + rules: [{ name: 'test', type: 'area', unitPrice: 30, unit: 'm2', minCharge: 0 }], + })).toThrow(); + }); + + it('throws on negative minCharge', () => { + expect(() => new PricingEngine({ + rules: [{ name: 'test', type: 'area', unitPrice: 30, unit: 'm2', minCharge: -50 }] + })).toThrow(); + }); + + it('throws if no rules are provided', () => { + expect(() => new PricingEngine({ rules: [] })).toThrow(); + }); + + it('throws if rule names are not unique', () => { + expect(() => new PricingEngine({ + rules: [ + { name: 'duplicate', type: 'area', unitPrice: 30, unit: 'm2', minCharge: 50 }, + { name: 'duplicate', type: 'area', unitPrice: 25, unit: 'm2' }, + ] + })).toThrow(); + }); +}); + +describe('CalculationInputSchema', () => { + const engine = new PricingEngine({ + rules: [ + { name: 'flat-surface', type: 'area', unitPrice: 12.5, unit: 'm2' }, + { name: 'premium', type: 'area', unitPrice: 30, unit: 'm2', minCharge: 50 }, + ], + }); + + it('throws on empty rule name in input', () => { + expect(() => engine.calculate({ + rule: '', + dimensions: { width: 2, height: 3 }, + quantity: 1, + })).toThrow(); + }); + + it('throws on zero quantity', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: 2, height: 3 }, + quantity: 0, + })).toThrow(); + }); + + it('throws on negative quantity', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: 2, height: 3 }, + quantity: -1, + })).toThrow(); + }); + + it('throws on negative width', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: -1, height: 3 }, + quantity: 1, + })).toThrow(); + }); + + it('throws on negative height', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: 2, height: -3 }, + quantity: 1, + })).toThrow(); + }); + it('throws on zero width', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: 0, height: 3 }, + quantity: 1, + })).toThrow(); + }); + + it('throws on zero height', () => { + expect(() => engine.calculate({ + rule: 'flat-surface', + dimensions: { width: 2, height: 0 }, + quantity: 1, + })).toThrow(); + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dea967d..44fc9f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,11 @@ importers: specifier: ^3.1.0 version: 3.2.4 - packages/core: {} + packages/core: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: @@ -1014,6 +1018,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@esbuild/aix-ppc64@0.27.7': @@ -1884,3 +1891,5 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + zod@4.4.3: {}