|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
4 | 4 | import { LiteKernel } from '@objectstack/core'; |
5 | 5 | import { AutomationEngine, DEFAULT_MAX_EXECUTION_LOG_SIZE } from './engine.js'; |
6 | | -import { AutomationServicePlugin } from './plugin.js'; |
| 6 | +import { AutomationServicePlugin, parseObjectFieldSchema } from './plugin.js'; |
7 | 7 | import { registerScreenNodes } from './builtin/screen-nodes.js'; |
8 | 8 | import { InMemorySuspendedRunStore } from './suspended-run-store.js'; |
9 | 9 | import type { NodeExecutor } from './engine.js'; |
@@ -821,6 +821,117 @@ describe('AutomationEngine', () => { |
821 | 821 | }); |
822 | 822 | }); |
823 | 823 |
|
| 824 | +// ─── Schema-aware condition validation at registration (#1928) ─────── |
| 825 | + |
| 826 | +describe('Schema-aware condition validation at registration (#1928)', () => { |
| 827 | + // A logger that captures warnings, so we can assert the advisory channel. |
| 828 | + function loggerCapturing(warns: string[]) { |
| 829 | + const l: any = { |
| 830 | + info: () => {}, error: () => {}, debug: () => {}, |
| 831 | + warn: (m: string) => warns.push(m), |
| 832 | + child: () => l, |
| 833 | + }; |
| 834 | + return l; |
| 835 | + } |
| 836 | + |
| 837 | + const OPP_SCHEMA = { |
| 838 | + crm_opportunity: { |
| 839 | + fields: ['stage', 'amount', 'is_active', 'title'], |
| 840 | + fieldTypes: { stage: 'select', amount: 'currency', is_active: 'boolean', title: 'text' } as Record<string, string>, |
| 841 | + } as { fields: string[]; fieldTypes: Record<string, string> }, |
| 842 | + }; |
| 843 | + |
| 844 | + function makeFlow(condition: string) { |
| 845 | + return { |
| 846 | + name: 'opp_flow', label: 'Opp Flow', type: 'record_change', |
| 847 | + nodes: [ |
| 848 | + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'crm_opportunity' } }, |
| 849 | + { id: 'check', type: 'decision', label: 'Check', config: { condition } }, |
| 850 | + { id: 'end', type: 'end', label: 'End' }, |
| 851 | + ], |
| 852 | + edges: [ |
| 853 | + { id: 'e1', source: 'start', target: 'check' }, |
| 854 | + { id: 'e2', source: 'check', target: 'end' }, |
| 855 | + ], |
| 856 | + }; |
| 857 | + } |
| 858 | + |
| 859 | + it('logs an advisory warning (does NOT throw) for a text field misused in arithmetic — tier 4', () => { |
| 860 | + const warns: string[] = []; |
| 861 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 862 | + engine.setObjectSchemaResolver((name) => (OPP_SCHEMA as Record<string, unknown>)[name] as any); |
| 863 | + expect(() => engine.registerFlow('opp_flow', makeFlow('title * 2 > 10'))).not.toThrow(); |
| 864 | + const w = warns.filter((m) => /type mismatch/i.test(m)); |
| 865 | + expect(w).toHaveLength(1); |
| 866 | + expect(w[0]).toMatch(/`title`/); |
| 867 | + }); |
| 868 | + |
| 869 | + it('logs an advisory warning for a likely field typo — tier 3', () => { |
| 870 | + const warns: string[] = []; |
| 871 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 872 | + engine.setObjectSchemaResolver((name) => (OPP_SCHEMA as Record<string, unknown>)[name] as any); |
| 873 | + expect(() => engine.registerFlow('opp_flow', makeFlow('stagee == "closed_won"'))).not.toThrow(); |
| 874 | + expect(warns.some((m) => /did you mean `stage`/.test(m))).toBe(true); |
| 875 | + }); |
| 876 | + |
| 877 | + it('logs an advisory warning for an unknown record field — tier 2', () => { |
| 878 | + const warns: string[] = []; |
| 879 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 880 | + engine.setObjectSchemaResolver((name) => (OPP_SCHEMA as Record<string, unknown>)[name] as any); |
| 881 | + expect(() => engine.registerFlow('opp_flow', makeFlow('record.amont > 5'))).not.toThrow(); |
| 882 | + expect(warns.some((m) => /unknown field `amont`/.test(m))).toBe(true); |
| 883 | + }); |
| 884 | + |
| 885 | + it('does not warn on sound conditions (number arithmetic, equality, flow variables)', () => { |
| 886 | + const warns: string[] = []; |
| 887 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 888 | + engine.setObjectSchemaResolver((name) => (OPP_SCHEMA as Record<string, unknown>)[name] as any); |
| 889 | + engine.registerFlow('opp_flow', makeFlow('amount / 100 > 5 && stage == "won" && expiring_count * 2 > 3')); |
| 890 | + expect(warns.filter((m) => /type mismatch|did you mean|unknown field/i.test(m))).toHaveLength(0); |
| 891 | + }); |
| 892 | + |
| 893 | + it('still HARD-FAILS a malformed condition (fatal set unchanged)', () => { |
| 894 | + const warns: string[] = []; |
| 895 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 896 | + engine.setObjectSchemaResolver((name) => (OPP_SCHEMA as Record<string, unknown>)[name] as any); |
| 897 | + expect(() => engine.registerFlow('opp_flow', makeFlow('{record.stage} == "won"'))).toThrow(/template braces|bare CEL/); |
| 898 | + }); |
| 899 | + |
| 900 | + it('is a no-op when no resolver is wired (registration behaviour unchanged)', () => { |
| 901 | + const warns: string[] = []; |
| 902 | + const engine = new AutomationEngine(loggerCapturing(warns)); |
| 903 | + // No setObjectSchemaResolver — a tier-4 mistake registers with NO advisory. |
| 904 | + expect(() => engine.registerFlow('opp_flow', makeFlow('title * 2 > 10'))).not.toThrow(); |
| 905 | + expect(warns.filter((m) => /type mismatch|did you mean|unknown field/i.test(m))).toHaveLength(0); |
| 906 | + }); |
| 907 | +}); |
| 908 | + |
| 909 | +describe('parseObjectFieldSchema (#1928 — object-registry → schema hint)', () => { |
| 910 | + it('normalizes a name-keyed field map (the ObjectQL ServiceObject shape)', () => { |
| 911 | + const r = parseObjectFieldSchema({ title: { type: 'text' }, amount: { type: 'currency' }, done: { type: 'boolean' } }); |
| 912 | + expect(r?.fields.sort()).toEqual(['amount', 'done', 'title']); |
| 913 | + expect(r?.fieldTypes).toEqual({ title: 'text', amount: 'currency', done: 'boolean' }); |
| 914 | + }); |
| 915 | + |
| 916 | + it('normalizes an array of {name, type}', () => { |
| 917 | + const r = parseObjectFieldSchema([{ name: 'title', type: 'text' }, { name: 'amount', type: 'currency' }]); |
| 918 | + expect(r?.fields).toEqual(['title', 'amount']); |
| 919 | + expect(r?.fieldTypes).toEqual({ title: 'text', amount: 'currency' }); |
| 920 | + }); |
| 921 | + |
| 922 | + it('lists a field with a non-string type but assigns it no type (→ dyn)', () => { |
| 923 | + const r = parseObjectFieldSchema({ meta: {}, name: { type: 'text' } }); |
| 924 | + expect(r?.fields.sort()).toEqual(['meta', 'name']); |
| 925 | + expect(r?.fieldTypes).toEqual({ name: 'text' }); |
| 926 | + }); |
| 927 | + |
| 928 | + it('returns undefined for a non-object fields value', () => { |
| 929 | + expect(parseObjectFieldSchema(undefined)).toBeUndefined(); |
| 930 | + expect(parseObjectFieldSchema(null)).toBeUndefined(); |
| 931 | + expect(parseObjectFieldSchema('nope')).toBeUndefined(); |
| 932 | + }); |
| 933 | +}); |
| 934 | + |
824 | 935 | // ─── Plugin Integration Tests ──────────────────────────────────────── |
825 | 936 |
|
826 | 937 | describe('AutomationServicePlugin (Kernel Integration)', () => { |
|
0 commit comments