mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-30 14:13:12 +00:00
Compare commits
2 Commits
fix/sessio
...
v2.41.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ebfbb0fc | ||
|
|
6e4a9d520d |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.41.4] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`validate_workflow` misses `conditions.options` check for If/Switch nodes** (Issue #675): Added version-conditional validation — If v2.2+ and Switch v3.2+ now require `conditions.options` metadata, If v2.0-2.1 validates operator structures, and v1.x is left unchecked. Previously only caught by `n8n_create_workflow` pre-flight but not by offline `validate_workflow`.
|
||||
|
||||
- **False positive "Set node has no fields configured" for Set v3+** (Issue #676): The `validateSet` checker now recognizes `config.assignments.assignments` (v3+ schema) in addition to `config.values` (v1/v2 schema). Updated suggestion text to match current UI terminology.
|
||||
|
||||
- **Expression validator does not detect unwrapped n8n expressions** (Issue #677): Added heuristic pre-pass that detects bare `$json`, `$node`, `$input`, `$execution`, `$workflow`, `$prevNode`, `$env`, `$now`, `$today`, `$itemIndex`, and `$runIndex` references missing `={{ }}` wrappers. Uses anchored patterns to avoid false positives. Emits warnings, not errors.
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.41.3] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.41.3",
|
||||
"version": "2.41.4",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -19,6 +19,18 @@ interface ExpressionContext {
|
||||
}
|
||||
|
||||
export class ExpressionValidator {
|
||||
// Bare n8n variable references missing {{ }} wrappers
|
||||
private static readonly BARE_EXPRESSION_PATTERNS: Array<{ pattern: RegExp; name: string }> = [
|
||||
{ pattern: /^\$json[.\[]/, name: '$json' },
|
||||
{ pattern: /^\$node\[/, name: '$node' },
|
||||
{ pattern: /^\$input\./, name: '$input' },
|
||||
{ pattern: /^\$execution\./, name: '$execution' },
|
||||
{ pattern: /^\$workflow\./, name: '$workflow' },
|
||||
{ pattern: /^\$prevNode\./, name: '$prevNode' },
|
||||
{ pattern: /^\$env\./, name: '$env' },
|
||||
{ pattern: /^\$(now|today|itemIndex|runIndex)$/, name: 'built-in variable' },
|
||||
];
|
||||
|
||||
// Common n8n expression patterns
|
||||
private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g;
|
||||
private static readonly VARIABLE_PATTERNS = {
|
||||
@@ -288,6 +300,32 @@ export class ExpressionValidator {
|
||||
return combinedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect bare n8n variable references missing {{ }} wrappers.
|
||||
* Emits warnings since the value is technically valid as a literal string.
|
||||
*/
|
||||
private static checkBareExpression(
|
||||
value: string,
|
||||
path: string,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
if (value.includes('{{') || value.startsWith('=')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
for (const { pattern, name } of this.BARE_EXPRESSION_PATTERNS) {
|
||||
if (pattern.test(trimmed)) {
|
||||
result.warnings.push(
|
||||
(path ? `${path}: ` : '') +
|
||||
`Possible unwrapped expression: "${trimmed}" looks like an n8n ${name} reference. ` +
|
||||
`Use "={{ ${trimmed} }}" to evaluate it as an expression.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively validate expressions in parameters
|
||||
*/
|
||||
@@ -307,6 +345,9 @@ export class ExpressionValidator {
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
// Detect bare expressions missing {{ }} wrappers
|
||||
this.checkBareExpression(obj, path, result);
|
||||
|
||||
if (obj.includes('{{')) {
|
||||
const validation = this.validateExpression(obj, context);
|
||||
|
||||
|
||||
@@ -344,10 +344,10 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
|
||||
});
|
||||
}
|
||||
|
||||
// Validate filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
|
||||
// Validate If/Switch condition structures (version-conditional)
|
||||
if (workflow.nodes) {
|
||||
workflow.nodes.forEach((node, index) => {
|
||||
const filterErrors = validateFilterBasedNodeMetadata(node);
|
||||
const filterErrors = validateConditionNodeStructure(node);
|
||||
if (filterErrors.length > 0) {
|
||||
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
|
||||
}
|
||||
@@ -488,106 +488,81 @@ export function hasWebhookTrigger(workflow: Workflow): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
|
||||
* Returns array of error messages
|
||||
* Validate If/Switch node conditions structure for ANY version.
|
||||
* Version-conditional: validates the correct structure per version.
|
||||
*/
|
||||
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
|
||||
export function validateConditionNodeStructure(node: WorkflowNode): string[] {
|
||||
const errors: string[] = [];
|
||||
const typeVersion = node.typeVersion || 1;
|
||||
|
||||
// Check if node is filter-based
|
||||
const isIFNode = node.type === 'n8n-nodes-base.if' && node.typeVersion >= 2.2;
|
||||
const isSwitchNode = node.type === 'n8n-nodes-base.switch' && node.typeVersion >= 3.2;
|
||||
|
||||
if (!isIFNode && !isSwitchNode) {
|
||||
return errors; // Not a filter-based node
|
||||
}
|
||||
|
||||
// Validate IF node
|
||||
if (isIFNode) {
|
||||
const conditions = (node.parameters.conditions as any);
|
||||
|
||||
// Check conditions.options exists
|
||||
if (!conditions?.options) {
|
||||
errors.push(
|
||||
'Missing required "conditions.options". ' +
|
||||
'IF v2.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||
);
|
||||
} else {
|
||||
// Validate required fields
|
||||
const requiredFields = {
|
||||
version: 2,
|
||||
leftValue: '',
|
||||
caseSensitive: 'boolean',
|
||||
typeValidation: 'strict'
|
||||
};
|
||||
|
||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
||||
if (!(field in conditions.options)) {
|
||||
errors.push(
|
||||
`Missing required field "conditions.options.${field}". ` +
|
||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
||||
);
|
||||
}
|
||||
if (node.type === 'n8n-nodes-base.if') {
|
||||
if (typeVersion >= 2.2) {
|
||||
errors.push(...validateFilterOptionsRequired(node.parameters?.conditions, 'conditions'));
|
||||
errors.push(...validateFilterConditionOperators(node.parameters?.conditions, 'conditions'));
|
||||
} else if (typeVersion >= 2) {
|
||||
// v2 has conditions but no options requirement; just validate operators
|
||||
errors.push(...validateFilterConditionOperators(node.parameters?.conditions as any, 'conditions'));
|
||||
}
|
||||
} else if (node.type === 'n8n-nodes-base.switch') {
|
||||
if (typeVersion >= 3.2) {
|
||||
const rules = node.parameters?.rules as any;
|
||||
if (rules?.rules && Array.isArray(rules.rules)) {
|
||||
rules.rules.forEach((rule: any, i: number) => {
|
||||
errors.push(...validateFilterOptionsRequired(rule.conditions, `rules.rules[${i}].conditions`));
|
||||
errors.push(...validateFilterConditionOperators(rule.conditions, `rules.rules[${i}].conditions`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate operators in conditions
|
||||
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
|
||||
conditions.conditions.forEach((condition: any, i: number) => {
|
||||
const operatorErrors = validateOperatorStructure(condition.operator, `conditions.conditions[${i}].operator`);
|
||||
errors.push(...operatorErrors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Switch node
|
||||
if (isSwitchNode) {
|
||||
const rules = (node.parameters.rules as any);
|
||||
|
||||
if (rules?.rules && Array.isArray(rules.rules)) {
|
||||
rules.rules.forEach((rule: any, ruleIndex: number) => {
|
||||
// Check rule.conditions.options
|
||||
if (!rule.conditions?.options) {
|
||||
errors.push(
|
||||
`Missing required "rules.rules[${ruleIndex}].conditions.options". ` +
|
||||
'Switch v3.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||
);
|
||||
} else {
|
||||
// Validate required fields
|
||||
const requiredFields = {
|
||||
version: 2,
|
||||
leftValue: '',
|
||||
caseSensitive: 'boolean',
|
||||
typeValidation: 'strict'
|
||||
};
|
||||
|
||||
for (const [field, expectedValue] of Object.entries(requiredFields)) {
|
||||
if (!(field in rule.conditions.options)) {
|
||||
errors.push(
|
||||
`Missing required field "rules.rules[${ruleIndex}].conditions.options.${field}". ` +
|
||||
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate operators in rule conditions
|
||||
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
|
||||
rule.conditions.conditions.forEach((condition: any, condIndex: number) => {
|
||||
const operatorErrors = validateOperatorStructure(
|
||||
condition.operator,
|
||||
`rules.rules[${ruleIndex}].conditions.conditions[${condIndex}].operator`
|
||||
);
|
||||
errors.push(...operatorErrors);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFilterOptionsRequired(conditions: any, path: string): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!conditions || typeof conditions !== 'object') return errors;
|
||||
|
||||
if (!conditions.options) {
|
||||
errors.push(
|
||||
`Missing required "${path}.options". ` +
|
||||
'Filter-based nodes require: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
|
||||
);
|
||||
} else {
|
||||
const requiredFields: [string, string][] = [
|
||||
['version', '2'],
|
||||
['leftValue', '""'],
|
||||
['caseSensitive', 'true'],
|
||||
['typeValidation', '"strict"'],
|
||||
];
|
||||
for (const [field, display] of requiredFields) {
|
||||
if (!(field in conditions.options)) {
|
||||
errors.push(
|
||||
`Missing required field "${path}.options.${field}". Expected value: ${display}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateFilterConditionOperators(conditions: any, path: string): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!conditions?.conditions || !Array.isArray(conditions.conditions)) return errors;
|
||||
|
||||
conditions.conditions.forEach((condition: any, i: number) => {
|
||||
errors.push(...validateOperatorStructure(
|
||||
condition.operator,
|
||||
`${path}.conditions[${i}].operator`
|
||||
));
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** @deprecated Use validateConditionNodeStructure instead */
|
||||
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
|
||||
return validateConditionNodeStructure(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate operator structure
|
||||
* Ensures operator has correct format: {type, operation, singleValue?}
|
||||
|
||||
@@ -1713,12 +1713,16 @@ export class NodeSpecificValidators {
|
||||
// Validate mode-specific requirements
|
||||
if (config.mode === 'manual') {
|
||||
// In manual mode, at least one field should be defined
|
||||
const hasFields = config.values && Object.keys(config.values).length > 0;
|
||||
const hasFieldsViaValues = config.values && Object.keys(config.values).length > 0;
|
||||
const hasFieldsViaAssignments = config.assignments?.assignments
|
||||
&& Array.isArray(config.assignments.assignments)
|
||||
&& config.assignments.assignments.length > 0;
|
||||
const hasFields = hasFieldsViaValues || hasFieldsViaAssignments;
|
||||
if (!hasFields && !config.jsonOutput) {
|
||||
warnings.push({
|
||||
type: 'missing_common',
|
||||
message: 'Set node has no fields configured - will output empty items',
|
||||
suggestion: 'Add fields in the Values section or use JSON mode'
|
||||
suggestion: 'Add field assignments or use JSON mode'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { validateAISpecificNodes, hasAINodes, AI_CONNECTION_TYPES } from './ai-n
|
||||
import { isAIToolSubNode } from './ai-tool-validators';
|
||||
import { isTriggerNode } from '../utils/node-type-utils';
|
||||
import { isNonExecutableNode } from '../utils/node-classification';
|
||||
import { validateConditionNodeStructure } from './n8n-validation';
|
||||
import { ToolVariantGenerator } from './tool-variant-generator';
|
||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||
|
||||
@@ -579,6 +580,19 @@ export class WorkflowValidator {
|
||||
});
|
||||
});
|
||||
|
||||
// Validate If/Switch conditions structure (version-conditional)
|
||||
if (node.type === 'n8n-nodes-base.if' || node.type === 'n8n-nodes-base.switch') {
|
||||
const conditionErrors = validateConditionNodeStructure(node as any);
|
||||
for (const err of conditionErrors) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
|
||||
@@ -105,6 +105,78 @@ describe('ExpressionValidator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -2384,6 +2384,73 @@ return [{"json": {"result": result}}]
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSet', () => {
|
||||
it('should not warn when Set v3 has populated assignments', () => {
|
||||
context.config = {
|
||||
mode: 'manual',
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: '1', name: 'status', value: 'active', type: 'string' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
|
||||
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||
expect(fieldWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not warn when Set v2 has populated values', () => {
|
||||
context.config = {
|
||||
mode: 'manual',
|
||||
values: {
|
||||
string: [{ name: 'field', value: 'val' }]
|
||||
}
|
||||
};
|
||||
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
|
||||
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||
expect(fieldWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should warn when Set v3 has empty assignments array', () => {
|
||||
context.config = {
|
||||
mode: 'manual',
|
||||
assignments: { assignments: [] }
|
||||
};
|
||||
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
|
||||
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||
expect(fieldWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should warn when Set manual mode has no values or assignments', () => {
|
||||
context.config = {
|
||||
mode: 'manual'
|
||||
};
|
||||
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
|
||||
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||
expect(fieldWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not warn when Set manual mode has jsonOutput', () => {
|
||||
context.config = {
|
||||
mode: 'manual',
|
||||
jsonOutput: '{"key":"value"}'
|
||||
};
|
||||
|
||||
NodeSpecificValidators.validateSet(context);
|
||||
|
||||
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
|
||||
expect(fieldWarnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAIAgent', () => {
|
||||
let context: NodeValidationContext;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NodeRepository } from '@/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||
import { ExpressionValidator } from '@/services/expression-validator';
|
||||
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
|
||||
import { validateConditionNodeStructure } from '@/services/n8n-validation';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/database/node-repository');
|
||||
@@ -743,4 +744,156 @@ describe('WorkflowValidator', () => {
|
||||
expect(result.statistics.validConnections).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── If/Switch conditions validation ──────────────────────────────
|
||||
|
||||
describe('If/Switch conditions validation (validateConditionNodeStructure)', () => {
|
||||
it('If v2.3 missing conditions.options → error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('options'))).toBe(true);
|
||||
});
|
||||
|
||||
it('If v2.3 with complete options → no error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('If v2.0 without options → no error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('If v2.0 with bad operator (missing type) → operator error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('type'))).toBe(true);
|
||||
});
|
||||
|
||||
it('If v1 with old format → no errors', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: { string: [{ value1: '={{ $json.x }}', value2: 'a', operation: 'equals' }] }
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Switch v3.2 missing rule options → error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
rules: [{
|
||||
conditions: {
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
},
|
||||
outputKey: 'Branch 1'
|
||||
}]
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.includes('rules.rules[0].conditions.options'))).toBe(true);
|
||||
});
|
||||
|
||||
it('Switch v3.2 with complete options → no error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
rules: [{
|
||||
conditions: {
|
||||
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
},
|
||||
outputKey: 'Branch 1'
|
||||
}]
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('If v2.2 with empty parameters (missing conditions) → no error (graceful)', () => {
|
||||
const node = {
|
||||
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.2,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
// Empty parameters are allowed — draft/incomplete nodes are valid at this level
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Switch v3.0 without options → no error', () => {
|
||||
const node = {
|
||||
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.0,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
rules: [{
|
||||
conditions: {
|
||||
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
|
||||
combinator: 'and'
|
||||
},
|
||||
outputKey: 'Branch 1'
|
||||
}]
|
||||
}
|
||||
}
|
||||
};
|
||||
const errors = validateConditionNodeStructure(node);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user