|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import { |
| 3 | + ApproverType, |
| 4 | + ApprovalActionType, |
| 5 | + ApprovalActionSchema, |
| 6 | + ApprovalStepSchema, |
| 7 | + ApprovalProcessSchema, |
| 8 | + ApprovalProcess, |
| 9 | +} from './approval.zod'; |
| 10 | + |
| 11 | +describe('ApproverType', () => { |
| 12 | + it('should accept all valid approver types', () => { |
| 13 | + ['user', 'role', 'manager', 'field', 'queue'].forEach(t => { |
| 14 | + expect(() => ApproverType.parse(t)).not.toThrow(); |
| 15 | + }); |
| 16 | + }); |
| 17 | + |
| 18 | + it('should reject invalid approver type', () => { |
| 19 | + expect(() => ApproverType.parse('group')).toThrow(); |
| 20 | + }); |
| 21 | +}); |
| 22 | + |
| 23 | +describe('ApprovalActionType', () => { |
| 24 | + it('should accept all valid action types', () => { |
| 25 | + ['field_update', 'email_alert', 'webhook', 'script', 'connector_action'].forEach(t => { |
| 26 | + expect(() => ApprovalActionType.parse(t)).not.toThrow(); |
| 27 | + }); |
| 28 | + }); |
| 29 | + |
| 30 | + it('should reject invalid action type', () => { |
| 31 | + expect(() => ApprovalActionType.parse('sms')).toThrow(); |
| 32 | + }); |
| 33 | +}); |
| 34 | + |
| 35 | +describe('ApprovalActionSchema', () => { |
| 36 | + it('should accept valid action', () => { |
| 37 | + expect(() => ApprovalActionSchema.parse({ |
| 38 | + type: 'field_update', |
| 39 | + name: 'Set Status', |
| 40 | + config: { field: 'status', value: 'approved' }, |
| 41 | + })).not.toThrow(); |
| 42 | + }); |
| 43 | + |
| 44 | + it('should accept connector action with optional fields', () => { |
| 45 | + expect(() => ApprovalActionSchema.parse({ |
| 46 | + type: 'connector_action', |
| 47 | + name: 'Notify Slack', |
| 48 | + config: { channel: '#approvals' }, |
| 49 | + connectorId: 'slack', |
| 50 | + actionId: 'send_message', |
| 51 | + })).not.toThrow(); |
| 52 | + }); |
| 53 | + |
| 54 | + it('should reject missing type', () => { |
| 55 | + expect(() => ApprovalActionSchema.parse({ |
| 56 | + name: 'Bad Action', |
| 57 | + config: {}, |
| 58 | + })).toThrow(); |
| 59 | + }); |
| 60 | + |
| 61 | + it('should reject missing name', () => { |
| 62 | + expect(() => ApprovalActionSchema.parse({ |
| 63 | + type: 'webhook', |
| 64 | + config: { url: 'https://example.com' }, |
| 65 | + })).toThrow(); |
| 66 | + }); |
| 67 | + |
| 68 | + it('should reject missing config', () => { |
| 69 | + expect(() => ApprovalActionSchema.parse({ |
| 70 | + type: 'email_alert', |
| 71 | + name: 'Send Email', |
| 72 | + })).toThrow(); |
| 73 | + }); |
| 74 | +}); |
| 75 | + |
| 76 | +describe('ApprovalStepSchema', () => { |
| 77 | + const minimalStep = { |
| 78 | + name: 'manager_review', |
| 79 | + label: 'Manager Review', |
| 80 | + approvers: [{ type: 'manager', value: 'direct_manager' }], |
| 81 | + }; |
| 82 | + |
| 83 | + it('should accept minimal step with defaults', () => { |
| 84 | + const result = ApprovalStepSchema.parse(minimalStep); |
| 85 | + expect(result.behavior).toBe('first_response'); |
| 86 | + expect(result.rejectionBehavior).toBe('reject_process'); |
| 87 | + }); |
| 88 | + |
| 89 | + it('should accept full step', () => { |
| 90 | + expect(() => ApprovalStepSchema.parse({ |
| 91 | + name: 'vp_review', |
| 92 | + label: 'VP Review', |
| 93 | + description: 'VP must approve expenses over $10k', |
| 94 | + entryCriteria: 'amount > 10000', |
| 95 | + approvers: [ |
| 96 | + { type: 'role', value: 'vp_finance' }, |
| 97 | + { type: 'user', value: 'user_001' }, |
| 98 | + ], |
| 99 | + behavior: 'unanimous', |
| 100 | + rejectionBehavior: 'back_to_previous', |
| 101 | + onApprove: [{ type: 'field_update', name: 'Update Status', config: { field: 'status', value: 'vp_approved' } }], |
| 102 | + onReject: [{ type: 'email_alert', name: 'Notify Submitter', config: { template: 'rejection' } }], |
| 103 | + })).not.toThrow(); |
| 104 | + }); |
| 105 | + |
| 106 | + it('should reject invalid name (not snake_case)', () => { |
| 107 | + expect(() => ApprovalStepSchema.parse({ |
| 108 | + ...minimalStep, |
| 109 | + name: 'ManagerReview', |
| 110 | + })).toThrow(); |
| 111 | + }); |
| 112 | + |
| 113 | + it('should reject empty approvers array', () => { |
| 114 | + expect(() => ApprovalStepSchema.parse({ |
| 115 | + ...minimalStep, |
| 116 | + approvers: [], |
| 117 | + })).toThrow(); |
| 118 | + }); |
| 119 | + |
| 120 | + it('should reject missing approvers', () => { |
| 121 | + expect(() => ApprovalStepSchema.parse({ |
| 122 | + name: 'bad_step', |
| 123 | + label: 'Bad Step', |
| 124 | + })).toThrow(); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('ApprovalProcessSchema', () => { |
| 129 | + const minimalProcess = { |
| 130 | + name: 'expense_approval', |
| 131 | + label: 'Expense Approval', |
| 132 | + object: 'expense_report', |
| 133 | + steps: [{ |
| 134 | + name: 'manager_review', |
| 135 | + label: 'Manager Review', |
| 136 | + approvers: [{ type: 'manager', value: 'direct_manager' }], |
| 137 | + }], |
| 138 | + }; |
| 139 | + |
| 140 | + it('should accept minimal process with defaults', () => { |
| 141 | + const result = ApprovalProcessSchema.parse(minimalProcess); |
| 142 | + expect(result.active).toBe(false); |
| 143 | + expect(result.lockRecord).toBe(true); |
| 144 | + }); |
| 145 | + |
| 146 | + it('should accept full process', () => { |
| 147 | + expect(() => ApprovalProcessSchema.parse({ |
| 148 | + name: 'purchase_approval', |
| 149 | + label: 'Purchase Approval', |
| 150 | + object: 'purchase_order', |
| 151 | + active: true, |
| 152 | + description: 'Multi-step purchase approval', |
| 153 | + entryCriteria: 'amount > 1000', |
| 154 | + lockRecord: false, |
| 155 | + steps: [ |
| 156 | + { |
| 157 | + name: 'manager_review', |
| 158 | + label: 'Manager Review', |
| 159 | + approvers: [{ type: 'manager', value: 'direct_manager' }], |
| 160 | + }, |
| 161 | + { |
| 162 | + name: 'finance_review', |
| 163 | + label: 'Finance Review', |
| 164 | + entryCriteria: 'amount > 5000', |
| 165 | + approvers: [{ type: 'role', value: 'finance_team' }], |
| 166 | + behavior: 'unanimous', |
| 167 | + }, |
| 168 | + ], |
| 169 | + onSubmit: [{ type: 'field_update', name: 'Lock', config: { field: 'status', value: 'submitted' } }], |
| 170 | + onFinalApprove: [{ type: 'email_alert', name: 'Approved', config: { template: 'approved' } }], |
| 171 | + onFinalReject: [{ type: 'webhook', name: 'Notify', config: { url: 'https://example.com/reject' } }], |
| 172 | + onRecall: [{ type: 'field_update', name: 'Reset', config: { field: 'status', value: 'draft' } }], |
| 173 | + })).not.toThrow(); |
| 174 | + }); |
| 175 | + |
| 176 | + it('should reject empty steps array', () => { |
| 177 | + expect(() => ApprovalProcessSchema.parse({ |
| 178 | + ...minimalProcess, |
| 179 | + steps: [], |
| 180 | + })).toThrow(); |
| 181 | + }); |
| 182 | + |
| 183 | + it('should reject invalid process name', () => { |
| 184 | + expect(() => ApprovalProcessSchema.parse({ |
| 185 | + ...minimalProcess, |
| 186 | + name: 'ExpenseApproval', |
| 187 | + })).toThrow(); |
| 188 | + }); |
| 189 | + |
| 190 | + it('should reject missing object', () => { |
| 191 | + expect(() => ApprovalProcessSchema.parse({ |
| 192 | + name: 'test_process', |
| 193 | + label: 'Test', |
| 194 | + steps: [{ |
| 195 | + name: 'step_one', |
| 196 | + label: 'Step One', |
| 197 | + approvers: [{ type: 'user', value: 'admin' }], |
| 198 | + }], |
| 199 | + })).toThrow(); |
| 200 | + }); |
| 201 | +}); |
| 202 | + |
| 203 | +describe('ApprovalProcess.create', () => { |
| 204 | + it('should return the config object as-is', () => { |
| 205 | + const config = { |
| 206 | + name: 'quick_approval', |
| 207 | + label: 'Quick Approval', |
| 208 | + object: 'invoice', |
| 209 | + steps: [{ |
| 210 | + name: 'review', |
| 211 | + label: 'Review', |
| 212 | + approvers: [{ type: 'role' as const, value: 'reviewer' }], |
| 213 | + }], |
| 214 | + }; |
| 215 | + const result = ApprovalProcess.create(config); |
| 216 | + expect(result).toEqual(config); |
| 217 | + }); |
| 218 | +}); |
0 commit comments