From 20ebfbb0fc268f4a6e6df6f37d3968437c193a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Romuald=20Cz=C5=82onkowski?= <56956555+czlonkowski@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:29:04 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20validation=20bugs=20=E2=80=94=20If/Switc?= =?UTF-8?q?h=20version=20check,=20Set=20false=20positive,=20bare=20express?= =?UTF-8?q?ions=20(#675,=20#676,=20#677)=20(#678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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) --- CHANGELOG.md | 12 ++ package.json | 2 +- src/services/expression-validator.ts | 41 +++++ src/services/n8n-validation.ts | 159 ++++++++---------- src/services/node-specific-validators.ts | 8 +- src/services/workflow-validator.ts | 14 ++ .../services/expression-validator.test.ts | 72 ++++++++ .../services/node-specific-validators.test.ts | 67 ++++++++ .../unit/services/workflow-validator.test.ts | 153 +++++++++++++++++ 9 files changed, 433 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ec782..49d6129 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/package.json b/package.json index bb15947..c21e005 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/services/expression-validator.ts b/src/services/expression-validator.ts index 7a0f123..2d89e34 100644 --- a/src/services/expression-validator.ts +++ b/src/services/expression-validator.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); diff --git a/src/services/n8n-validation.ts b/src/services/n8n-validation.ts index 8a8e306..4aafa16 100644 --- a/src/services/n8n-validation.ts +++ b/src/services/n8n-validation.ts @@ -344,10 +344,10 @@ export function validateWorkflowStructure(workflow: Partial): 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?} diff --git a/src/services/node-specific-validators.ts b/src/services/node-specific-validators.ts index ef59998..8495198 100644 --- a/src/services/node-specific-validators.ts +++ b/src/services/node-specific-validators.ts @@ -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' }); } } diff --git a/src/services/workflow-validator.ts b/src/services/workflow-validator.ts index 9b6d740..4612c9a 100644 --- a/src/services/workflow-validator.ts +++ b/src/services/workflow-validator.ts @@ -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', diff --git a/tests/unit/services/expression-validator.test.ts b/tests/unit/services/expression-validator.test.ts index 51f9098..d9c8fa9 100644 --- a/tests/unit/services/expression-validator.test.ts +++ b/tests/unit/services/expression-validator.test.ts @@ -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); diff --git a/tests/unit/services/node-specific-validators.test.ts b/tests/unit/services/node-specific-validators.test.ts index cc5c5a2..595f62a 100644 --- a/tests/unit/services/node-specific-validators.test.ts +++ b/tests/unit/services/node-specific-validators.test.ts @@ -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; diff --git a/tests/unit/services/workflow-validator.test.ts b/tests/unit/services/workflow-validator.test.ts index 2bab631..b046cdb 100644 --- a/tests/unit/services/workflow-validator.test.ts +++ b/tests/unit/services/workflow-validator.test.ts @@ -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); + }); + }); });