Files
n8n-mcp/tests/unit/services/expression-validator.test.ts
Romuald Członkowski 20ebfbb0fc fix: validation bugs — If/Switch version check, Set false positive, bare expressions (#675, #676, #677) (#678)
- #675: Wire `validateConditionNodeStructure` into `WorkflowValidator` with
  version-conditional checks (If v2.2+ requires options, v2.0-2.1 validates
  operators, v1.x skipped; Switch v3.2+ requires options)
- #676: Fix `validateSet` to check `assignments.assignments` (v3+) alongside
  `config.values` (v1/v2), eliminating false positive warnings
- #677: Add anchored heuristic pre-pass in `ExpressionValidator` detecting bare
  `$json`, `$node`, `$input`, `$execution`, `$workflow`, `$prevNode`, `$env`,
  `$now`, `$today`, `$itemIndex`, `$runIndex` references missing `={{ }}`

25 new tests across 3 test files.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:29:04 +02:00

200 lines
7.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExpressionValidator } from '@/services/expression-validator';
describe('ExpressionValidator', () => {
const defaultContext = {
availableNodes: [],
currentNodeName: 'TestNode',
isInLoop: false,
hasInputData: true
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('validateExpression', () => {
it('should be a static method that validates expressions', () => {
expect(typeof ExpressionValidator.validateExpression).toBe('function');
});
it('should return a validation result', () => {
const result = ExpressionValidator.validateExpression('{{ $json.field }}', defaultContext);
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('errors');
expect(result).toHaveProperty('warnings');
expect(result).toHaveProperty('usedVariables');
expect(result).toHaveProperty('usedNodes');
});
it('should validate expressions with proper syntax', () => {
const validExpr = '{{ $json.field }}';
const result = ExpressionValidator.validateExpression(validExpr, defaultContext);
expect(result).toBeDefined();
expect(Array.isArray(result.errors)).toBe(true);
});
it('should detect malformed expressions', () => {
const invalidExpr = '{{ $json.field'; // Missing closing braces
const result = ExpressionValidator.validateExpression(invalidExpr, defaultContext);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('validateNodeExpressions', () => {
it('should validate all expressions in node parameters', () => {
const parameters = {
field1: '{{ $json.data }}',
nested: {
field2: 'regular text',
field3: '{{ $node["Webhook"].json }}'
}
};
const result = ExpressionValidator.validateNodeExpressions(parameters, defaultContext);
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('errors');
expect(result).toHaveProperty('warnings');
});
it('should collect errors from invalid expressions', () => {
const parameters = {
badExpr: '{{ $json.field', // Missing closing
goodExpr: '{{ $json.field }}'
};
const result = ExpressionValidator.validateNodeExpressions(parameters, defaultContext);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('expression patterns', () => {
it('should recognize n8n variable patterns', () => {
const expressions = [
'{{ $json }}',
'{{ $json.field }}',
'{{ $node["NodeName"].json }}',
'{{ $workflow.id }}',
'{{ $now }}',
'{{ $itemIndex }}'
];
expressions.forEach(expr => {
const result = ExpressionValidator.validateExpression(expr, defaultContext);
expect(result).toBeDefined();
});
});
});
describe('context validation', () => {
it('should use available nodes from context', () => {
const contextWithNodes = {
...defaultContext,
availableNodes: ['Webhook', 'Function', 'Slack']
};
const expr = '{{ $node["Webhook"].json }}';
const result = ExpressionValidator.validateExpression(expr, contextWithNodes);
expect(result.usedNodes.has('Webhook')).toBe(true);
});
});
describe('bare expression detection', () => {
it('should warn on bare $json.name', () => {
const params = { value: '$json.name' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $node["Webhook"].json', () => {
const params = { value: '$node["Webhook"].json' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $now', () => {
const params = { value: '$now' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $execution.id', () => {
const params = { value: '$execution.id' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $env.API_KEY', () => {
const params = { value: '$env.API_KEY' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $input.item.json.field', () => {
const params = { value: '$input.item.json.field' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should NOT warn on properly wrapped ={{ $json.name }}', () => {
const params = { value: '={{ $json.name }}' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn on properly wrapped {{ $json.name }}', () => {
const params = { value: '{{ $json.name }}' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn when $json appears mid-string', () => {
const params = { value: 'The $json data is ready' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn on plain text', () => {
const params = { value: 'Hello World' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should detect bare expression in nested structure', () => {
const params = {
assignments: {
assignments: [{ value: '$json.name' }]
}
};
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
});
describe('edge cases', () => {
it('should handle empty expressions', () => {
const result = ExpressionValidator.validateExpression('{{ }}', defaultContext);
// The implementation might consider empty expressions as valid
expect(result).toBeDefined();
expect(Array.isArray(result.errors)).toBe(true);
});
it('should handle non-expression text', () => {
const result = ExpressionValidator.validateExpression('regular text without expressions', defaultContext);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should handle nested expressions', () => {
const expr = '{{ $json[{{ $json.index }}] }}'; // Nested expressions not allowed
const result = ExpressionValidator.validateExpression(expr, defaultContext);
expect(result).toBeDefined();
});
});
});