mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 13:33:11 +00:00
fix: resolve issue #90 - prevent 'propertyValues[itemName] is not iterable' error
- Add validation for invalid fixedCollection structures in Switch, If, and Filter nodes - Detect and prevent nested 'conditions.values' patterns that cause n8n UI crashes - Support both 'n8n-nodes-base.x' and 'nodes-base.x' node type formats - Provide auto-fix suggestions for invalid structures - Add comprehensive test coverage for all edge cases This prevents AI agents from creating invalid node configurations that break n8n's UI.
This commit is contained in:
413
tests/unit/services/workflow-fixed-collection-validation.test.ts
Normal file
413
tests/unit/services/workflow-fixed-collection-validation.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Workflow Fixed Collection Validation Tests
|
||||
* Tests that workflow validation catches fixedCollection structure errors at the workflow level
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { WorkflowValidator } from '../../../src/services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../../../src/database/node-repository';
|
||||
|
||||
describe('Workflow FixedCollection Validation', () => {
|
||||
let validator: WorkflowValidator;
|
||||
let mockNodeRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock repository that returns basic node info for common nodes
|
||||
mockNodeRepository = {
|
||||
getNode: vi.fn().mockImplementation((type: string) => {
|
||||
const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', '');
|
||||
switch (normalizedType) {
|
||||
case 'webhook':
|
||||
return {
|
||||
nodeType: 'nodes-base.webhook',
|
||||
displayName: 'Webhook',
|
||||
properties: [
|
||||
{ name: 'path', type: 'string', required: true },
|
||||
{ name: 'httpMethod', type: 'options' }
|
||||
]
|
||||
};
|
||||
case 'switch':
|
||||
return {
|
||||
nodeType: 'nodes-base.switch',
|
||||
displayName: 'Switch',
|
||||
properties: [
|
||||
{ name: 'rules', type: 'fixedCollection', required: true }
|
||||
]
|
||||
};
|
||||
case 'if':
|
||||
return {
|
||||
nodeType: 'nodes-base.if',
|
||||
displayName: 'If',
|
||||
properties: [
|
||||
{ name: 'conditions', type: 'filter', required: true }
|
||||
]
|
||||
};
|
||||
case 'filter':
|
||||
return {
|
||||
nodeType: 'nodes-base.filter',
|
||||
displayName: 'Filter',
|
||||
properties: [
|
||||
{ name: 'conditions', type: 'filter', required: true }
|
||||
]
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||
});
|
||||
|
||||
test('should catch invalid Switch node structure in workflow validation', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow with Invalid Switch',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'switch',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
// This is the problematic structure that causes "propertyValues[itemName] is not iterable"
|
||||
rules: {
|
||||
conditions: {
|
||||
values: [
|
||||
{
|
||||
value1: '={{$json.status}}',
|
||||
operation: 'equals',
|
||||
value2: 'active'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
|
||||
const switchError = result.errors.find(e => e.nodeId === 'switch');
|
||||
expect(switchError).toBeDefined();
|
||||
expect(switchError!.message).toContain('propertyValues[itemName] is not iterable');
|
||||
expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node');
|
||||
});
|
||||
|
||||
test('should catch invalid If node structure in workflow validation', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow with Invalid If',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'if',
|
||||
name: 'If',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
// This is the problematic structure
|
||||
conditions: {
|
||||
values: [
|
||||
{
|
||||
value1: '={{$json.age}}',
|
||||
operation: 'largerEqual',
|
||||
value2: 18
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'If', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
|
||||
const ifError = result.errors.find(e => e.nodeId === 'if');
|
||||
expect(ifError).toBeDefined();
|
||||
expect(ifError!.message).toContain('Invalid structure for nodes-base.if node');
|
||||
});
|
||||
|
||||
test('should accept valid Switch node structure in workflow validation', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow with Valid Switch',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'switch',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
// This is the correct structure
|
||||
rules: {
|
||||
values: [
|
||||
{
|
||||
conditions: {
|
||||
value1: '={{$json.status}}',
|
||||
operation: 'equals',
|
||||
value2: 'active'
|
||||
},
|
||||
outputKey: 'active'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
// Should not have fixedCollection structure errors
|
||||
const hasFixedCollectionError = result.errors.some(e =>
|
||||
e.message.includes('propertyValues[itemName] is not iterable')
|
||||
);
|
||||
expect(hasFixedCollectionError).toBe(false);
|
||||
});
|
||||
|
||||
test('should catch multiple fixedCollection errors in a single workflow', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow with Multiple Invalid Structures',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'switch',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
conditions: {
|
||||
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'if',
|
||||
name: 'If',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [400, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
name: 'Filter',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
position: [600, 0] as [number, number],
|
||||
parameters: {
|
||||
conditions: {
|
||||
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||
},
|
||||
Switch: {
|
||||
main: [
|
||||
[{ node: 'If', type: 'main', index: 0 }],
|
||||
[{ node: 'Filter', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node
|
||||
|
||||
// Check that each problematic node has an error
|
||||
const switchError = result.errors.find(e => e.nodeId === 'switch');
|
||||
const ifError = result.errors.find(e => e.nodeId === 'if');
|
||||
const filterError = result.errors.find(e => e.nodeId === 'filter');
|
||||
|
||||
expect(switchError).toBeDefined();
|
||||
expect(ifError).toBeDefined();
|
||||
expect(filterError).toBeDefined();
|
||||
});
|
||||
|
||||
test('should provide helpful statistics about fixedCollection errors', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow Statistics',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'test' }
|
||||
},
|
||||
{
|
||||
id: 'bad-switch',
|
||||
name: 'Bad Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'good-switch',
|
||||
name: 'Good Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [400, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [
|
||||
[{ node: 'Bad Switch', type: 'main', index: 0 }],
|
||||
[{ node: 'Good Switch', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
expect(result.statistics.totalNodes).toBe(3);
|
||||
expect(result.statistics.enabledNodes).toBe(3);
|
||||
expect(result.valid).toBe(false); // Should be invalid due to the bad switch
|
||||
|
||||
// Should have at least one error for the bad switch
|
||||
const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch');
|
||||
expect(badSwitchError).toBeDefined();
|
||||
|
||||
// Should not have errors for the good switch or webhook
|
||||
const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch');
|
||||
const webhookError = result.errors.find(e => e.nodeId === 'webhook');
|
||||
|
||||
// These might have other validation errors, but not fixedCollection errors
|
||||
if (goodSwitchError) {
|
||||
expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable');
|
||||
}
|
||||
if (webhookError) {
|
||||
expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable');
|
||||
}
|
||||
});
|
||||
|
||||
test('should work with different validation profiles', async () => {
|
||||
const workflow = {
|
||||
name: 'Test Profile Compatibility',
|
||||
nodes: [
|
||||
{
|
||||
id: 'switch',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {
|
||||
rules: {
|
||||
conditions: {
|
||||
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
|
||||
['strict', 'runtime', 'ai-friendly', 'minimal'];
|
||||
|
||||
for (const profile of profiles) {
|
||||
const result = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile
|
||||
});
|
||||
|
||||
// All profiles should catch this critical error
|
||||
const hasCriticalError = result.errors.some(e =>
|
||||
e.message.includes('propertyValues[itemName] is not iterable')
|
||||
);
|
||||
|
||||
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
|
||||
expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user