|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25598] An SLA escalation's `escalateTo` that no approver could ever act |
| 5 | + * under is refused at registration — the contract half of the card whose |
| 6 | + * runtime half keeps a stranded slate from being written on timeout. |
| 7 | + * |
| 8 | + * A person acts on a slot under their user id, their email or |
| 9 | + * `position:<p>` (`approver-address.ts` in `plugin-approvals`), so a `team:`, |
| 10 | + * `department:` or `org_membership_level:` literal, or the retired `role:` |
| 11 | + * spelling, names nobody under any escalation action. The shape alone settles |
| 12 | + * it, so it is judged here; whether a user id or a position resolves is |
| 13 | + * directory state, so every other shape stays accepted. |
| 14 | + * |
| 15 | + * - §1 each refused prefix is refused at `escalateTo`, under every action, |
| 16 | + * padded or not; |
| 17 | + * - §2 each accepted shape parses, and an omitted target still parses; |
| 18 | + * - §3 the refusal reaches the door authors meet: `FlowSchema` and the build |
| 19 | + * judge `flowNodeConfigRefusals`, anchored at the key. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, expect, it } from 'vitest'; |
| 23 | + |
| 24 | +import { ApprovalEscalationSchema } from './approval.zod'; |
| 25 | +import { flowNodeConfigRefusals } from './flow-node-config-refusals'; |
| 26 | +import { FlowSchema } from './flow.zod'; |
| 27 | + |
| 28 | +const REFUSED = ['team:night_shift', 'department:sales', 'org_membership_level:admin', 'role:sales_manager']; |
| 29 | +const ACCEPTED = ['usr_7f3a', 'vp@example.com', 'vp_operations', 'position:vp_operations']; |
| 30 | +const ACTIONS = ['reassign', 'notify', 'auto_approve', 'auto_reject'] as const; |
| 31 | + |
| 32 | +function issues(value: unknown): { code: string; path: string }[] { |
| 33 | + const result = ApprovalEscalationSchema.safeParse(value); |
| 34 | + if (result.success) return []; |
| 35 | + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); |
| 36 | +} |
| 37 | + |
| 38 | +function flowWith(escalation: Record<string, unknown>) { |
| 39 | + return { |
| 40 | + name: 'expense_approval', |
| 41 | + label: 'Expense approval', |
| 42 | + type: 'autolaunched', |
| 43 | + nodes: [ |
| 44 | + { id: 'start', type: 'start', label: 'Start' }, |
| 45 | + { |
| 46 | + id: 'gate', type: 'approval', label: 'Finance', |
| 47 | + config: { approvers: [{ type: 'position', value: 'finance_manager' }], escalation }, |
| 48 | + }, |
| 49 | + { id: 'approved', type: 'end', label: 'Approved' }, |
| 50 | + { id: 'rejected', type: 'end', label: 'Rejected' }, |
| 51 | + ], |
| 52 | + edges: [ |
| 53 | + { id: 'e1', source: 'start', target: 'gate' }, |
| 54 | + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, |
| 55 | + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, |
| 56 | + ], |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +describe('§1 a target no approver acts under is refused at escalateTo', () => { |
| 61 | + it.each(REFUSED)('%s is refused under every action', (escalateTo) => { |
| 62 | + for (const action of ACTIONS) { |
| 63 | + expect(issues({ timeoutHours: 24, action, escalateTo })).toEqual([{ code: 'custom', path: 'escalateTo' }]); |
| 64 | + } |
| 65 | + }); |
| 66 | + |
| 67 | + it.each(REFUSED)('%s is refused with surrounding whitespace, as the runtime trims it', (escalateTo) => { |
| 68 | + expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo: ` ${escalateTo} ` })) |
| 69 | + .toEqual([{ code: 'custom', path: 'escalateTo' }]); |
| 70 | + }); |
| 71 | + |
| 72 | + it('names the value it refused and the shapes it accepts', () => { |
| 73 | + const result = ApprovalEscalationSchema.safeParse({ timeoutHours: 24, action: 'reassign', escalateTo: 'team:ops' }); |
| 74 | + expect(result.success).toBe(false); |
| 75 | + const message = result.success ? '' : result.error.issues[0]!.message; |
| 76 | + expect(message).toContain("'team:ops'"); |
| 77 | + expect(message).toContain("'position:<name>'"); |
| 78 | + }); |
| 79 | +}); |
| 80 | + |
| 81 | +describe('§2 a user id, an email and a position stay accepted', () => { |
| 82 | + it.each(ACCEPTED)('%s parses, whole schema green', (escalateTo) => { |
| 83 | + expect(issues({ timeoutHours: 24, action: 'reassign', escalateTo })).toEqual([]); |
| 84 | + const parsed = ApprovalEscalationSchema.parse({ timeoutHours: 24, action: 'reassign', escalateTo }); |
| 85 | + expect(parsed.escalateTo).toBe(escalateTo); |
| 86 | + }); |
| 87 | + |
| 88 | + it('an omitted target still parses and stays absent', () => { |
| 89 | + expect(issues({ timeoutHours: 24 })).toEqual([]); |
| 90 | + expect(ApprovalEscalationSchema.parse({ timeoutHours: 24 }).escalateTo).toBeUndefined(); |
| 91 | + }); |
| 92 | +}); |
| 93 | + |
| 94 | +describe('§3 the refusal reaches the flow door', () => { |
| 95 | + it.each(REFUSED)('FlowSchema refuses %s at the node config key', (escalateTo) => { |
| 96 | + const result = FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })); |
| 97 | + expect(result.success).toBe(false); |
| 98 | + const paths = result.success ? [] : result.error.issues.map((i) => i.path.join('.')); |
| 99 | + expect(paths).toContain('nodes.1.config.escalation.escalateTo'); |
| 100 | + }); |
| 101 | + |
| 102 | + it.each(REFUSED)('the build judge reports %s as node-config-refused-by-contract', (escalateTo) => { |
| 103 | + const config = { |
| 104 | + approvers: [{ type: 'position', value: 'finance_manager' }], |
| 105 | + escalation: { timeoutHours: 24, action: 'reassign', escalateTo }, |
| 106 | + }; |
| 107 | + expect(flowNodeConfigRefusals('approval', config).map(({ code, path }) => ({ code, path }))).toEqual([ |
| 108 | + { code: 'node-config-refused-by-contract', path: 'escalation.escalateTo' }, |
| 109 | + ]); |
| 110 | + }); |
| 111 | + |
| 112 | + it.each(ACCEPTED)('FlowSchema accepts %s', (escalateTo) => { |
| 113 | + expect(FlowSchema.safeParse(flowWith({ timeoutHours: 24, action: 'reassign', escalateTo })).success).toBe(true); |
| 114 | + }); |
| 115 | +}); |
0 commit comments