|
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | 4 | import { BUILTIN_MEMBERSHIP_ROLES } from '@objectstack/spec'; |
| 5 | +import { APPROVER_EXPRESSION_ROOTS, ApprovalNodeConfigSchema } from '@objectstack/spec/automation'; |
5 | 6 | import { |
6 | 7 | validateApprovalApprovers as validateApprovalApproversUnrecorded, |
7 | 8 | APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER, |
@@ -856,3 +857,127 @@ describe('[#22161] one-line fixes — approval-approvers-may-resolve-empty, agai |
856 | 857 | expect(text).toContain("only an organization with no administrator leaves the request on an empty"); |
857 | 858 | }); |
858 | 859 | }); |
| 860 | + |
| 861 | +// [#22161] Slice 16: the `fix:` lines of the other three `approval-*` ids. Each |
| 862 | +// names its remedy in one line; what each root holds, why `record` is unbound, |
| 863 | +// what each empty-slate policy does and why a business role is a position are |
| 864 | +// the explanations'. The bound reads every finding the cases above fired. |
| 865 | +describe('[#22161] one-line fixes — the expression and membership-tier ids', () => { |
| 866 | + const SLICE_16 = [ |
| 867 | + APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER, |
| 868 | + APPROVAL_EXPRESSION_INVALID, |
| 869 | + APPROVAL_EXPRESSION_NO_EMPTY_POLICY, |
| 870 | + ]; |
| 871 | + const stackWithConfig = (config: Record<string, unknown>): Record<string, unknown> => ({ |
| 872 | + flows: [{ |
| 873 | + name: 'expense_approval', |
| 874 | + nodes: [ |
| 875 | + { id: 'start', type: 'start', config: {} }, |
| 876 | + { id: 'step1', type: 'approval', config }, |
| 877 | + ], |
| 878 | + edges: [], |
| 879 | + }], |
| 880 | + }); |
| 881 | + |
| 882 | + it('every `fix:` line of the three ids the cases above fired is one line of at most 200 characters', () => { |
| 883 | + // A long position name, so the bound is not read on short names alone. |
| 884 | + validateApprovalApprovers(stackWithApprovers([ |
| 885 | + { type: 'org_membership_level', value: 'regional_vice_president_of_enterprise_sales' }, |
| 886 | + ])); |
| 887 | + const converted = fired.filter((f) => SLICE_16.includes(f.rule)); |
| 888 | + // Coverage control: every arm of the three ids fired. |
| 889 | + expect(converted.some((f) => f.rule === APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER)).toBe(true); |
| 890 | + expect(converted.some((f) => f.rule === APPROVAL_EXPRESSION_NO_EMPTY_POLICY)).toBe(true); |
| 891 | + const invalid = converted.filter((f) => f.rule === APPROVAL_EXPRESSION_INVALID); |
| 892 | + for (const arm of ['empty expression', 'does not parse', 'references `record`', 'references `approvers_dynamic`', 'resolveAs has no effect']) { |
| 893 | + expect(invalid.some((f) => f.message.includes(arm)), arm).toBe(true); |
| 894 | + } |
| 895 | + for (const f of converted) { |
| 896 | + expect(f.hint, f.rule).not.toContain('\n'); |
| 897 | + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); |
| 898 | + } |
| 899 | + }); |
| 900 | + |
| 901 | + it('each arm reads as one remedy', () => { |
| 902 | + const [tier] = validateApprovalApprovers(stackWithApprovers([{ type: 'org_membership_level', value: 'sales_manager' }])); |
| 903 | + expect(tier.hint).toBe( |
| 904 | + "If it is an org position, author { type: 'position', value: 'sales_manager' }; keep " + |
| 905 | + "'org_membership_level' for the tiers the verdict lists", |
| 906 | + ); |
| 907 | + const [empty] = validateApprovalApprovers(stackWithConfig({ |
| 908 | + approvers: [{ type: 'expression', value: '' }], |
| 909 | + onEmptyApprovers: 'fail', |
| 910 | + })); |
| 911 | + expect(empty.hint).toBe('Write a CEL expression over current.*, trigger.* or vars.*, e.g. current.approvers_dynamic'); |
| 912 | + const [record] = validateApprovalApprovers(stackWithConfig({ |
| 913 | + approvers: [{ type: 'expression', value: 'record.owner_id' }], |
| 914 | + onEmptyApprovers: 'fail', |
| 915 | + })); |
| 916 | + expect(record.hint).toBe( |
| 917 | + 'Write current.<field> for the live value at node entry or trigger.<field> for the submit-time ' + |
| 918 | + 'snapshot; vars.previous carries the pre-update row', |
| 919 | + ); |
| 920 | + const [nudge] = validateApprovalApprovers(stackWithConfig({ |
| 921 | + approvers: [{ type: 'expression', value: 'vars.picked' }], |
| 922 | + })); |
| 923 | + expect(nudge.hint).toBe( |
| 924 | + "Declare onEmptyApprovers on the node: 'admin_rescue', 'fail', 'auto_approve', or 'fallback' " + |
| 925 | + 'with a fallbackApprovers list', |
| 926 | + ); |
| 927 | + }); |
| 928 | + |
| 929 | + it("the nudge's policies and the explanation's are the spec's `onEmptyApprovers` enum", () => { |
| 930 | + // The explanation module imports nothing, so it spells the list out; this |
| 931 | + // holds both it and the fix line to the schema. |
| 932 | + const policies = ( |
| 933 | + ApprovalNodeConfigSchema.shape.onEmptyApprovers.unwrap() as unknown as { options: readonly string[] } |
| 934 | + ).options; |
| 935 | + expect([...policies].sort()).toEqual(['admin_rescue', 'auto_approve', 'fail', 'fallback']); |
| 936 | + const [nudge] = validateApprovalApprovers(stackWithConfig({ |
| 937 | + approvers: [{ type: 'expression', value: 'vars.picked' }], |
| 938 | + })); |
| 939 | + const text = explainRule(APPROVAL_EXPRESSION_NO_EMPTY_POLICY)!.paragraphs.join('\n'); |
| 940 | + for (const policy of policies) { |
| 941 | + expect(nudge.hint, policy).toContain(`'${policy}'`); |
| 942 | + expect(text, policy).toContain(`\`${policy}\``); |
| 943 | + } |
| 944 | + }); |
| 945 | + |
| 946 | + it("the explanation's expression roots are the spec's `APPROVER_EXPRESSION_ROOTS`", () => { |
| 947 | + const text = explainRule(APPROVAL_EXPRESSION_INVALID)!.paragraphs.join('\n'); |
| 948 | + expect(APPROVER_EXPRESSION_ROOTS.length).toBe(3); |
| 949 | + for (const root of APPROVER_EXPRESSION_ROOTS) expect(text, root).toContain(`\`${root}.*\``); |
| 950 | + }); |
| 951 | + |
| 952 | + it('`os explain` carries what the three `fix:` lines no longer say', () => { |
| 953 | + const facts: Record<string, string[]> = { |
| 954 | + [APPROVAL_EXPRESSION_INVALID]: [ |
| 955 | + "record's live state at node entry", |
| 956 | + 'the submit-time snapshot', |
| 957 | + 'no `{…}` template braces', |
| 958 | + 'record at event time', |
| 959 | + '`vars.previous` carries the pre-update row', |
| 960 | + 'fails the node at entry', |
| 961 | + '`resolveAs` means something only on an `expression` approver', |
| 962 | + ], |
| 963 | + [APPROVAL_EXPRESSION_NO_EMPTY_POLICY]: [ |
| 964 | + "the organization's administrators", |
| 965 | + 'its fault edge', |
| 966 | + '`output.autoApproved = true`', |
| 967 | + '`fallbackApprovers`', |
| 968 | + 'degrades to `admin_rescue`', |
| 969 | + ], |
| 970 | + [APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER]: [ |
| 971 | + 'A business role is always a position', |
| 972 | + '`sys_user_position`', |
| 973 | + 'ADR-0108', |
| 974 | + ], |
| 975 | + }; |
| 976 | + for (const [rule, list] of Object.entries(facts)) { |
| 977 | + const entry = explainRule(rule); |
| 978 | + expect(entry, `no \`os explain ${rule}\` entry`).toBeDefined(); |
| 979 | + const text = entry!.paragraphs.join('\n'); |
| 980 | + for (const fact of list) expect(text, `${rule} explanation names ${fact}`).toContain(fact); |
| 981 | + } |
| 982 | + }); |
| 983 | +}); |
0 commit comments