diff --git a/packages/server/src/services/validation/index.ts b/packages/server/src/services/validation/index.ts index 85cde6467d0..c7cb7fba603 100644 --- a/packages/server/src/services/validation/index.ts +++ b/packages/server/src/services/validation/index.ts @@ -4,317 +4,323 @@ import { getErrorMessage } from '../../errors/utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { ChatFlow } from '../../database/entities/ChatFlow' import { INodeParams } from 'flowise-components' -import { IReactFlowEdge, IReactFlowNode } from '../../Interface' +import { IComponentNodes, IReactFlowEdge, IReactFlowNode, IReactFlowObject } from '../../Interface' -interface IValidationResult { +export interface IValidationResult { id: string label: string name: string issues: string[] } -const checkFlowValidation = async (flowId: string, workspaceId?: string): Promise => { - try { - const appServer = getRunningExpressApp() - - const componentNodes = appServer.nodesPool.componentNodes - - // Create query conditions with workspace filtering if provided - const whereCondition: any = { id: flowId } - if (workspaceId) whereCondition.workspaceId = workspaceId - - const flow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ - where: whereCondition - }) - - if (!flow) { - throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Error: validationService.checkFlowValidation - flow not found!`) +/** + * Pure validation logic that checks flow data for structural issues. + * Operates on already-parsed nodes/edges — no DB or network access. + */ +export const validateFlowData = ( + nodes: IReactFlowNode[], + edges: IReactFlowEdge[], + componentNodes: IComponentNodes +): IValidationResult[] => { + const validationResults: IValidationResult[] = [] + + // Create a map of connected nodes + const connectedNodes = new Set() + edges.forEach((edge: IReactFlowEdge) => { + connectedNodes.add(edge.source) + connectedNodes.add(edge.target) + }) + + // Validate each node + for (const node of nodes) { + if (node.data.name === 'stickyNoteAgentflow') continue + + const nodeIssues: string[] = [] + + // Check if node is connected + if (!connectedNodes.has(node.id)) { + nodeIssues.push('This node is not connected to anything') } - const flowData = JSON.parse(flow.flowData) - const nodes = flowData.nodes - const edges = flowData.edges - - // Store validation results - const validationResults = [] - - // Create a map of connected nodes - const connectedNodes = new Set() - edges.forEach((edge: IReactFlowEdge) => { - connectedNodes.add(edge.source) - connectedNodes.add(edge.target) - }) - - // Validate each node - for (const node of nodes) { - if (node.data.name === 'stickyNoteAgentflow') continue - - const nodeIssues = [] - - // Check if node is connected - if (!connectedNodes.has(node.id)) { - nodeIssues.push('This node is not connected to anything') - } - - // Validate input parameters - if (node.data && node.data.inputParams && node.data.inputs) { - for (const param of node.data.inputParams) { - // Skip validation if the parameter has show condition that doesn't match - if (param.show) { - let shouldShow = true - for (const [key, value] of Object.entries(param.show)) { - if (node.data.inputs[key] !== value) { - shouldShow = false - break - } + // Validate input parameters + if (node.data && node.data.inputParams && node.data.inputs) { + for (const param of node.data.inputParams) { + // Skip validation if the parameter has show condition that doesn't match + if (param.show) { + let shouldShow = true + for (const [key, value] of Object.entries(param.show)) { + if (node.data.inputs[key] !== value) { + shouldShow = false + break } - if (!shouldShow) continue } + if (!shouldShow) continue + } - // Skip validation if the parameter has hide condition that matches - if (param.hide) { - let shouldHide = true - for (const [key, value] of Object.entries(param.hide)) { - if (node.data.inputs[key] !== value) { - shouldHide = false - break - } + // Skip validation if the parameter has hide condition that matches + if (param.hide) { + let shouldHide = true + for (const [key, value] of Object.entries(param.hide)) { + if (node.data.inputs[key] !== value) { + shouldHide = false + break } - if (shouldHide) continue } + if (shouldHide) continue + } - // Check if required parameter has a value - if (!param.optional) { - const inputValue = node.data.inputs[param.name] - if (inputValue === undefined || inputValue === null || inputValue === '') { - nodeIssues.push(`${param.label} is required`) - } + // Check if required parameter has a value + if (!param.optional) { + const inputValue = node.data.inputs[param.name] + if (inputValue === undefined || inputValue === null || inputValue === '') { + nodeIssues.push(`${param.label} is required`) } + } - // Check array type parameters (even if the array itself is optional) - if (param.type === 'array' && Array.isArray(node.data.inputs[param.name])) { - const inputValue = node.data.inputs[param.name] - - // Only validate non-empty arrays (if array is required but empty, it's caught above) - if (inputValue.length > 0) { - // Check each item in the array - inputValue.forEach((item: Record, index: number) => { - if (param.array) { - param.array.forEach((arrayParam: INodeParams) => { - // Evaluate if this parameter should be shown based on current values - // First check show conditions - let shouldValidate = true - - if (arrayParam.show) { - // Default to not showing unless conditions match - shouldValidate = false - - // Each key in show is a condition that must be satisfied - for (const [conditionKey, expectedValue] of Object.entries(arrayParam.show)) { - const isIndexCondition = conditionKey.includes('$index') - let actualValue - - if (isIndexCondition) { - // Replace $index with actual index and evaluate - const normalizedKey = conditionKey.replace(/conditions\[\$index\]\.(\w+)/, '$1') - actualValue = item[normalizedKey] - } else { - // Direct property in the current item - actualValue = item[conditionKey] - } - - // Check if condition is satisfied - let conditionMet = false - if (Array.isArray(expectedValue)) { - conditionMet = expectedValue.includes(actualValue) - } else { - conditionMet = actualValue === expectedValue - } - - if (conditionMet) { - shouldValidate = true - break // One matching condition is enough - } + // Check array type parameters (even if the array itself is optional) + if (param.type === 'array' && Array.isArray(node.data.inputs[param.name])) { + const inputValue = node.data.inputs[param.name] + + // Only validate non-empty arrays (if array is required but empty, it's caught above) + if (inputValue.length > 0) { + // Check each item in the array + inputValue.forEach((item: Record, index: number) => { + if (param.array) { + param.array.forEach((arrayParam: INodeParams) => { + // Evaluate if this parameter should be shown based on current values + // First check show conditions + let shouldValidate = true + + if (arrayParam.show) { + // Default to not showing unless conditions match + shouldValidate = false + + // Each key in show is a condition that must be satisfied + for (const [conditionKey, expectedValue] of Object.entries(arrayParam.show)) { + const isIndexCondition = conditionKey.includes('$index') + let actualValue + + if (isIndexCondition) { + // Replace $index with actual index and evaluate + const normalizedKey = conditionKey.replace(/conditions\[\$index\]\.(\w+)/, '$1') + actualValue = item[normalizedKey] + } else { + // Direct property in the current item + actualValue = item[conditionKey] } - } - // Then check hide conditions (they override show conditions) - if (shouldValidate && arrayParam.hide) { - for (const [conditionKey, expectedValue] of Object.entries(arrayParam.hide)) { - const isIndexCondition = conditionKey.includes('$index') - let actualValue - - if (isIndexCondition) { - // Replace $index with actual index and evaluate - const normalizedKey = conditionKey.replace(/conditions\[\$index\]\.(\w+)/, '$1') - actualValue = item[normalizedKey] - } else { - // Direct property in the current item - actualValue = item[conditionKey] - } - - // Check if hide condition is met - let shouldHide = false - if (Array.isArray(expectedValue)) { - shouldHide = expectedValue.includes(actualValue) - } else { - shouldHide = actualValue === expectedValue - } - - if (shouldHide) { - shouldValidate = false - break // One matching hide condition is enough to hide - } + // Check if condition is satisfied + let conditionMet = false + if (Array.isArray(expectedValue)) { + conditionMet = expectedValue.includes(actualValue) + } else { + conditionMet = actualValue === expectedValue } - } - // Only validate if field should be shown - if (shouldValidate) { - // Check if value is required and missing - if ( - (arrayParam.optional === undefined || !arrayParam.optional) && - (item[arrayParam.name] === undefined || - item[arrayParam.name] === null || - item[arrayParam.name] === '' || - item[arrayParam.name] === '

') - ) { - nodeIssues.push(`${param.label} item #${index + 1}: ${arrayParam.label} is required`) + if (conditionMet) { + shouldValidate = true + break // One matching condition is enough } } - }) - } - }) - } - } + } - // Check for credential requirements - if (param.name === 'credential' && !param.optional) { - const credentialValue = node.data.inputs[param.name] - if (!credentialValue) { - nodeIssues.push(`Credential is required`) - } - } + // Then check hide conditions (they override show conditions) + if (shouldValidate && arrayParam.hide) { + for (const [conditionKey, expectedValue] of Object.entries(arrayParam.hide)) { + const isIndexCondition = conditionKey.includes('$index') + let actualValue + + if (isIndexCondition) { + // Replace $index with actual index and evaluate + const normalizedKey = conditionKey.replace(/conditions\[\$index\]\.(\w+)/, '$1') + actualValue = item[normalizedKey] + } else { + // Direct property in the current item + actualValue = item[conditionKey] + } + + // Check if hide condition is met + let shouldHide = false + if (Array.isArray(expectedValue)) { + shouldHide = expectedValue.includes(actualValue) + } else { + shouldHide = actualValue === expectedValue + } - // Check for nested config parameters - const configKey = `${param.name}Config` - if (node.data.inputs[configKey] && node.data.inputs[param.name]) { - const componentName = node.data.inputs[param.name] - const configValue = node.data.inputs[configKey] - - // Check if the component exists in the componentNodes pool - if (componentNodes[componentName] && componentNodes[componentName].inputs) { - const componentInputParams = componentNodes[componentName].inputs - - // Validate each required input parameter in the component - for (const componentParam of componentInputParams) { - // Skip validation if the parameter has show condition that doesn't match - if (componentParam.show) { - let shouldShow = true - for (const [key, value] of Object.entries(componentParam.show)) { - if (configValue[key] !== value) { - shouldShow = false - break + if (shouldHide) { + shouldValidate = false + break // One matching hide condition is enough to hide + } } } - if (!shouldShow) continue - } - // Skip validation if the parameter has hide condition that matches - if (componentParam.hide) { - let shouldHide = true - for (const [key, value] of Object.entries(componentParam.hide)) { - if (configValue[key] !== value) { - shouldHide = false - break + // Only validate if field should be shown + if (shouldValidate) { + // Check if value is required and missing + if ( + (arrayParam.optional === undefined || !arrayParam.optional) && + (item[arrayParam.name] === undefined || + item[arrayParam.name] === null || + item[arrayParam.name] === '' || + item[arrayParam.name] === '

') + ) { + nodeIssues.push(`${param.label} item #${index + 1}: ${arrayParam.label} is required`) } } - if (shouldHide) continue + }) + } + }) + } + } + + // Check for credential requirements + if (param.name === 'credential' && !param.optional) { + const credentialValue = node.data.inputs[param.name] + if (!credentialValue) { + nodeIssues.push(`Credential is required`) + } + } + + // Check for nested config parameters + const configKey = `${param.name}Config` + if (node.data.inputs[configKey] && node.data.inputs[param.name]) { + const componentName = node.data.inputs[param.name] + const configValue = node.data.inputs[configKey] + + // Check if the component exists in the componentNodes pool + if (componentNodes[componentName] && componentNodes[componentName].inputs) { + const componentInputParams = componentNodes[componentName].inputs + + // Validate each required input parameter in the component + for (const componentParam of componentInputParams) { + // Skip validation if the parameter has show condition that doesn't match + if (componentParam.show) { + let shouldShow = true + for (const [key, value] of Object.entries(componentParam.show)) { + if (configValue[key] !== value) { + shouldShow = false + break + } } + if (!shouldShow) continue + } - if (!componentParam.optional) { - const nestedValue = configValue[componentParam.name] - if (nestedValue === undefined || nestedValue === null || nestedValue === '') { - nodeIssues.push(`${param.label} configuration: ${componentParam.label} is required`) + // Skip validation if the parameter has hide condition that matches + if (componentParam.hide) { + let shouldHide = true + for (const [key, value] of Object.entries(componentParam.hide)) { + if (configValue[key] !== value) { + shouldHide = false + break } } + if (shouldHide) continue } - // Check for credential requirement in the component - if (componentNodes[componentName].credential && !componentNodes[componentName].credential.optional) { - if (!configValue.FLOWISE_CREDENTIAL_ID && !configValue.credential) { - nodeIssues.push(`${param.label} requires a credential`) + if (!componentParam.optional) { + const nestedValue = configValue[componentParam.name] + if (nestedValue === undefined || nestedValue === null || nestedValue === '') { + nodeIssues.push(`${param.label} configuration: ${componentParam.label} is required`) } } } + + // Check for credential requirement in the component + if (componentNodes[componentName].credential && !componentNodes[componentName].credential.optional) { + if (!configValue.FLOWISE_CREDENTIAL_ID && !configValue.credential) { + nodeIssues.push(`${param.label} requires a credential`) + } + } } } } + } - // Add node to validation results if it has issues - if (nodeIssues.length > 0) { - validationResults.push({ - id: node.id, - label: node.data.label, - name: node.data.name, - issues: nodeIssues - }) - } + // Add node to validation results if it has issues + if (nodeIssues.length > 0) { + validationResults.push({ + id: node.id, + label: node.data.label, + name: node.data.name, + issues: nodeIssues + }) } + } - // Check for hanging edges - for (const edge of edges) { - const sourceExists = nodes.some((node: IReactFlowNode) => node.id === edge.source) - const targetExists = nodes.some((node: IReactFlowEdge) => node.id === edge.target) - - if (!sourceExists || !targetExists) { - // Find the existing node that is connected to this hanging edge - if (!sourceExists && targetExists) { - // Target exists but source doesn't - add issue to target node - const targetNode = nodes.find((node: IReactFlowNode) => node.id === edge.target) - const targetNodeResult = validationResults.find((result) => result.id === edge.target) - - if (targetNodeResult) { - // Add to existing validation result - targetNodeResult.issues.push(`Connected to non-existent source node ${edge.source}`) - } else { - // Create new validation result for this node - validationResults.push({ - id: targetNode.id, - label: targetNode.data.label, - name: targetNode.data.name, - issues: [`Connected to non-existent source node ${edge.source}`] - }) - } - } else if (sourceExists && !targetExists) { - // Source exists but target doesn't - add issue to source node - const sourceNode = nodes.find((node: IReactFlowNode) => node.id === edge.source) - const sourceNodeResult = validationResults.find((result) => result.id === edge.source) - - if (sourceNodeResult) { - // Add to existing validation result - sourceNodeResult.issues.push(`Connected to non-existent target node ${edge.target}`) - } else { - // Create new validation result for this node - validationResults.push({ - id: sourceNode.id, - label: sourceNode.data.label, - name: sourceNode.data.name, - issues: [`Connected to non-existent target node ${edge.target}`] - }) - } + // Check for hanging edges + for (const edge of edges) { + const sourceExists = nodes.some((node: IReactFlowNode) => node.id === edge.source) + const targetExists = nodes.some((node: IReactFlowNode) => node.id === edge.target) + + if (!sourceExists || !targetExists) { + // Find the existing node that is connected to this hanging edge + if (!sourceExists && targetExists) { + // Target exists but source doesn't - add issue to target node + const targetNode = nodes.find((node: IReactFlowNode) => node.id === edge.target)! + const targetNodeResult = validationResults.find((result) => result.id === edge.target) + + if (targetNodeResult) { + // Add to existing validation result + targetNodeResult.issues.push(`Connected to non-existent source node ${edge.source}`) + } else { + // Create new validation result for this node + validationResults.push({ + id: targetNode.id, + label: targetNode.data.label, + name: targetNode.data.name, + issues: [`Connected to non-existent source node ${edge.source}`] + }) + } + } else if (sourceExists && !targetExists) { + // Source exists but target doesn't - add issue to source node + const sourceNode = nodes.find((node: IReactFlowNode) => node.id === edge.source)! + const sourceNodeResult = validationResults.find((result) => result.id === edge.source) + + if (sourceNodeResult) { + // Add to existing validation result + sourceNodeResult.issues.push(`Connected to non-existent target node ${edge.target}`) } else { - // Both source and target don't exist - create a generic edge issue + // Create new validation result for this node validationResults.push({ - id: edge.id, - label: `Edge ${edge.id}`, - name: 'edge', - issues: ['Disconnected edge - both source and target nodes do not exist'] + id: sourceNode.id, + label: sourceNode.data.label, + name: sourceNode.data.name, + issues: [`Connected to non-existent target node ${edge.target}`] }) } + } else { + // Both source and target don't exist - create a generic edge issue + validationResults.push({ + id: edge.id, + label: `Edge ${edge.id}`, + name: 'edge', + issues: ['Disconnected edge - both source and target nodes do not exist'] + }) } } + } + + return validationResults +} + +const checkFlowValidation = async (flowId: string, workspaceId?: string): Promise => { + try { + const appServer = getRunningExpressApp() + + // Create query conditions with workspace filtering if provided + const whereCondition: any = { id: flowId } + if (workspaceId) whereCondition.workspaceId = workspaceId + + const flow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: whereCondition + }) + + if (!flow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Error: validationService.checkFlowValidation - flow not found!`) + } - return validationResults + const flowData: IReactFlowObject = JSON.parse(flow.flowData) + return validateFlowData(flowData.nodes, flowData.edges, appServer.nodesPool.componentNodes) } catch (error) { throw new InternalFlowiseError( StatusCodes.INTERNAL_SERVER_ERROR, diff --git a/packages/server/src/services/validation/validateFlowData.test.ts b/packages/server/src/services/validation/validateFlowData.test.ts new file mode 100644 index 00000000000..7bf792377cb --- /dev/null +++ b/packages/server/src/services/validation/validateFlowData.test.ts @@ -0,0 +1,246 @@ +import { validateFlowData, IValidationResult } from './index' +import type { IComponentNodes, IReactFlowNode, IReactFlowEdge } from '../../Interface' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeNode = (id: string, name: string, inputParams: any[] = [], inputs: Record = {}, label?: string): IReactFlowNode => + ({ + id, + data: { name, label: label ?? name, inputParams, inputs } + } as unknown as IReactFlowNode) + +const makeEdge = (source: string, target: string): IReactFlowEdge => + ({ + id: `e-${source}-${target}`, + source, + target, + sourceHandle: '', + targetHandle: '', + type: 'default', + data: { label: '' } + } as IReactFlowEdge) + +const emptyComponentNodes: IComponentNodes = {} + +const issuesFor = (results: IValidationResult[], nodeId: string): string[] => { + return results.find((r) => r.id === nodeId)?.issues ?? [] +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('validateFlowData', () => { + it('returns empty array for a valid connected flow', () => { + const nodes = [ + makeNode('n1', 'chatAgent', [{ name: 'model', label: 'Model', optional: true }], { model: 'gpt-4' }), + makeNode('n2', 'llm', [{ name: 'key', label: 'Key', optional: true }], { key: 'abc' }) + ] + const edges = [makeEdge('n1', 'n2')] + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(results).toEqual([]) + }) + + // --- Unconnected nodes --- + + it('flags an unconnected node', () => { + const nodes = [makeNode('n1', 'chatAgent'), makeNode('n2', 'llm')] + const edges = [makeEdge('n1', 'n2')] + const orphan = makeNode('n3', 'tool') + nodes.push(orphan) + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n3')).toContain('This node is not connected to anything') + }) + + it('skips stickyNoteAgentflow nodes', () => { + const nodes = [makeNode('n1', 'stickyNoteAgentflow')] + const results = validateFlowData(nodes, [], emptyComponentNodes) + expect(results).toEqual([]) + }) + + // --- Required parameters --- + + it('flags missing required parameter', () => { + const nodes = [makeNode('n1', 'chatAgent', [{ name: 'model', label: 'Model' }], {})] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Model is required') + }) + + it('does not flag optional parameter', () => { + const nodes = [makeNode('n1', 'chatAgent', [{ name: 'model', label: 'Model', optional: true }], {})] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(results).toEqual([]) + }) + + it('flags empty string as missing', () => { + const nodes = [makeNode('n1', 'chatAgent', [{ name: 'model', label: 'Model' }], { model: '' })] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Model is required') + }) + + // --- Show / Hide conditions --- + + it('skips validation when show condition is not met', () => { + const nodes = [ + makeNode( + 'n1', + 'api', + [{ name: 'body', label: 'Body', show: { bodyType: 'raw' } }], + { bodyType: 'formData' } // show condition NOT met + ) + ] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(results).toEqual([]) + }) + + it('validates when show condition is met', () => { + const nodes = [makeNode('n1', 'api', [{ name: 'body', label: 'Body', show: { bodyType: 'raw' } }], { bodyType: 'raw' })] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Body is required') + }) + + it('skips validation when hide condition is met', () => { + const nodes = [makeNode('n1', 'api', [{ name: 'body', label: 'Body', hide: { bodyType: 'none' } }], { bodyType: 'none' })] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(results).toEqual([]) + }) + + // --- Credential requirements --- + + it('flags missing required credential', () => { + const nodes = [makeNode('n1', 'chatAgent', [{ name: 'credential', label: 'Credential' }], {})] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Credential is required') + }) + + // --- Array type parameters --- + + it('flags missing required fields in array items', () => { + const nodes = [ + makeNode( + 'n1', + 'conditionAgent', + [ + { + name: 'conditions', + label: 'Conditions', + type: 'array', + optional: true, + array: [{ name: 'field', label: 'Field' }] + } + ], + { conditions: [{ field: '' }] } + ) + ] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Conditions item #1: Field is required') + }) + + // --- Nested config parameters --- + + it('flags missing required nested config fields', () => { + const componentNodes: IComponentNodes = { + openAIEmbeddings: { + inputs: [{ name: 'apiKey', label: 'API Key' }] + } as any + } + const nodes = [ + makeNode('n1', 'vectorStore', [{ name: 'embedding', label: 'Embedding' }], { + embedding: 'openAIEmbeddings', + embeddingConfig: {} + }) + ] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, componentNodes) + expect(issuesFor(results, 'n1')).toContain('Embedding configuration: API Key is required') + }) + + it('flags missing credential in nested component', () => { + const componentNodes: IComponentNodes = { + openAIEmbeddings: { + inputs: [], + credential: { name: 'credential', label: 'Credential' } + } as any + } + const nodes = [ + makeNode('n1', 'vectorStore', [{ name: 'embedding', label: 'Embedding' }], { + embedding: 'openAIEmbeddings', + embeddingConfig: {} + }) + ] + const edges = [makeEdge('n1', 'n1')] + + const results = validateFlowData(nodes, edges, componentNodes) + expect(issuesFor(results, 'n1')).toContain('Embedding requires a credential') + }) + + // --- Hanging edges --- + + it('flags edge with non-existent source', () => { + const nodes = [makeNode('n2', 'llm')] + const edges = [makeEdge('ghost', 'n2')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n2')).toContain('Connected to non-existent source node ghost') + }) + + it('flags edge with non-existent target', () => { + const nodes = [makeNode('n1', 'chatAgent')] + const edges = [makeEdge('n1', 'ghost')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + expect(issuesFor(results, 'n1')).toContain('Connected to non-existent target node ghost') + }) + + it('flags edge where both source and target are missing', () => { + const nodes = [makeNode('n1', 'chatAgent')] + const edges = [makeEdge('n1', 'n1'), makeEdge('ghostA', 'ghostB')] + + const results = validateFlowData(nodes, edges, emptyComponentNodes) + const edgeResult = results.find((r) => r.name === 'edge') + expect(edgeResult).toBeDefined() + expect(edgeResult!.issues).toContain('Disconnected edge - both source and target nodes do not exist') + }) + + // --- Multiple issues on one node --- + + it('collects multiple issues on the same node', () => { + const nodes = [ + makeNode( + 'n1', + 'chatAgent', + [ + { name: 'model', label: 'Model' }, + { name: 'temperature', label: 'Temperature' } + ], + {} + ) + ] + // n1 is not connected + has 2 missing required params + const results = validateFlowData(nodes, [], emptyComponentNodes) + const issues = issuesFor(results, 'n1') + expect(issues).toContain('This node is not connected to anything') + expect(issues).toContain('Model is required') + expect(issues).toContain('Temperature is required') + }) +})