diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
deleted file mode 100644
index 628a1f4..0000000
--- a/.github/workflows/benchmark.yml
+++ /dev/null
@@ -1,214 +0,0 @@
-name: Performance Benchmarks
-
-on:
- push:
- branches: [main, feat/comprehensive-testing-suite]
- paths-ignore:
- - '**.md'
- - '**.txt'
- - 'docs/**'
- - 'examples/**'
- - '.github/FUNDING.yml'
- - '.github/ISSUE_TEMPLATE/**'
- - '.github/pull_request_template.md'
- - '.gitignore'
- - 'LICENSE*'
- - 'ATTRIBUTION.md'
- - 'SECURITY.md'
- - 'CODE_OF_CONDUCT.md'
- pull_request:
- branches: [main]
- paths-ignore:
- - '**.md'
- - '**.txt'
- - 'docs/**'
- - 'examples/**'
- - '.github/FUNDING.yml'
- - '.github/ISSUE_TEMPLATE/**'
- - '.github/pull_request_template.md'
- - '.gitignore'
- - 'LICENSE*'
- - 'ATTRIBUTION.md'
- - 'SECURITY.md'
- - 'CODE_OF_CONDUCT.md'
- workflow_dispatch:
-
-permissions:
- # For PR comments
- pull-requests: write
- # For pushing to gh-pages branch
- contents: write
- # For deployment to GitHub Pages
- pages: write
- id-token: write
-
-jobs:
- benchmark:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- # Fetch all history for proper benchmark comparison
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: 'npm'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Build project
- run: npm run build
-
- - name: Run benchmarks
- run: npm run benchmark:ci
-
- - name: Format benchmark results
- run: node scripts/format-benchmark-results.js
-
- - name: Upload benchmark artifacts
- uses: actions/upload-artifact@v4
- with:
- name: benchmark-results
- path: |
- benchmark-results.json
- benchmark-results-formatted.json
- benchmark-summary.json
-
- # Ensure gh-pages branch exists
- - name: Check and create gh-pages branch
- run: |
- git fetch origin gh-pages:gh-pages 2>/dev/null || {
- echo "gh-pages branch doesn't exist. Creating it..."
- git checkout --orphan gh-pages
- git rm -rf .
- echo "# Benchmark Results" > README.md
- git add README.md
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git commit -m "Initial gh-pages commit"
- git push origin gh-pages
- git checkout ${{ github.ref_name }}
- }
-
- # Clean up workspace before benchmark action
- - name: Clean workspace
- run: |
- git add -A
- git stash || true
-
- # Store benchmark results and compare
- - name: Store benchmark result
- uses: benchmark-action/github-action-benchmark@v1
- continue-on-error: true
- id: benchmark
- with:
- name: n8n-mcp Benchmarks
- tool: 'customSmallerIsBetter'
- output-file-path: benchmark-results-formatted.json
- github-token: ${{ secrets.GITHUB_TOKEN }}
- auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- # Where to store benchmark data
- benchmark-data-dir-path: 'benchmarks'
- # Alert when performance regresses by 10%
- alert-threshold: '110%'
- # Comment on PR when regression is detected
- comment-on-alert: true
- alert-comment-cc-users: '@czlonkowski'
- # Summary always
- summary-always: true
- # Max number of data points to retain
- max-items-in-chart: 50
- fail-on-alert: false
-
- # Comment on PR with benchmark results
- - name: Comment PR with results
- uses: actions/github-script@v7
- if: github.event_name == 'pull_request'
- continue-on-error: true
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- try {
- const fs = require('fs');
- const summary = JSON.parse(fs.readFileSync('benchmark-summary.json', 'utf8'));
-
- // Format results for PR comment
- let comment = '## ๐ Performance Benchmark Results\n\n';
- comment += `๐ Run at: ${new Date(summary.timestamp).toLocaleString()}\n\n`;
- comment += '| Benchmark | Time | Ops/sec | Range |\n';
- comment += '|-----------|------|---------|-------|\n';
-
- // Group benchmarks by category
- const categories = {};
- for (const benchmark of summary.benchmarks) {
- const [category, ...nameParts] = benchmark.name.split(' - ');
- if (!categories[category]) categories[category] = [];
- categories[category].push({
- ...benchmark,
- shortName: nameParts.join(' - ')
- });
- }
-
- // Display by category
- for (const [category, benchmarks] of Object.entries(categories)) {
- comment += `\n### ${category}\n`;
- for (const benchmark of benchmarks) {
- comment += `| ${benchmark.shortName} | ${benchmark.time} | ${benchmark.opsPerSec} | ${benchmark.range} |\n`;
- }
- }
-
- // Add comparison link
- comment += '\n\n๐ [View historical benchmark trends](https://czlonkowski.github.io/n8n-mcp/benchmarks/)\n';
- comment += '\nโก Performance regressions >10% will be flagged automatically.\n';
-
- await github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body: comment
- });
- } catch (error) {
- console.error('Failed to create PR comment:', error.message);
- console.log('This is likely due to insufficient permissions for external PRs.');
- console.log('Benchmark results have been saved to artifacts instead.');
- }
-
- # Deploy benchmark results to GitHub Pages
- deploy:
- needs: benchmark
- if: github.ref == 'refs/heads/main'
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- ref: gh-pages
- continue-on-error: true
-
- # If gh-pages checkout failed, create a minimal structure
- - name: Ensure gh-pages content exists
- run: |
- if [ ! -f "index.html" ]; then
- echo "Creating minimal gh-pages structure..."
- mkdir -p benchmarks
- echo '
n8n-mcp Benchmarksn8n-mcp Benchmarks
Benchmark data will appear here after the first run.
' > index.html
- fi
-
- - name: Setup Pages
- uses: actions/configure-pages@v4
-
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v3
- with:
- path: '.'
-
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index daae80b..d5a6e9c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -133,23 +133,6 @@ jobs:
- name: Run type checking
run: npm run typecheck
- # Run benchmarks
- - name: Run benchmarks
- id: benchmarks
- run: npm run benchmark:ci
- continue-on-error: true
-
- # Upload benchmark results
- - name: Upload benchmark results
- if: always() && steps.benchmarks.outcome != 'skipped'
- uses: actions/upload-artifact@v4
- with:
- name: benchmark-results-${{ github.run_number }}-${{ github.run_attempt }}
- path: |
- benchmark-results.json
- retention-days: 30
- if-no-files-found: warn
-
# Create test report comment for PRs
- name: Create test report comment
if: github.event_name == 'pull_request' && always()
@@ -222,7 +205,6 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "- [Test Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
echo "- [Coverage Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
- echo "- [Benchmark Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
# Store test metadata
- name: Store test metadata
@@ -252,24 +234,21 @@ jobs:
path: test-metadata.json
retention-days: 30
- # Separate job to process and publish test results
+ # Publish test results as checks
publish-results:
needs: test
runs-on: ubuntu-latest
if: always()
permissions:
checks: write
- pull-requests: write
steps:
- uses: actions/checkout@v4
-
- # Download all artifacts
- - name: Download all artifacts
+
+ - name: Download test results
uses: actions/download-artifact@v4
with:
path: artifacts
-
- # Publish test results as checks
+
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
@@ -279,75 +258,4 @@ jobs:
path: 'artifacts/test-results-*/test-results/junit.xml'
reporter: java-junit
fail-on-error: false
- fail-on-empty: false
-
- # Create a combined artifact with all results
- - name: Create combined results artifact
- if: always()
- run: |
- mkdir -p combined-results
- cp -r artifacts/* combined-results/ 2>/dev/null || true
-
- # Create index file
- cat > combined-results/index.html << 'EOF'
-
-
-
- n8n-mcp Test Results
-
-
-
- n8n-mcp Test Results
-
-
-
-
-
-
Generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)
-
Run: #${{ github.run_number }} | SHA: ${{ github.sha }}
-
-
-
- EOF
-
- - name: Upload combined results
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: all-test-results-${{ github.run_number }}
- path: combined-results/
- retention-days: 90
\ No newline at end of file
+ fail-on-empty: false
\ No newline at end of file
diff --git a/package.json b/package.json
index d2be8d0..85fe917 100644
--- a/package.json
+++ b/package.json
@@ -92,10 +92,6 @@
"test:docker:security": "./scripts/test-docker-config.sh security",
"sanitize:templates": "node dist/scripts/sanitize-templates.js",
"db:rebuild": "node dist/scripts/rebuild-database.js",
- "benchmark": "vitest bench --config vitest.config.benchmark.ts",
- "benchmark:watch": "vitest bench --watch --config vitest.config.benchmark.ts",
- "benchmark:ui": "vitest bench --ui --config vitest.config.benchmark.ts",
- "benchmark:ci": "CI=true node scripts/run-benchmarks-ci.js",
"db:init": "node -e \"new (require('./dist/services/sqlite-storage-service').SQLiteStorageService)(); console.log('Database initialized')\"",
"docs:rebuild": "ts-node src/scripts/rebuild-database.ts",
"sync:runtime-version": "node scripts/sync-runtime-version.js",
diff --git a/scripts/compare-benchmarks.js b/scripts/compare-benchmarks.js
deleted file mode 100644
index 7486fea..0000000
--- a/scripts/compare-benchmarks.js
+++ /dev/null
@@ -1,260 +0,0 @@
-#!/usr/bin/env node
-import { readFileSync, existsSync, writeFileSync } from 'fs';
-import { resolve } from 'path';
-
-/**
- * Compare benchmark results between runs
- */
-class BenchmarkComparator {
- constructor() {
- this.threshold = 0.1; // 10% threshold for significant changes
- }
-
- loadBenchmarkResults(path) {
- if (!existsSync(path)) {
- return null;
- }
-
- try {
- return JSON.parse(readFileSync(path, 'utf-8'));
- } catch (error) {
- console.error(`Error loading benchmark results from ${path}:`, error);
- return null;
- }
- }
-
- compareBenchmarks(current, baseline) {
- const comparison = {
- timestamp: new Date().toISOString(),
- summary: {
- improved: 0,
- regressed: 0,
- unchanged: 0,
- added: 0,
- removed: 0
- },
- benchmarks: []
- };
-
- // Create maps for easy lookup
- const currentMap = new Map();
- const baselineMap = new Map();
-
- // Process current benchmarks
- if (current && current.files) {
- for (const file of current.files) {
- for (const group of file.groups || []) {
- for (const bench of group.benchmarks || []) {
- const key = `${group.name}::${bench.name}`;
- currentMap.set(key, {
- ops: bench.result.hz,
- mean: bench.result.mean,
- file: file.filepath
- });
- }
- }
- }
- }
-
- // Process baseline benchmarks
- if (baseline && baseline.files) {
- for (const file of baseline.files) {
- for (const group of file.groups || []) {
- for (const bench of group.benchmarks || []) {
- const key = `${group.name}::${bench.name}`;
- baselineMap.set(key, {
- ops: bench.result.hz,
- mean: bench.result.mean,
- file: file.filepath
- });
- }
- }
- }
- }
-
- // Compare benchmarks
- for (const [key, current] of currentMap) {
- const baseline = baselineMap.get(key);
-
- if (!baseline) {
- // New benchmark
- comparison.summary.added++;
- comparison.benchmarks.push({
- name: key,
- status: 'added',
- current: current.ops,
- baseline: null,
- change: null,
- file: current.file
- });
- } else {
- // Compare performance
- const change = ((current.ops - baseline.ops) / baseline.ops) * 100;
- let status = 'unchanged';
-
- if (Math.abs(change) >= this.threshold * 100) {
- if (change > 0) {
- status = 'improved';
- comparison.summary.improved++;
- } else {
- status = 'regressed';
- comparison.summary.regressed++;
- }
- } else {
- comparison.summary.unchanged++;
- }
-
- comparison.benchmarks.push({
- name: key,
- status,
- current: current.ops,
- baseline: baseline.ops,
- change,
- meanCurrent: current.mean,
- meanBaseline: baseline.mean,
- file: current.file
- });
- }
- }
-
- // Check for removed benchmarks
- for (const [key, baseline] of baselineMap) {
- if (!currentMap.has(key)) {
- comparison.summary.removed++;
- comparison.benchmarks.push({
- name: key,
- status: 'removed',
- current: null,
- baseline: baseline.ops,
- change: null,
- file: baseline.file
- });
- }
- }
-
- // Sort by change percentage (regressions first)
- comparison.benchmarks.sort((a, b) => {
- if (a.status === 'regressed' && b.status !== 'regressed') return -1;
- if (b.status === 'regressed' && a.status !== 'regressed') return 1;
- if (a.change !== null && b.change !== null) {
- return a.change - b.change;
- }
- return 0;
- });
-
- return comparison;
- }
-
- generateMarkdownReport(comparison) {
- let report = '## Benchmark Comparison Report\n\n';
-
- const { summary } = comparison;
- report += '### Summary\n\n';
- report += `- **Improved**: ${summary.improved} benchmarks\n`;
- report += `- **Regressed**: ${summary.regressed} benchmarks\n`;
- report += `- **Unchanged**: ${summary.unchanged} benchmarks\n`;
- report += `- **Added**: ${summary.added} benchmarks\n`;
- report += `- **Removed**: ${summary.removed} benchmarks\n\n`;
-
- // Regressions
- const regressions = comparison.benchmarks.filter(b => b.status === 'regressed');
- if (regressions.length > 0) {
- report += '### โ ๏ธ Performance Regressions\n\n';
- report += '| Benchmark | Current | Baseline | Change |\n';
- report += '|-----------|---------|----------|--------|\n';
-
- for (const bench of regressions) {
- const currentOps = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
- const baselineOps = bench.baseline.toLocaleString('en-US', { maximumFractionDigits: 0 });
- const changeStr = bench.change.toFixed(2);
- report += `| ${bench.name} | ${currentOps} ops/s | ${baselineOps} ops/s | **${changeStr}%** |\n`;
- }
- report += '\n';
- }
-
- // Improvements
- const improvements = comparison.benchmarks.filter(b => b.status === 'improved');
- if (improvements.length > 0) {
- report += '### โ
Performance Improvements\n\n';
- report += '| Benchmark | Current | Baseline | Change |\n';
- report += '|-----------|---------|----------|--------|\n';
-
- for (const bench of improvements) {
- const currentOps = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
- const baselineOps = bench.baseline.toLocaleString('en-US', { maximumFractionDigits: 0 });
- const changeStr = bench.change.toFixed(2);
- report += `| ${bench.name} | ${currentOps} ops/s | ${baselineOps} ops/s | **+${changeStr}%** |\n`;
- }
- report += '\n';
- }
-
- // New benchmarks
- const added = comparison.benchmarks.filter(b => b.status === 'added');
- if (added.length > 0) {
- report += '### ๐ New Benchmarks\n\n';
- report += '| Benchmark | Performance |\n';
- report += '|-----------|-------------|\n';
-
- for (const bench of added) {
- const ops = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
- report += `| ${bench.name} | ${ops} ops/s |\n`;
- }
- report += '\n';
- }
-
- return report;
- }
-
- generateJsonReport(comparison) {
- return JSON.stringify(comparison, null, 2);
- }
-
- async compare(currentPath, baselinePath) {
- // Load results
- const current = this.loadBenchmarkResults(currentPath);
- const baseline = this.loadBenchmarkResults(baselinePath);
-
- if (!current && !baseline) {
- console.error('No benchmark results found');
- return;
- }
-
- // Generate comparison
- const comparison = this.compareBenchmarks(current, baseline);
-
- // Generate reports
- const markdownReport = this.generateMarkdownReport(comparison);
- const jsonReport = this.generateJsonReport(comparison);
-
- // Write reports
- writeFileSync('benchmark-comparison.md', markdownReport);
- writeFileSync('benchmark-comparison.json', jsonReport);
-
- // Output summary to console
- console.log(markdownReport);
-
- // Return exit code based on regressions
- if (comparison.summary.regressed > 0) {
- console.error(`\nโ Found ${comparison.summary.regressed} performance regressions`);
- process.exit(1);
- } else {
- console.log(`\nโ
No performance regressions found`);
- process.exit(0);
- }
- }
-}
-
-// Parse command line arguments
-const args = process.argv.slice(2);
-if (args.length < 1) {
- console.error('Usage: node compare-benchmarks.js [baseline-results]');
- console.error('If baseline-results is not provided, it will look for benchmark-baseline.json');
- process.exit(1);
-}
-
-const currentPath = args[0];
-const baselinePath = args[1] || 'benchmark-baseline.json';
-
-// Run comparison
-const comparator = new BenchmarkComparator();
-comparator.compare(currentPath, baselinePath).catch(console.error);
\ No newline at end of file
diff --git a/scripts/format-benchmark-results.js b/scripts/format-benchmark-results.js
deleted file mode 100755
index 516f048..0000000
--- a/scripts/format-benchmark-results.js
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/usr/bin/env node
-
-const fs = require('fs');
-const path = require('path');
-
-/**
- * Formats Vitest benchmark results for github-action-benchmark
- * Converts from Vitest format to the expected format
- */
-function formatBenchmarkResults() {
- const resultsPath = path.join(process.cwd(), 'benchmark-results.json');
-
- if (!fs.existsSync(resultsPath)) {
- console.error('benchmark-results.json not found');
- process.exit(1);
- }
-
- const vitestResults = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
-
- // Convert to github-action-benchmark format
- const formattedResults = [];
-
- // Vitest benchmark JSON reporter format
- if (vitestResults.files) {
- for (const file of vitestResults.files) {
- const suiteName = path.basename(file.filepath, '.bench.ts');
-
- // Process each suite in the file
- if (file.groups) {
- for (const group of file.groups) {
- for (const benchmark of group.benchmarks || []) {
- if (benchmark.result) {
- formattedResults.push({
- name: `${suiteName} - ${benchmark.name}`,
- unit: 'ms',
- value: benchmark.result.mean || 0,
- range: (benchmark.result.max - benchmark.result.min) || 0,
- extra: `${benchmark.result.hz?.toFixed(0) || 0} ops/sec`
- });
- }
- }
- }
- }
- }
- } else if (Array.isArray(vitestResults)) {
- // Alternative format handling
- for (const result of vitestResults) {
- if (result.name && result.result) {
- formattedResults.push({
- name: result.name,
- unit: 'ms',
- value: result.result.mean || 0,
- range: (result.result.max - result.result.min) || 0,
- extra: `${result.result.hz?.toFixed(0) || 0} ops/sec`
- });
- }
- }
- }
-
- // Write formatted results
- const outputPath = path.join(process.cwd(), 'benchmark-results-formatted.json');
- fs.writeFileSync(outputPath, JSON.stringify(formattedResults, null, 2));
-
- // Also create a summary for PR comments
- const summary = {
- timestamp: new Date().toISOString(),
- benchmarks: formattedResults.map(b => ({
- name: b.name,
- time: `${b.value.toFixed(3)}ms`,
- opsPerSec: b.extra,
- range: `ยฑ${(b.range / 2).toFixed(3)}ms`
- }))
- };
-
- fs.writeFileSync(
- path.join(process.cwd(), 'benchmark-summary.json'),
- JSON.stringify(summary, null, 2)
- );
-
- console.log(`Formatted ${formattedResults.length} benchmark results`);
-}
-
-// Run if called directly
-if (require.main === module) {
- formatBenchmarkResults();
-}
\ No newline at end of file
diff --git a/scripts/generate-benchmark-stub.js b/scripts/generate-benchmark-stub.js
deleted file mode 100644
index c2bfbc4..0000000
--- a/scripts/generate-benchmark-stub.js
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Generates a stub benchmark-results.json file when benchmarks fail to produce output.
- * This ensures the CI pipeline doesn't fail due to missing files.
- */
-
-const fs = require('fs');
-const path = require('path');
-
-const stubResults = {
- timestamp: new Date().toISOString(),
- files: [
- {
- filepath: 'tests/benchmarks/stub.bench.ts',
- groups: [
- {
- name: 'Stub Benchmarks',
- benchmarks: [
- {
- name: 'stub-benchmark',
- result: {
- mean: 0.001,
- min: 0.001,
- max: 0.001,
- hz: 1000,
- p75: 0.001,
- p99: 0.001,
- p995: 0.001,
- p999: 0.001,
- rme: 0,
- samples: 1
- }
- }
- ]
- }
- ]
- }
- ]
-};
-
-const outputPath = path.join(process.cwd(), 'benchmark-results.json');
-fs.writeFileSync(outputPath, JSON.stringify(stubResults, null, 2));
-console.log(`Generated stub benchmark results at ${outputPath}`);
\ No newline at end of file
diff --git a/scripts/run-benchmarks-ci.js b/scripts/run-benchmarks-ci.js
deleted file mode 100755
index 9fb6404..0000000
--- a/scripts/run-benchmarks-ci.js
+++ /dev/null
@@ -1,172 +0,0 @@
-#!/usr/bin/env node
-
-const { spawn } = require('child_process');
-const fs = require('fs');
-const path = require('path');
-
-const benchmarkResults = {
- timestamp: new Date().toISOString(),
- files: []
-};
-
-// Function to strip ANSI color codes
-function stripAnsi(str) {
- return str.replace(/\x1b\[[0-9;]*m/g, '');
-}
-
-// Run vitest bench command with no color output for easier parsing
-const vitest = spawn('npx', ['vitest', 'bench', '--run', '--config', 'vitest.config.benchmark.ts', '--no-color'], {
- stdio: ['inherit', 'pipe', 'pipe'],
- shell: true,
- env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }
-});
-
-let output = '';
-let currentFile = null;
-let currentSuite = null;
-
-vitest.stdout.on('data', (data) => {
- const text = stripAnsi(data.toString());
- output += text;
- process.stdout.write(data); // Write original with colors
-
- // Parse the output to extract benchmark results
- const lines = text.split('\n');
-
- for (const line of lines) {
- // Detect test file - match with or without checkmark
- const fileMatch = line.match(/[โ ]\s+(tests\/benchmarks\/[^>]+\.bench\.ts)/);
- if (fileMatch) {
- console.log(`\n[Parser] Found file: ${fileMatch[1]}`);
- currentFile = {
- filepath: fileMatch[1],
- groups: []
- };
- benchmarkResults.files.push(currentFile);
- currentSuite = null;
- }
-
- // Detect suite name
- const suiteMatch = line.match(/^\s+ยท\s+(.+?)\s+[\d,]+\.\d+\s+/);
- if (suiteMatch && currentFile) {
- const suiteName = suiteMatch[1].trim();
-
- // Check if this is part of the previous line's suite description
- const lastLineMatch = lines[lines.indexOf(line) - 1]?.match(/>\s+(.+?)(?:\s+\d+ms)?$/);
- if (lastLineMatch) {
- currentSuite = {
- name: lastLineMatch[1].trim(),
- benchmarks: []
- };
- currentFile.groups.push(currentSuite);
- }
- }
-
- // Parse benchmark result line - the format is: name hz min max mean p75 p99 p995 p999 rme samples
- const benchMatch = line.match(/^\s*[ยทโข]\s+(.+?)\s+([\d,]+\.\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+ยฑ([\d.]+)%\s+([\d,]+)/);
- if (benchMatch && currentFile) {
- const [, name, hz, min, max, mean, p75, p99, p995, p999, rme, samples] = benchMatch;
- console.log(`[Parser] Found benchmark: ${name.trim()}`);
-
-
- const benchmark = {
- name: name.trim(),
- result: {
- hz: parseFloat(hz.replace(/,/g, '')),
- min: parseFloat(min),
- max: parseFloat(max),
- mean: parseFloat(mean),
- p75: parseFloat(p75),
- p99: parseFloat(p99),
- p995: parseFloat(p995),
- p999: parseFloat(p999),
- rme: parseFloat(rme),
- samples: parseInt(samples.replace(/,/g, ''))
- }
- };
-
- // Add to current suite or create a default one
- if (!currentSuite) {
- currentSuite = {
- name: 'Default',
- benchmarks: []
- };
- currentFile.groups.push(currentSuite);
- }
-
- currentSuite.benchmarks.push(benchmark);
- }
- }
-});
-
-vitest.stderr.on('data', (data) => {
- process.stderr.write(data);
-});
-
-vitest.on('close', (code) => {
- if (code !== 0) {
- console.error(`Benchmark process exited with code ${code}`);
- process.exit(code);
- }
-
- // Clean up empty files/groups
- benchmarkResults.files = benchmarkResults.files.filter(file =>
- file.groups.length > 0 && file.groups.some(group => group.benchmarks.length > 0)
- );
-
- // Write results
- const outputPath = path.join(process.cwd(), 'benchmark-results.json');
- fs.writeFileSync(outputPath, JSON.stringify(benchmarkResults, null, 2));
- console.log(`\nBenchmark results written to ${outputPath}`);
- console.log(`Total files processed: ${benchmarkResults.files.length}`);
-
- // Validate that we captured results
- let totalBenchmarks = 0;
- for (const file of benchmarkResults.files) {
- for (const group of file.groups) {
- totalBenchmarks += group.benchmarks.length;
- }
- }
-
- if (totalBenchmarks === 0) {
- console.warn('No benchmark results were captured! Generating stub results...');
-
- // Generate stub results to prevent CI failure
- const stubResults = {
- timestamp: new Date().toISOString(),
- files: [
- {
- filepath: 'tests/benchmarks/sample.bench.ts',
- groups: [
- {
- name: 'Sample Benchmarks',
- benchmarks: [
- {
- name: 'array sorting - small',
- result: {
- mean: 0.0136,
- min: 0.0124,
- max: 0.3220,
- hz: 73341.27,
- p75: 0.0133,
- p99: 0.0213,
- p995: 0.0307,
- p999: 0.1062,
- rme: 0.51,
- samples: 36671
- }
- }
- ]
- }
- ]
- }
- ]
- };
-
- fs.writeFileSync(outputPath, JSON.stringify(stubResults, null, 2));
- console.log('Stub results generated to prevent CI failure');
- return;
- }
-
- console.log(`Total benchmarks captured: ${totalBenchmarks}`);
-});
\ No newline at end of file
diff --git a/scripts/vitest-benchmark-json-reporter.js b/scripts/vitest-benchmark-json-reporter.js
deleted file mode 100644
index a8fe936..0000000
--- a/scripts/vitest-benchmark-json-reporter.js
+++ /dev/null
@@ -1,121 +0,0 @@
-const { writeFileSync } = require('fs');
-const { resolve } = require('path');
-
-class BenchmarkJsonReporter {
- constructor() {
- this.results = [];
- console.log('[BenchmarkJsonReporter] Initialized');
- }
-
- onInit(ctx) {
- console.log('[BenchmarkJsonReporter] onInit called');
- }
-
- onCollected(files) {
- console.log('[BenchmarkJsonReporter] onCollected called with', files ? files.length : 0, 'files');
- }
-
- onTaskUpdate(tasks) {
- console.log('[BenchmarkJsonReporter] onTaskUpdate called');
- }
-
- onBenchmarkResult(file, benchmark) {
- console.log('[BenchmarkJsonReporter] onBenchmarkResult called for', benchmark.name);
- }
-
- onFinished(files, errors) {
- console.log('[BenchmarkJsonReporter] onFinished called with', files ? files.length : 0, 'files');
-
- const results = {
- timestamp: new Date().toISOString(),
- files: []
- };
-
- try {
- for (const file of files || []) {
- if (!file) continue;
-
- const fileResult = {
- filepath: file.filepath || file.name || 'unknown',
- groups: []
- };
-
- // Handle both file.tasks and file.benchmarks
- const tasks = file.tasks || file.benchmarks || [];
-
- // Process tasks/benchmarks
- for (const task of tasks) {
- if (task.type === 'suite' && task.tasks) {
- // This is a suite containing benchmarks
- const group = {
- name: task.name,
- benchmarks: []
- };
-
- for (const benchmark of task.tasks) {
- if (benchmark.result?.benchmark) {
- group.benchmarks.push({
- name: benchmark.name,
- result: {
- mean: benchmark.result.benchmark.mean,
- min: benchmark.result.benchmark.min,
- max: benchmark.result.benchmark.max,
- hz: benchmark.result.benchmark.hz,
- p75: benchmark.result.benchmark.p75,
- p99: benchmark.result.benchmark.p99,
- p995: benchmark.result.benchmark.p995,
- p999: benchmark.result.benchmark.p999,
- rme: benchmark.result.benchmark.rme,
- samples: benchmark.result.benchmark.samples
- }
- });
- }
- }
-
- if (group.benchmarks.length > 0) {
- fileResult.groups.push(group);
- }
- } else if (task.result?.benchmark) {
- // This is a direct benchmark (not in a suite)
- if (!fileResult.groups.length) {
- fileResult.groups.push({
- name: 'Default',
- benchmarks: []
- });
- }
-
- fileResult.groups[0].benchmarks.push({
- name: task.name,
- result: {
- mean: task.result.benchmark.mean,
- min: task.result.benchmark.min,
- max: task.result.benchmark.max,
- hz: task.result.benchmark.hz,
- p75: task.result.benchmark.p75,
- p99: task.result.benchmark.p99,
- p995: task.result.benchmark.p995,
- p999: task.result.benchmark.p999,
- rme: task.result.benchmark.rme,
- samples: task.result.benchmark.samples
- }
- });
- }
- }
-
- if (fileResult.groups.length > 0) {
- results.files.push(fileResult);
- }
- }
-
- // Write results
- const outputPath = resolve(process.cwd(), 'benchmark-results.json');
- writeFileSync(outputPath, JSON.stringify(results, null, 2));
- console.log(`[BenchmarkJsonReporter] Benchmark results written to ${outputPath}`);
- console.log(`[BenchmarkJsonReporter] Total files processed: ${results.files.length}`);
- } catch (error) {
- console.error('[BenchmarkJsonReporter] Error writing results:', error);
- }
- }
-}
-
-module.exports = BenchmarkJsonReporter;
\ No newline at end of file
diff --git a/scripts/vitest-benchmark-reporter.ts b/scripts/vitest-benchmark-reporter.ts
deleted file mode 100644
index e26b5e3..0000000
--- a/scripts/vitest-benchmark-reporter.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { Task, TaskResult, BenchmarkResult } from 'vitest';
-import { writeFileSync } from 'fs';
-import { resolve } from 'path';
-
-interface BenchmarkJsonResult {
- timestamp: string;
- files: Array<{
- filepath: string;
- groups: Array<{
- name: string;
- benchmarks: Array<{
- name: string;
- result: {
- mean: number;
- min: number;
- max: number;
- hz: number;
- p75: number;
- p99: number;
- p995: number;
- p999: number;
- rme: number;
- samples: number;
- };
- }>;
- }>;
- }>;
-}
-
-export class BenchmarkJsonReporter {
- private results: BenchmarkJsonResult = {
- timestamp: new Date().toISOString(),
- files: []
- };
-
- onInit() {
- console.log('[BenchmarkJsonReporter] Initialized');
- }
-
- onFinished(files?: Task[]) {
- console.log('[BenchmarkJsonReporter] onFinished called');
-
- if (!files) {
- console.log('[BenchmarkJsonReporter] No files provided');
- return;
- }
-
- for (const file of files) {
- const fileResult = {
- filepath: file.filepath || 'unknown',
- groups: [] as any[]
- };
-
- this.processTask(file, fileResult);
-
- if (fileResult.groups.length > 0) {
- this.results.files.push(fileResult);
- }
- }
-
- // Write results
- const outputPath = resolve(process.cwd(), 'benchmark-results.json');
- writeFileSync(outputPath, JSON.stringify(this.results, null, 2));
- console.log(`[BenchmarkJsonReporter] Results written to ${outputPath}`);
- }
-
- private processTask(task: Task, fileResult: any) {
- if (task.type === 'suite' && task.tasks) {
- const group = {
- name: task.name,
- benchmarks: [] as any[]
- };
-
- for (const benchmark of task.tasks) {
- const result = benchmark.result as TaskResult & { benchmark?: BenchmarkResult };
- if (result?.benchmark) {
- group.benchmarks.push({
- name: benchmark.name,
- result: {
- mean: result.benchmark.mean || 0,
- min: result.benchmark.min || 0,
- max: result.benchmark.max || 0,
- hz: result.benchmark.hz || 0,
- p75: result.benchmark.p75 || 0,
- p99: result.benchmark.p99 || 0,
- p995: result.benchmark.p995 || 0,
- p999: result.benchmark.p999 || 0,
- rme: result.benchmark.rme || 0,
- samples: result.benchmark.samples?.length || 0
- }
- });
- }
- }
-
- if (group.benchmarks.length > 0) {
- fileResult.groups.push(group);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/tests/benchmarks/README.md b/tests/benchmarks/README.md
deleted file mode 100644
index 6f3f623..0000000
--- a/tests/benchmarks/README.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Performance Benchmarks
-
-This directory contains performance benchmarks for critical operations in the n8n-mcp project.
-
-## Running Benchmarks
-
-### Local Development
-
-```bash
-# Run all benchmarks
-npm run benchmark
-
-# Watch mode for development
-npm run benchmark:watch
-
-# Interactive UI
-npm run benchmark:ui
-
-# Run specific benchmark file
-npx vitest bench tests/benchmarks/node-loading.bench.ts
-```
-
-### CI/CD
-
-Benchmarks run automatically on:
-- Every push to `main` branch
-- Every pull request
-- Manual workflow dispatch
-
-## Benchmark Suites
-
-### 1. Node Loading Performance (`node-loading.bench.ts`)
-- Package loading (n8n-nodes-base, @n8n/n8n-nodes-langchain)
-- Individual node file loading
-- Package.json parsing
-
-### 2. Database Query Performance (`database-queries.bench.ts`)
-- Node retrieval by type
-- Category filtering
-- Search operations (OR, AND, FUZZY modes)
-- Node counting and statistics
-- Insert/update operations
-
-### 3. Search Operations (`search-operations.bench.ts`)
-- Single and multi-word searches
-- Exact phrase matching
-- Fuzzy search performance
-- Property search within nodes
-- Complex filtering operations
-
-### 4. Validation Performance (`validation-performance.bench.ts`)
-- Node configuration validation (minimal, strict, ai-friendly)
-- Expression validation
-- Workflow validation
-- Property dependency resolution
-
-### 5. MCP Tool Execution (`mcp-tools.bench.ts`)
-- Tool execution overhead
-- Response formatting
-- Complex query handling
-
-## Performance Targets
-
-| Operation | Target | Alert Threshold |
-|-----------|--------|-----------------|
-| Node loading | <100ms per package | >150ms |
-| Database query | <5ms per query | >10ms |
-| Search (simple) | <10ms | >20ms |
-| Search (complex) | <50ms | >100ms |
-| Validation (simple) | <1ms | >2ms |
-| Validation (complex) | <10ms | >20ms |
-| MCP tool execution | <50ms | >100ms |
-
-## Benchmark Results
-
-- Results are tracked over time using GitHub Actions
-- Historical data available at: https://czlonkowski.github.io/n8n-mcp/benchmarks/
-- Performance regressions >10% trigger automatic alerts
-- PR comments show benchmark comparisons
-
-## Writing New Benchmarks
-
-```typescript
-import { bench, describe } from 'vitest';
-
-describe('My Performance Suite', () => {
- bench('operation name', async () => {
- // Code to benchmark
- }, {
- iterations: 100, // Number of times to run
- warmupIterations: 10, // Warmup runs (not measured)
- warmupTime: 500, // Warmup duration in ms
- time: 3000 // Total benchmark duration in ms
- });
-});
-```
-
-## Best Practices
-
-1. **Isolate Operations**: Benchmark specific operations, not entire workflows
-2. **Use Realistic Data**: Load actual n8n nodes for realistic measurements
-3. **Warmup**: Always include warmup iterations to avoid JIT compilation effects
-4. **Memory**: Use in-memory databases for consistent results
-5. **Iterations**: Balance between accuracy and execution time
-
-## Troubleshooting
-
-### Inconsistent Results
-- Increase `warmupIterations` and `warmupTime`
-- Run benchmarks in isolation
-- Check for background processes
-
-### Memory Issues
-- Reduce `iterations` for memory-intensive operations
-- Add cleanup in `afterEach` hooks
-- Monitor memory usage during benchmarks
-
-### CI Failures
-- Check benchmark timeout settings
-- Verify GitHub Actions runner resources
-- Review alert thresholds for false positives
\ No newline at end of file
diff --git a/tests/benchmarks/database-queries.bench.ts b/tests/benchmarks/database-queries.bench.ts
deleted file mode 100644
index 481c403..0000000
--- a/tests/benchmarks/database-queries.bench.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { bench, describe } from 'vitest';
-import { NodeRepository } from '../../src/database/node-repository';
-import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
-import { NodeFactory } from '../factories/node-factory';
-import { PropertyDefinitionFactory } from '../factories/property-definition-factory';
-
-/**
- * Database Query Performance Benchmarks
- *
- * NOTE: These benchmarks use MOCK DATA (500 artificial test nodes)
- * created with factories, not the real production database.
- *
- * This is useful for tracking database layer performance in isolation,
- * but may not reflect real-world performance characteristics.
- *
- * For end-to-end MCP tool performance with real data, see mcp-tools.bench.ts
- */
-describe('Database Query Performance', () => {
- let repository: NodeRepository;
- let storage: SQLiteStorageService;
- const testNodeCount = 500;
-
- beforeAll(async () => {
- storage = new SQLiteStorageService(':memory:');
- repository = new NodeRepository(storage);
-
- // Seed database with test data
- for (let i = 0; i < testNodeCount; i++) {
- const node = NodeFactory.build({
- displayName: `TestNode${i}`,
- nodeType: `nodes-base.testNode${i}`,
- category: i % 2 === 0 ? 'transform' : 'trigger',
- packageName: 'n8n-nodes-base',
- documentation: `Test documentation for node ${i}`,
- properties: PropertyDefinitionFactory.buildList(5)
- });
- await repository.upsertNode(node);
- }
- });
-
- afterAll(() => {
- storage.close();
- });
-
- bench('getNodeByType - existing node', async () => {
- await repository.getNodeByType('nodes-base.testNode100');
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getNodeByType - non-existing node', async () => {
- await repository.getNodeByType('nodes-base.nonExistentNode');
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getNodesByCategory - transform', async () => {
- await repository.getNodesByCategory('transform');
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - OR mode', async () => {
- await repository.searchNodes('test node data', 'OR', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - AND mode', async () => {
- await repository.searchNodes('test node', 'AND', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - FUZZY mode', async () => {
- await repository.searchNodes('tst nde', 'FUZZY', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getAllNodes - no limit', async () => {
- await repository.getAllNodes();
- }, {
- iterations: 50,
- warmupIterations: 5,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getAllNodes - with limit', async () => {
- await repository.getAllNodes(50);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getNodeCount', async () => {
- await repository.getNodeCount();
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 100,
- time: 2000
- });
-
- bench('getAIToolNodes', async () => {
- await repository.getAIToolNodes();
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('upsertNode - new node', async () => {
- const node = NodeFactory.build({
- displayName: `BenchNode${Date.now()}`,
- nodeType: `nodes-base.benchNode${Date.now()}`
- });
- await repository.upsertNode(node);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('upsertNode - existing node update', async () => {
- const existingNode = await repository.getNodeByType('nodes-base.testNode0');
- if (existingNode) {
- existingNode.description = `Updated description ${Date.now()}`;
- await repository.upsertNode(existingNode);
- }
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-});
\ No newline at end of file
diff --git a/tests/benchmarks/index.ts b/tests/benchmarks/index.ts
deleted file mode 100644
index 8adb81c..0000000
--- a/tests/benchmarks/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Export all benchmark suites
-export * from './database-queries.bench';
-export * from './mcp-tools.bench';
\ No newline at end of file
diff --git a/tests/benchmarks/mcp-tools.bench.ts b/tests/benchmarks/mcp-tools.bench.ts
deleted file mode 100644
index c4a527a..0000000
--- a/tests/benchmarks/mcp-tools.bench.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { bench, describe } from 'vitest';
-import { NodeRepository } from '../../src/database/node-repository';
-import { createDatabaseAdapter } from '../../src/database/database-adapter';
-import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
-import { PropertyFilter } from '../../src/services/property-filter';
-import path from 'path';
-
-/**
- * MCP Tool Performance Benchmarks
- *
- * These benchmarks measure end-to-end performance of actual MCP tool operations
- * using the REAL production database (data/nodes.db with 525+ nodes).
- *
- * Unlike database-queries.bench.ts which uses mock data, these benchmarks
- * reflect what AI assistants actually experience when calling MCP tools,
- * making this the most meaningful performance metric for the system.
- */
-describe('MCP Tool Performance (Production Database)', () => {
- let repository: NodeRepository;
-
- beforeAll(async () => {
- // Use REAL production database
- const dbPath = path.join(__dirname, '../../data/nodes.db');
- const db = await createDatabaseAdapter(dbPath);
- repository = new NodeRepository(db);
- // Initialize similarity services for validation
- EnhancedConfigValidator.initializeSimilarityServices(repository);
- });
-
- /**
- * search_nodes - Most frequently used tool for node discovery
- *
- * This measures:
- * - Database FTS5 full-text search
- * - Result filtering and ranking
- * - Response serialization
- *
- * Target: <20ms for common queries
- */
- bench('search_nodes - common query (http)', async () => {
- await repository.searchNodes('http', 'OR', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('search_nodes - AI agent query (slack message)', async () => {
- await repository.searchNodes('slack send message', 'AND', 10);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- /**
- * get_node_essentials - Fast retrieval of node configuration
- *
- * This measures:
- * - Database node lookup
- * - Property filtering (essentials only)
- * - Response formatting
- *
- * Target: <10ms for most nodes
- */
- bench('get_node_essentials - HTTP Request node', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
- if (node && node.properties) {
- PropertyFilter.getEssentials(node.properties, node.nodeType);
- }
- }, {
- iterations: 200,
- warmupIterations: 20,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_node_essentials - Slack node', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.slack');
- if (node && node.properties) {
- PropertyFilter.getEssentials(node.properties, node.nodeType);
- }
- }, {
- iterations: 200,
- warmupIterations: 20,
- warmupTime: 500,
- time: 3000
- });
-
- /**
- * list_nodes - Initial exploration/listing
- *
- * This measures:
- * - Database query with pagination
- * - Result serialization
- * - Category filtering
- *
- * Target: <15ms for first page
- */
- bench('list_nodes - first 50 nodes', async () => {
- await repository.getAllNodes(50);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('list_nodes - AI tools only', async () => {
- await repository.getAIToolNodes();
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- /**
- * validate_node_operation - Configuration validation
- *
- * This measures:
- * - Schema lookup
- * - Validation logic execution
- * - Error message formatting
- *
- * Target: <15ms for simple validations
- */
- bench('validate_node_operation - HTTP Request (minimal)', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
- if (node && node.properties) {
- EnhancedConfigValidator.validateWithMode(
- 'n8n-nodes-base.httpRequest',
- {},
- node.properties,
- 'operation',
- 'ai-friendly'
- );
- }
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validate_node_operation - HTTP Request (with params)', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
- if (node && node.properties) {
- EnhancedConfigValidator.validateWithMode(
- 'n8n-nodes-base.httpRequest',
- {
- requestMethod: 'GET',
- url: 'https://api.example.com',
- authentication: 'none'
- },
- node.properties,
- 'operation',
- 'ai-friendly'
- );
- }
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-});
diff --git a/tests/benchmarks/mcp-tools.bench.ts.disabled b/tests/benchmarks/mcp-tools.bench.ts.disabled
deleted file mode 100644
index 02ff54b..0000000
--- a/tests/benchmarks/mcp-tools.bench.ts.disabled
+++ /dev/null
@@ -1,204 +0,0 @@
-import { bench, describe } from 'vitest';
-import { MCPEngine } from '../../src/mcp-tools-engine';
-import { NodeRepository } from '../../src/database/node-repository';
-import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
-import { N8nNodeLoader } from '../../src/loaders/node-loader';
-
-describe('MCP Tool Execution Performance', () => {
- let engine: MCPEngine;
- let storage: SQLiteStorageService;
-
- beforeAll(async () => {
- storage = new SQLiteStorageService(':memory:');
- const repository = new NodeRepository(storage);
- const loader = new N8nNodeLoader(repository);
- await loader.loadPackage('n8n-nodes-base');
-
- engine = new MCPEngine(repository);
- });
-
- afterAll(() => {
- storage.close();
- });
-
- bench('list_nodes - default limit', async () => {
- await engine.listNodes({});
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('list_nodes - large limit', async () => {
- await engine.listNodes({ limit: 200 });
- }, {
- iterations: 50,
- warmupIterations: 5,
- warmupTime: 500,
- time: 3000
- });
-
- bench('list_nodes - filtered by category', async () => {
- await engine.listNodes({ category: 'transform', limit: 100 });
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('search_nodes - single word', async () => {
- await engine.searchNodes({ query: 'http' });
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('search_nodes - multiple words', async () => {
- await engine.searchNodes({ query: 'http request webhook', mode: 'OR' });
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_node_info', async () => {
- await engine.getNodeInfo({ nodeType: 'n8n-nodes-base.httpRequest' });
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_node_essentials', async () => {
- await engine.getNodeEssentials({ nodeType: 'n8n-nodes-base.httpRequest' });
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_node_documentation', async () => {
- await engine.getNodeDocumentation({ nodeType: 'n8n-nodes-base.httpRequest' });
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validate_node_operation - simple', async () => {
- await engine.validateNodeOperation({
- nodeType: 'n8n-nodes-base.httpRequest',
- config: {
- url: 'https://api.example.com',
- method: 'GET'
- },
- profile: 'minimal'
- });
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validate_node_operation - complex', async () => {
- await engine.validateNodeOperation({
- nodeType: 'n8n-nodes-base.slack',
- config: {
- resource: 'message',
- operation: 'send',
- channel: 'C1234567890',
- text: 'Hello from benchmark'
- },
- profile: 'strict'
- });
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validate_node_minimal', async () => {
- await engine.validateNodeMinimal({
- nodeType: 'n8n-nodes-base.httpRequest',
- config: {}
- });
- }, {
- iterations: 2000,
- warmupIterations: 200,
- warmupTime: 500,
- time: 3000
- });
-
- bench('search_node_properties', async () => {
- await engine.searchNodeProperties({
- nodeType: 'n8n-nodes-base.httpRequest',
- query: 'authentication'
- });
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_node_for_task', async () => {
- await engine.getNodeForTask({ task: 'post_json_request' });
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('list_ai_tools', async () => {
- await engine.listAITools({});
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('get_database_statistics', async () => {
- await engine.getDatabaseStatistics({});
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validate_workflow - simple', async () => {
- await engine.validateWorkflow({
- workflow: {
- name: 'Test',
- nodes: [
- {
- id: '1',
- name: 'Manual',
- type: 'n8n-nodes-base.manualTrigger',
- typeVersion: 1,
- position: [250, 300],
- parameters: {}
- }
- ],
- connections: {}
- }
- });
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-});
\ No newline at end of file
diff --git a/tests/benchmarks/mcp-tools.bench.ts.skip b/tests/benchmarks/mcp-tools.bench.ts.skip
deleted file mode 100644
index cf57e20..0000000
--- a/tests/benchmarks/mcp-tools.bench.ts.skip
+++ /dev/null
@@ -1,2 +0,0 @@
-// This benchmark is temporarily disabled due to API changes in N8nNodeLoader
-// The benchmark needs to be updated to work with the new loader API
\ No newline at end of file
diff --git a/tests/benchmarks/node-loading.bench.ts.disabled b/tests/benchmarks/node-loading.bench.ts.disabled
deleted file mode 100644
index b21a4c2..0000000
--- a/tests/benchmarks/node-loading.bench.ts.disabled
+++ /dev/null
@@ -1,59 +0,0 @@
-import { bench, describe } from 'vitest';
-import { N8nNodeLoader } from '../../src/loaders/node-loader';
-import { NodeRepository } from '../../src/database/node-repository';
-import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
-import path from 'path';
-
-describe('Node Loading Performance', () => {
- let loader: N8nNodeLoader;
- let repository: NodeRepository;
- let storage: SQLiteStorageService;
-
- beforeAll(() => {
- storage = new SQLiteStorageService(':memory:');
- repository = new NodeRepository(storage);
- loader = new N8nNodeLoader(repository);
- });
-
- afterAll(() => {
- storage.close();
- });
-
- bench('loadPackage - n8n-nodes-base', async () => {
- await loader.loadPackage('n8n-nodes-base');
- }, {
- iterations: 5,
- warmupIterations: 2,
- warmupTime: 1000,
- time: 5000
- });
-
- bench('loadPackage - @n8n/n8n-nodes-langchain', async () => {
- await loader.loadPackage('@n8n/n8n-nodes-langchain');
- }, {
- iterations: 5,
- warmupIterations: 2,
- warmupTime: 1000,
- time: 5000
- });
-
- bench('loadNodesFromPath - single file', async () => {
- const testPath = path.join(process.cwd(), 'node_modules/n8n-nodes-base/dist/nodes/HttpRequest');
- await loader.loadNodesFromPath(testPath, 'n8n-nodes-base');
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('parsePackageJson', async () => {
- const packageJsonPath = path.join(process.cwd(), 'node_modules/n8n-nodes-base/package.json');
- await loader['parsePackageJson'](packageJsonPath);
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 100,
- time: 2000
- });
-});
\ No newline at end of file
diff --git a/tests/benchmarks/search-operations.bench.ts.disabled b/tests/benchmarks/search-operations.bench.ts.disabled
deleted file mode 100644
index f4d85af..0000000
--- a/tests/benchmarks/search-operations.bench.ts.disabled
+++ /dev/null
@@ -1,143 +0,0 @@
-import { bench, describe } from 'vitest';
-import { NodeRepository } from '../../src/database/node-repository';
-import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
-import { N8nNodeLoader } from '../../src/loaders/node-loader';
-
-describe('Search Operations Performance', () => {
- let repository: NodeRepository;
- let storage: SQLiteStorageService;
-
- beforeAll(async () => {
- storage = new SQLiteStorageService(':memory:');
- repository = new NodeRepository(storage);
- const loader = new N8nNodeLoader(repository);
-
- // Load real nodes for realistic benchmarking
- await loader.loadPackage('n8n-nodes-base');
- });
-
- afterAll(() => {
- storage.close();
- });
-
- bench('searchNodes - single word', async () => {
- await repository.searchNodes('http', 'OR', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - multiple words OR', async () => {
- await repository.searchNodes('http request webhook', 'OR', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - multiple words AND', async () => {
- await repository.searchNodes('http request', 'AND', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - fuzzy search', async () => {
- await repository.searchNodes('htpp requst', 'FUZZY', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - exact phrase', async () => {
- await repository.searchNodes('"HTTP Request"', 'OR', 20);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - large result set', async () => {
- await repository.searchNodes('data', 'OR', 100);
- }, {
- iterations: 50,
- warmupIterations: 5,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodes - no results', async () => {
- await repository.searchNodes('xyznonexistentquery123', 'OR', 20);
- }, {
- iterations: 200,
- warmupIterations: 20,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodeProperties - common property', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
- if (node) {
- await repository.searchNodeProperties(node.type, 'url', 20);
- }
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('searchNodeProperties - nested property', async () => {
- const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
- if (node) {
- await repository.searchNodeProperties(node.type, 'authentication', 20);
- }
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getNodesByCategory - all categories', async () => {
- const categories = ['trigger', 'transform', 'output', 'input'];
- for (const category of categories) {
- await repository.getNodesByCategory(category);
- }
- }, {
- iterations: 50,
- warmupIterations: 5,
- warmupTime: 500,
- time: 3000
- });
-
- bench('getNodesByPackage', async () => {
- await repository.getNodesByPackage('n8n-nodes-base');
- }, {
- iterations: 50,
- warmupIterations: 5,
- warmupTime: 500,
- time: 3000
- });
-
- bench('complex filter - AI tools in transform category', async () => {
- const allNodes = await repository.getAllNodes();
- const filtered = allNodes.filter(node =>
- node.category === 'transform' &&
- node.isAITool
- );
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-});
\ No newline at end of file
diff --git a/tests/benchmarks/validation-performance.bench.ts.disabled b/tests/benchmarks/validation-performance.bench.ts.disabled
deleted file mode 100644
index 923fb49..0000000
--- a/tests/benchmarks/validation-performance.bench.ts.disabled
+++ /dev/null
@@ -1,181 +0,0 @@
-import { bench, describe } from 'vitest';
-import { ConfigValidator } from '../../src/services/config-validator';
-import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
-import { ExpressionValidator } from '../../src/services/expression-validator';
-import { WorkflowValidator } from '../../src/services/workflow-validator';
-import { NodeRepository } from '../../src/database/node-repository';
-import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
-import { N8nNodeLoader } from '../../src/loaders/node-loader';
-
-describe('Validation Performance', () => {
- let workflowValidator: WorkflowValidator;
- let repository: NodeRepository;
- let storage: SQLiteStorageService;
-
- const simpleConfig = {
- url: 'https://api.example.com',
- method: 'GET',
- authentication: 'none'
- };
-
- const complexConfig = {
- resource: 'message',
- operation: 'send',
- channel: 'C1234567890',
- text: 'Hello from benchmark',
- authentication: {
- type: 'oAuth2',
- credentials: {
- oauthTokenData: {
- access_token: 'xoxb-test-token'
- }
- }
- },
- options: {
- as_user: true,
- link_names: true,
- parse: 'full',
- reply_broadcast: false,
- thread_ts: '',
- unfurl_links: true,
- unfurl_media: true
- }
- };
-
- const simpleWorkflow = {
- name: 'Simple Workflow',
- nodes: [
- {
- id: '1',
- name: 'Manual Trigger',
- type: 'n8n-nodes-base.manualTrigger',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 4.2,
- position: [450, 300] as [number, number],
- parameters: {
- url: 'https://api.example.com',
- method: 'GET'
- }
- }
- ],
- connections: {
- '1': {
- main: [
- [
- {
- node: '2',
- type: 'main',
- index: 0
- }
- ]
- ]
- }
- }
- };
-
- const complexWorkflow = {
- name: 'Complex Workflow',
- nodes: Array.from({ length: 20 }, (_, i) => ({
- id: `${i + 1}`,
- name: `Node ${i + 1}`,
- type: i % 3 === 0 ? 'n8n-nodes-base.httpRequest' :
- i % 3 === 1 ? 'n8n-nodes-base.slack' :
- 'n8n-nodes-base.code',
- typeVersion: 1,
- position: [250 + (i % 5) * 200, 300 + Math.floor(i / 5) * 150] as [number, number],
- parameters: {
- url: '={{ $json.url }}',
- method: 'POST',
- body: '={{ JSON.stringify($json) }}',
- headers: {
- 'Content-Type': 'application/json'
- }
- }
- })),
- connections: Object.fromEntries(
- Array.from({ length: 19 }, (_, i) => [
- `${i + 1}`,
- {
- main: [[{ node: `${i + 2}`, type: 'main', index: 0 }]]
- }
- ])
- )
- };
-
- beforeAll(async () => {
- storage = new SQLiteStorageService(':memory:');
- repository = new NodeRepository(storage);
- const loader = new N8nNodeLoader(repository);
- await loader.loadPackage('n8n-nodes-base');
-
- workflowValidator = new WorkflowValidator(repository);
- });
-
- afterAll(() => {
- storage.close();
- });
-
- // Note: ConfigValidator and EnhancedConfigValidator have static methods,
- // so instance-based benchmarks are not applicable
-
- bench('validateExpression - simple expression', async () => {
- ExpressionValidator.validateExpression('{{ $json.data }}');
- }, {
- iterations: 5000,
- warmupIterations: 500,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validateExpression - complex expression', async () => {
- ExpressionValidator.validateExpression('{{ $node["HTTP Request"].json.items.map(item => item.id).join(",") }}');
- }, {
- iterations: 2000,
- warmupIterations: 200,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validateWorkflow - simple workflow', async () => {
- await workflowValidator.validateWorkflow(simpleWorkflow);
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validateWorkflow - complex workflow', async () => {
- await workflowValidator.validateWorkflow(complexWorkflow);
- }, {
- iterations: 100,
- warmupIterations: 10,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validateWorkflow - connections only', async () => {
- await workflowValidator.validateConnections(simpleWorkflow);
- }, {
- iterations: 1000,
- warmupIterations: 100,
- warmupTime: 500,
- time: 3000
- });
-
- bench('validateWorkflow - expressions only', async () => {
- await workflowValidator.validateExpressions(complexWorkflow);
- }, {
- iterations: 500,
- warmupIterations: 50,
- warmupTime: 500,
- time: 3000
- });
-});
\ No newline at end of file
diff --git a/tests/examples/using-database-utils.test.ts b/tests/examples/using-database-utils.test.ts
deleted file mode 100644
index 6f50716..0000000
--- a/tests/examples/using-database-utils.test.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import {
- createTestDatabase,
- seedTestNodes,
- seedTestTemplates,
- createTestNode,
- createTestTemplate,
- createDatabaseSnapshot,
- restoreDatabaseSnapshot,
- loadFixtures,
- dbHelpers,
- TestDatabase
-} from '../utils/database-utils';
-import * as path from 'path';
-
-/**
- * Example test file showing how to use database utilities
- * in real test scenarios
- */
-
-describe('Example: Using Database Utils in Tests', () => {
- let testDb: TestDatabase;
-
- // Always cleanup after each test
- afterEach(async () => {
- if (testDb) {
- await testDb.cleanup();
- }
- });
-
- describe('Basic Database Setup', () => {
- it('should setup a test database for unit testing', async () => {
- // Create an in-memory database for fast tests
- testDb = await createTestDatabase();
-
- // Seed some test data
- await seedTestNodes(testDb.nodeRepository, [
- { nodeType: 'nodes-base.myCustomNode', displayName: 'My Custom Node' }
- ]);
-
- // Use the repository to test your logic
- const node = testDb.nodeRepository.getNode('nodes-base.myCustomNode');
- expect(node).toBeDefined();
- expect(node.displayName).toBe('My Custom Node');
- });
-
- it('should setup a file-based database for integration testing', async () => {
- // Create a file-based database when you need persistence
- testDb = await createTestDatabase({
- inMemory: false,
- dbPath: path.join(__dirname, '../temp/integration-test.db')
- });
-
- // The database will persist until cleanup() is called
- await seedTestNodes(testDb.nodeRepository);
-
- // You can verify the file exists
- expect(testDb.path).toContain('integration-test.db');
- });
- });
-
- describe('Testing with Fixtures', () => {
- it('should load complex test scenarios from fixtures', async () => {
- testDb = await createTestDatabase();
-
- // Load fixtures from JSON file
- const fixturePath = path.join(__dirname, '../fixtures/database/test-nodes.json');
- await loadFixtures(testDb.adapter, fixturePath);
-
- // Verify the fixture data was loaded
- expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(3);
- expect(dbHelpers.countRows(testDb.adapter, 'templates')).toBe(1);
-
- // Test your business logic with the fixture data
- const slackNode = testDb.nodeRepository.getNode('nodes-base.slack');
- expect(slackNode.isAITool).toBe(true);
- expect(slackNode.category).toBe('Communication');
- });
- });
-
- describe('Testing Repository Methods', () => {
- beforeEach(async () => {
- testDb = await createTestDatabase();
- });
-
- it('should test custom repository queries', async () => {
- // Seed nodes with specific properties
- await seedTestNodes(testDb.nodeRepository, [
- { nodeType: 'nodes-base.ai1', isAITool: true },
- { nodeType: 'nodes-base.ai2', isAITool: true },
- { nodeType: 'nodes-base.regular', isAITool: false }
- ]);
-
- // Test custom queries
- const aiNodes = testDb.nodeRepository.getAITools();
- expect(aiNodes).toHaveLength(4); // 2 custom + 2 default (httpRequest, slack)
-
- // Use dbHelpers for quick checks
- const allNodeTypes = dbHelpers.getAllNodeTypes(testDb.adapter);
- expect(allNodeTypes).toContain('nodes-base.ai1');
- expect(allNodeTypes).toContain('nodes-base.ai2');
- });
- });
-
- describe('Testing with Snapshots', () => {
- it('should test rollback scenarios using snapshots', async () => {
- testDb = await createTestDatabase();
-
- // Setup initial state
- await seedTestNodes(testDb.nodeRepository);
- await seedTestTemplates(testDb.templateRepository);
-
- // Create a snapshot of the good state
- const snapshot = await createDatabaseSnapshot(testDb.adapter);
-
- // Perform operations that might fail
- try {
- // Simulate a complex operation
- await testDb.nodeRepository.saveNode(createTestNode({
- nodeType: 'nodes-base.problematic',
- displayName: 'This might cause issues'
- }));
-
- // Simulate an error
- throw new Error('Something went wrong!');
- } catch (error) {
- // Restore to the known good state
- await restoreDatabaseSnapshot(testDb.adapter, snapshot);
- }
-
- // Verify we're back to the original state
- expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(snapshot.metadata.nodeCount);
- expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.problematic')).toBe(false);
- });
- });
-
- describe('Testing Database Performance', () => {
- it('should measure performance of database operations', async () => {
- testDb = await createTestDatabase();
-
- // Measure bulk insert performance
- const insertDuration = await measureDatabaseOperation('Bulk Insert', async () => {
- const nodes = Array.from({ length: 100 }, (_, i) =>
- createTestNode({
- nodeType: `nodes-base.perf${i}`,
- displayName: `Performance Test Node ${i}`
- })
- );
-
- for (const node of nodes) {
- testDb.nodeRepository.saveNode(node);
- }
- });
-
- // Measure query performance
- const queryDuration = await measureDatabaseOperation('Query All Nodes', async () => {
- const allNodes = testDb.nodeRepository.getAllNodes();
- expect(allNodes.length).toBe(100); // 100 bulk nodes (no defaults as we're not using seedTestNodes)
- });
-
- // Assert reasonable performance
- expect(insertDuration).toBeLessThan(1000); // Should complete in under 1 second
- expect(queryDuration).toBeLessThan(100); // Queries should be fast
- });
- });
-
- describe('Testing with Different Database States', () => {
- it('should test behavior with empty database', async () => {
- testDb = await createTestDatabase();
-
- // Test with empty database
- expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(0);
-
- const nonExistentNode = testDb.nodeRepository.getNode('nodes-base.doesnotexist');
- expect(nonExistentNode).toBeNull();
- });
-
- it('should test behavior with populated database', async () => {
- testDb = await createTestDatabase();
-
- // Populate with many nodes
- const nodes = Array.from({ length: 50 }, (_, i) => ({
- nodeType: `nodes-base.node${i}`,
- displayName: `Node ${i}`,
- category: i % 2 === 0 ? 'Category A' : 'Category B'
- }));
-
- await seedTestNodes(testDb.nodeRepository, nodes);
-
- // Test queries on populated database
- const allNodes = dbHelpers.getAllNodeTypes(testDb.adapter);
- expect(allNodes.length).toBe(53); // 50 custom + 3 default
-
- // Test filtering by category
- const categoryANodes = testDb.adapter
- .prepare('SELECT COUNT(*) as count FROM nodes WHERE category = ?')
- .get('Category A') as { count: number };
-
- expect(categoryANodes.count).toBe(25);
- });
- });
-
- describe('Testing Error Scenarios', () => {
- it('should handle database errors gracefully', async () => {
- testDb = await createTestDatabase();
-
- // Test saving invalid data
- const invalidNode = createTestNode({
- nodeType: '', // Invalid: empty nodeType
- displayName: 'Invalid Node'
- });
-
- // SQLite allows NULL in PRIMARY KEY, so test with empty string instead
- // which should violate any business logic constraints
- // For now, we'll just verify the save doesn't crash
- expect(() => {
- testDb.nodeRepository.saveNode(invalidNode);
- }).not.toThrow();
-
- // Database should still be functional
- await seedTestNodes(testDb.nodeRepository);
- expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(4); // 3 default nodes + 1 invalid node
- });
- });
-
- describe('Testing with Transactions', () => {
- it('should test transactional behavior', async () => {
- testDb = await createTestDatabase();
-
- // Seed initial data
- await seedTestNodes(testDb.nodeRepository);
- const initialCount = dbHelpers.countRows(testDb.adapter, 'nodes');
-
- // Use transaction for atomic operations
- try {
- testDb.adapter.transaction(() => {
- // Add multiple nodes atomically
- testDb.nodeRepository.saveNode(createTestNode({ nodeType: 'nodes-base.tx1' }));
- testDb.nodeRepository.saveNode(createTestNode({ nodeType: 'nodes-base.tx2' }));
-
- // Simulate error in transaction
- throw new Error('Transaction failed');
- });
- } catch (error) {
- // Transaction should have rolled back
- }
-
- // Verify no nodes were added
- const finalCount = dbHelpers.countRows(testDb.adapter, 'nodes');
- expect(finalCount).toBe(initialCount);
- expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.tx1')).toBe(false);
- expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.tx2')).toBe(false);
- });
- });
-});
-
-// Helper function for performance measurement
-async function measureDatabaseOperation(
- name: string,
- operation: () => Promise
-): Promise {
- const start = performance.now();
- await operation();
- const duration = performance.now() - start;
- console.log(`[Performance] ${name}: ${duration.toFixed(2)}ms`);
- return duration;
-}
\ No newline at end of file
diff --git a/tests/integration/security/rate-limiting.test.ts b/tests/integration/security/rate-limiting.test.ts
deleted file mode 100644
index 24f35f5..0000000
--- a/tests/integration/security/rate-limiting.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { spawn, ChildProcess } from 'child_process';
-import axios from 'axios';
-
-/**
- * Integration tests for rate limiting
- *
- * SECURITY: These tests verify rate limiting prevents brute force attacks
- * See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
- *
- * TODO: Re-enable when CI server startup issue is resolved
- * Server process fails to start on port 3001 in CI with ECONNREFUSED errors
- * Tests pass locally but consistently fail in GitHub Actions CI environment
- * Rate limiting functionality is verified and working in production
- */
-describe.skip('Integration: Rate Limiting', () => {
- let serverProcess: ChildProcess;
- const port = 3001;
- const authToken = 'test-token-for-rate-limiting-test-32-chars';
-
- beforeAll(async () => {
- // Start HTTP server with rate limiting
- serverProcess = spawn('node', ['dist/http-server-single-session.js'], {
- env: {
- ...process.env,
- MCP_MODE: 'http',
- PORT: port.toString(),
- AUTH_TOKEN: authToken,
- NODE_ENV: 'test',
- AUTH_RATE_LIMIT_WINDOW: '900000', // 15 minutes
- AUTH_RATE_LIMIT_MAX: '20', // 20 attempts
- },
- stdio: 'pipe',
- });
-
- // Wait for server to start (longer wait for CI)
- await new Promise(resolve => setTimeout(resolve, 8000));
- }, 20000);
-
- afterAll(() => {
- if (serverProcess) {
- serverProcess.kill();
- }
- });
-
- it('should block after max authentication attempts (sequential requests)', async () => {
- const baseUrl = `http://localhost:${port}/mcp`;
-
- // IMPORTANT: Use sequential requests to ensure deterministic order
- // Parallel requests can cause race conditions with in-memory rate limiter
- for (let i = 1; i <= 25; i++) {
- const response = await axios.post(
- baseUrl,
- { jsonrpc: '2.0', method: 'initialize', id: i },
- {
- headers: { Authorization: 'Bearer wrong-token' },
- validateStatus: () => true, // Don't throw on error status
- }
- );
-
- if (i <= 20) {
- // First 20 attempts should be 401 (invalid authentication)
- expect(response.status).toBe(401);
- expect(response.data.error.message).toContain('Unauthorized');
- } else {
- // Attempts 21+ should be 429 (rate limited)
- expect(response.status).toBe(429);
- expect(response.data.error.message).toContain('Too many');
- }
- }
- }, 60000);
-
- it('should include rate limit headers', async () => {
- const baseUrl = `http://localhost:${port}/mcp`;
-
- const response = await axios.post(
- baseUrl,
- { jsonrpc: '2.0', method: 'initialize', id: 1 },
- {
- headers: { Authorization: 'Bearer wrong-token' },
- validateStatus: () => true,
- }
- );
-
- // Check for standard rate limit headers
- expect(response.headers['ratelimit-limit']).toBeDefined();
- expect(response.headers['ratelimit-remaining']).toBeDefined();
- expect(response.headers['ratelimit-reset']).toBeDefined();
- }, 15000);
-
- it('should accept valid tokens within rate limit', async () => {
- const baseUrl = `http://localhost:${port}/mcp`;
-
- const response = await axios.post(
- baseUrl,
- {
- jsonrpc: '2.0',
- method: 'initialize',
- params: {
- protocolVersion: '2024-11-05',
- capabilities: {},
- clientInfo: { name: 'test', version: '1.0' },
- },
- id: 1,
- },
- {
- headers: { Authorization: `Bearer ${authToken}` },
- }
- );
-
- expect(response.status).toBe(200);
- expect(response.data.result).toBeDefined();
- }, 15000);
-
- it('should return JSON-RPC formatted error on rate limit', async () => {
- const baseUrl = `http://localhost:${port}/mcp`;
-
- // Exhaust rate limit
- for (let i = 0; i < 21; i++) {
- await axios.post(
- baseUrl,
- { jsonrpc: '2.0', method: 'initialize', id: i },
- {
- headers: { Authorization: 'Bearer wrong-token' },
- validateStatus: () => true,
- }
- );
- }
-
- // Get rate limited response
- const response = await axios.post(
- baseUrl,
- { jsonrpc: '2.0', method: 'initialize', id: 999 },
- {
- headers: { Authorization: 'Bearer wrong-token' },
- validateStatus: () => true,
- }
- );
-
- // Verify JSON-RPC error format
- expect(response.data).toHaveProperty('jsonrpc', '2.0');
- expect(response.data).toHaveProperty('error');
- expect(response.data.error).toHaveProperty('code', -32000);
- expect(response.data.error).toHaveProperty('message');
- expect(response.data).toHaveProperty('id', null);
- }, 60000);
-});
diff --git a/tests/integration/telemetry/mcp-telemetry.test.ts b/tests/integration/telemetry/mcp-telemetry.test.ts
deleted file mode 100644
index 8fd25c5..0000000
--- a/tests/integration/telemetry/mcp-telemetry.test.ts
+++ /dev/null
@@ -1,754 +0,0 @@
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
-import { telemetry } from '../../../src/telemetry/telemetry-manager';
-import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
-import { CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk/types.js';
-
-// Mock dependencies
-vi.mock('../../../src/utils/logger', () => ({
- Logger: vi.fn().mockImplementation(() => ({
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- })),
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- }
-}));
-
-vi.mock('../../../src/telemetry/telemetry-manager', () => ({
- telemetry: {
- trackSessionStart: vi.fn(),
- trackToolUsage: vi.fn(),
- trackToolSequence: vi.fn(),
- trackError: vi.fn(),
- trackSearchQuery: vi.fn(),
- trackValidationDetails: vi.fn(),
- trackWorkflowCreation: vi.fn(),
- trackPerformanceMetric: vi.fn(),
- getMetrics: vi.fn().mockReturnValue({
- status: 'enabled',
- initialized: true,
- tracking: { eventQueueSize: 0 },
- processing: { eventsTracked: 0 },
- errors: { totalErrors: 0 }
- })
- }
-}));
-
-vi.mock('../../../src/telemetry/config-manager');
-
-// Mock database and other dependencies
-vi.mock('../../../src/database/node-repository');
-vi.mock('../../../src/services/enhanced-config-validator');
-vi.mock('../../../src/services/expression-validator');
-vi.mock('../../../src/services/workflow-validator');
-
-// TODO: This test needs to be refactored. It's currently mocking everything
-// which defeats the purpose of an integration test. It should either:
-// 1. Be moved to unit tests if we want to test with mocks
-// 2. Be rewritten as a proper integration test without mocks
-// Skipping for now to unblock CI - the telemetry functionality is tested
-// properly in the unit tests at tests/unit/telemetry/
-describe.skip('MCP Telemetry Integration', () => {
- let mcpServer: N8NDocumentationMCPServer;
- let mockTelemetryConfig: any;
-
- beforeEach(() => {
- // Mock TelemetryConfigManager
- mockTelemetryConfig = {
- isEnabled: vi.fn().mockReturnValue(true),
- getUserId: vi.fn().mockReturnValue('test-user-123'),
- disable: vi.fn(),
- enable: vi.fn(),
- getStatus: vi.fn().mockReturnValue('enabled')
- };
- vi.mocked(TelemetryConfigManager.getInstance).mockReturnValue(mockTelemetryConfig);
-
- // Mock database repository
- const mockNodeRepository = {
- searchNodes: vi.fn().mockResolvedValue({ results: [], totalResults: 0 }),
- getNodeInfo: vi.fn().mockResolvedValue(null),
- getAllNodes: vi.fn().mockResolvedValue([]),
- close: vi.fn()
- };
- vi.doMock('../../../src/database/node-repository', () => ({
- NodeRepository: vi.fn().mockImplementation(() => mockNodeRepository)
- }));
-
- // Create a mock server instance to avoid initialization issues
- const mockServer = {
- requestHandlers: new Map(),
- notificationHandlers: new Map(),
- setRequestHandler: vi.fn((method: string, handler: any) => {
- mockServer.requestHandlers.set(method, handler);
- }),
- setNotificationHandler: vi.fn((method: string, handler: any) => {
- mockServer.notificationHandlers.set(method, handler);
- })
- };
-
- // Set up basic handlers
- mockServer.requestHandlers.set('initialize', async () => {
- telemetry.trackSessionStart();
- return { protocolVersion: '2024-11-05' };
- });
-
- mockServer.requestHandlers.set('tools/call', async (params: any) => {
- // Use the actual tool name from the request
- const toolName = params?.name || 'unknown-tool';
-
- try {
- // Call executeTool if it's been mocked
- if ((mcpServer as any).executeTool) {
- const result = await (mcpServer as any).executeTool(params);
-
- // Track specific telemetry based on tool type
- if (toolName === 'search_nodes') {
- const query = params?.arguments?.query || '';
- const totalResults = result?.totalResults || 0;
- const mode = params?.arguments?.mode || 'OR';
- telemetry.trackSearchQuery(query, totalResults, mode);
- } else if (toolName === 'validate_workflow') {
- const workflow = params?.arguments?.workflow || {};
- const validationPassed = result?.isValid !== false;
- telemetry.trackWorkflowCreation(workflow, validationPassed);
- if (!validationPassed && result?.errors) {
- result.errors.forEach((error: any) => {
- telemetry.trackValidationDetails(error.nodeType || 'unknown', error.type || 'validation_error', error);
- });
- }
- } else if (toolName === 'validate_node_operation' || toolName === 'validate_node_minimal') {
- const nodeType = params?.arguments?.nodeType || 'unknown';
- const errorType = result?.errors?.[0]?.type || 'validation_error';
- telemetry.trackValidationDetails(nodeType, errorType, result);
- }
-
- // Simulate a duration for tool execution
- const duration = params?.duration || Math.random() * 100;
- telemetry.trackToolUsage(toolName, true, duration);
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
- } else {
- // Default behavior if executeTool is not mocked
- telemetry.trackToolUsage(toolName, true);
- return { content: [{ type: 'text', text: 'Success' }] };
- }
- } catch (error: any) {
- telemetry.trackToolUsage(toolName, false);
- telemetry.trackError(
- error.constructor.name,
- error.message,
- toolName,
- error.message
- );
- throw error;
- }
- });
-
- // Mock the N8NDocumentationMCPServer to have the server property
- mcpServer = {
- server: mockServer,
- handleTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Success' }] }),
- executeTool: vi.fn().mockResolvedValue({
- results: [{ nodeType: 'nodes-base.webhook' }],
- totalResults: 1
- }),
- close: vi.fn()
- } as any;
-
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe('Session tracking', () => {
- it('should track session start on MCP initialize', async () => {
- const initializeRequest = {
- method: 'initialize' as const,
- params: {
- protocolVersion: '2024-11-05',
- clientInfo: {
- name: 'test-client',
- version: '1.0.0'
- },
- capabilities: {}
- }
- };
-
- // Access the private server instance for testing
- const server = (mcpServer as any).server;
- const initializeHandler = server.requestHandlers.get('initialize');
-
- if (initializeHandler) {
- await initializeHandler(initializeRequest.params);
- }
-
- expect(telemetry.trackSessionStart).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('Tool usage tracking', () => {
- it('should track successful tool execution', async () => {
- const callToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'webhook' }
- }
- };
-
- // Mock the executeTool method to return a successful result
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [{ nodeType: 'nodes-base.webhook' }],
- totalResults: 1
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(callToolRequest.params);
- }
-
- expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
- 'search_nodes',
- true,
- expect.any(Number)
- );
- });
-
- it('should track failed tool execution', async () => {
- const callToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'get_node',
- arguments: { nodeType: 'invalid-node' }
- }
- };
-
- // Mock the executeTool method to throw an error
- const error = new Error('Node not found');
- vi.spyOn(mcpServer as any, 'executeTool').mockRejectedValue(error);
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- try {
- await callToolHandler(callToolRequest.params);
- } catch (e) {
- // Expected to throw
- }
- }
-
- expect(telemetry.trackToolUsage).toHaveBeenCalledWith('get_node', false);
- expect(telemetry.trackError).toHaveBeenCalledWith(
- 'Error',
- 'Node not found',
- 'get_node'
- );
- });
-
- it('should track tool sequences', async () => {
- // Set up previous tool state
- (mcpServer as any).previousTool = 'search_nodes';
- (mcpServer as any).previousToolTimestamp = Date.now() - 5000;
-
- const callToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'get_node',
- arguments: { nodeType: 'nodes-base.webhook' }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- nodeType: 'nodes-base.webhook',
- displayName: 'Webhook'
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(callToolRequest.params);
- }
-
- expect(telemetry.trackToolSequence).toHaveBeenCalledWith(
- 'search_nodes',
- 'get_node',
- expect.any(Number)
- );
- });
- });
-
- describe('Search query tracking', () => {
- it('should track search queries with results', async () => {
- const searchRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'webhook', mode: 'OR' }
- }
- };
-
- // Mock search results
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [
- { nodeType: 'nodes-base.webhook', score: 0.95 },
- { nodeType: 'nodes-base.httpRequest', score: 0.8 }
- ],
- totalResults: 2
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(searchRequest.params);
- }
-
- expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('webhook', 2, 'OR');
- });
-
- it('should track zero-result searches', async () => {
- const zeroResultRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'nonexistent', mode: 'AND' }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [],
- totalResults: 0
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(zeroResultRequest.params);
- }
-
- expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('nonexistent', 0, 'AND');
- });
-
- it('should track fallback search queries', async () => {
- const fallbackRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'partial-match', mode: 'OR' }
- }
- };
-
- // Mock main search with no results, triggering fallback
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [{ nodeType: 'nodes-base.webhook', score: 0.6 }],
- totalResults: 1,
- usedFallback: true
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(fallbackRequest.params);
- }
-
- // Should track both main query and fallback
- expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 0, 'OR');
- expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 1, 'OR_LIKE_FALLBACK');
- });
- });
-
- describe('Workflow validation tracking', () => {
- it('should track successful workflow creation', async () => {
- const workflow = {
- nodes: [
- { id: '1', type: 'webhook', name: 'Webhook' },
- { id: '2', type: 'httpRequest', name: 'HTTP Request' }
- ],
- connections: {
- '1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
- }
- };
-
- const validateRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'validate_workflow',
- arguments: { workflow }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- isValid: true,
- errors: [],
- warnings: [],
- summary: { totalIssues: 0, criticalIssues: 0 }
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(validateRequest.params);
- }
-
- expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(workflow, true);
- });
-
- it('should track validation details for failed workflows', async () => {
- const workflow = {
- nodes: [
- { id: '1', type: 'invalid-node', name: 'Invalid Node' }
- ],
- connections: {}
- };
-
- const validateRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'validate_workflow',
- arguments: { workflow }
- }
- };
-
- const validationResult = {
- isValid: false,
- errors: [
- {
- nodeId: '1',
- nodeType: 'invalid-node',
- category: 'node_validation',
- severity: 'error',
- message: 'Unknown node type',
- details: { type: 'unknown_node_type' }
- }
- ],
- warnings: [],
- summary: { totalIssues: 1, criticalIssues: 1 }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue(validationResult);
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(validateRequest.params);
- }
-
- expect(telemetry.trackValidationDetails).toHaveBeenCalledWith(
- 'invalid-node',
- 'unknown_node_type',
- expect.objectContaining({
- category: 'node_validation',
- severity: 'error'
- })
- );
- });
- });
-
- describe('Node configuration tracking', () => {
- it('should track node configuration validation', async () => {
- const validateNodeRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'validate_node_operation',
- arguments: {
- nodeType: 'nodes-base.httpRequest',
- config: { url: 'https://api.example.com', method: 'GET' }
- }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- isValid: true,
- errors: [],
- warnings: [],
- nodeConfig: { url: 'https://api.example.com', method: 'GET' }
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(validateNodeRequest.params);
- }
-
- // Should track the validation attempt
- expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
- 'validate_node_operation',
- true,
- expect.any(Number)
- );
- });
- });
-
- describe('Performance metric tracking', () => {
- it('should track slow tool executions', async () => {
- const slowToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'http', limit: 1000 }
- }
- };
-
- // Mock a slow operation
- vi.spyOn(mcpServer as any, 'executeTool').mockImplementation(async () => {
- await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
- return { results: [], totalCount: 0 };
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(slowToolRequest.params);
- }
-
- expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
- 'search_nodes',
- true,
- expect.any(Number)
- );
-
- // Verify duration is tracked (should be around 2000ms)
- const trackUsageCall = vi.mocked(telemetry.trackToolUsage).mock.calls[0];
- expect(trackUsageCall[2]).toBeGreaterThan(1500); // Allow some variance
- });
- });
-
- describe('Tool listing and capabilities', () => {
- it('should handle tool listing without telemetry interference', async () => {
- const listToolsRequest: ListToolsRequest = {
- method: 'tools/list',
- params: {}
- };
-
- const server = (mcpServer as any).server;
- const listToolsHandler = server.requestHandlers.get('tools/list');
-
- if (listToolsHandler) {
- const result = await listToolsHandler(listToolsRequest.params);
- expect(result).toHaveProperty('tools');
- expect(Array.isArray(result.tools)).toBe(true);
- }
-
- // Tool listing shouldn't generate telemetry events
- expect(telemetry.trackToolUsage).not.toHaveBeenCalled();
- });
- });
-
- describe('Error handling and telemetry', () => {
- it('should track errors without breaking MCP protocol', async () => {
- const errorRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'nonexistent_tool',
- arguments: {}
- }
- };
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- try {
- await callToolHandler(errorRequest.params);
- } catch (error) {
- // Error should be handled by MCP server
- expect(error).toBeDefined();
- }
- }
-
- // Should track error without throwing
- expect(telemetry.trackError).toHaveBeenCalled();
- });
-
- it('should handle telemetry errors gracefully', async () => {
- // Mock telemetry to throw an error
- vi.mocked(telemetry.trackToolUsage).mockImplementation(() => {
- throw new Error('Telemetry service unavailable');
- });
-
- const callToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'webhook' }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [],
- totalResults: 0
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- // Should not throw even if telemetry fails
- if (callToolHandler) {
- await expect(callToolHandler(callToolRequest.params)).resolves.toBeDefined();
- }
- });
- });
-
- describe('Telemetry configuration integration', () => {
- it('should respect telemetry disabled state', async () => {
- mockTelemetryConfig.isEnabled.mockReturnValue(false);
-
- const callToolRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'search_nodes',
- arguments: { query: 'webhook' }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [],
- totalResults: 0
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(callToolRequest.params);
- }
-
- // Should still track if telemetry manager handles disabled state
- // The actual filtering happens in telemetry manager, not MCP server
- expect(telemetry.trackToolUsage).toHaveBeenCalled();
- });
- });
-
- describe('Complex workflow scenarios', () => {
- it('should track comprehensive workflow validation scenario', async () => {
- const complexWorkflow = {
- nodes: [
- { id: '1', type: 'webhook', name: 'Webhook Trigger' },
- { id: '2', type: 'httpRequest', name: 'API Call', parameters: { url: 'https://api.example.com' } },
- { id: '3', type: 'set', name: 'Transform Data' },
- { id: '4', type: 'if', name: 'Conditional Logic' },
- { id: '5', type: 'slack', name: 'Send Notification' }
- ],
- connections: {
- '1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
- '2': { main: [[{ node: '3', type: 'main', index: 0 }]] },
- '3': { main: [[{ node: '4', type: 'main', index: 0 }]] },
- '4': { main: [[{ node: '5', type: 'main', index: 0 }]] }
- }
- };
-
- const validateRequest: CallToolRequest = {
- method: 'tools/call',
- params: {
- name: 'validate_workflow',
- arguments: { workflow: complexWorkflow }
- }
- };
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- isValid: true,
- errors: [],
- warnings: [
- {
- nodeId: '2',
- nodeType: 'httpRequest',
- category: 'configuration',
- severity: 'warning',
- message: 'Consider adding error handling'
- }
- ],
- summary: { totalIssues: 1, criticalIssues: 0 }
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await callToolHandler(validateRequest.params);
- }
-
- expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(complexWorkflow, true);
- expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
- 'validate_workflow',
- true,
- expect.any(Number)
- );
- });
- });
-
- describe('MCP server lifecycle and telemetry', () => {
- it('should handle server initialization with telemetry', async () => {
- // Set up minimal environment for server creation
- process.env.NODE_DB_PATH = ':memory:';
-
- // Verify that server creation doesn't interfere with telemetry
- const newServer = {} as N8NDocumentationMCPServer; // Mock instance
- expect(newServer).toBeDefined();
-
- // Telemetry should still be functional
- expect(telemetry.getMetrics).toBeDefined();
- expect(typeof telemetry.trackToolUsage).toBe('function');
- });
-
- it('should handle concurrent tool executions with telemetry', async () => {
- const requests = [
- {
- method: 'tools/call' as const,
- params: {
- name: 'search_nodes',
- arguments: { query: 'webhook' }
- }
- },
- {
- method: 'tools/call' as const,
- params: {
- name: 'search_nodes',
- arguments: { query: 'http' }
- }
- },
- {
- method: 'tools/call' as const,
- params: {
- name: 'search_nodes',
- arguments: { query: 'database' }
- }
- }
- ];
-
- vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
- results: [{ nodeType: 'test-node' }],
- totalResults: 1
- });
-
- const server = (mcpServer as any).server;
- const callToolHandler = server.requestHandlers.get('tools/call');
-
- if (callToolHandler) {
- await Promise.all(
- requests.map(req => callToolHandler(req.params))
- );
- }
-
- // All three calls should be tracked
- expect(telemetry.trackToolUsage).toHaveBeenCalledTimes(3);
- expect(telemetry.trackSearchQuery).toHaveBeenCalledTimes(3);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/database/database-adapter-unit.test.ts b/tests/unit/database/database-adapter-unit.test.ts
deleted file mode 100644
index 2f223fc..0000000
--- a/tests/unit/database/database-adapter-unit.test.ts
+++ /dev/null
@@ -1,328 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-
-// Mock logger
-vi.mock('../../../src/utils/logger', () => ({
- logger: {
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- debug: vi.fn()
- }
-}));
-
-describe('Database Adapter - Unit Tests', () => {
- describe('DatabaseAdapter Interface', () => {
- it('should define interface when adapter is created', () => {
- // This is a type test - ensuring the interface is correctly defined
- type DatabaseAdapter = {
- prepare: (sql: string) => any;
- exec: (sql: string) => void;
- close: () => void;
- pragma: (key: string, value?: any) => any;
- readonly inTransaction: boolean;
- transaction: (fn: () => T) => T;
- checkFTS5Support: () => boolean;
- };
-
- // Type assertion to ensure interface matches
- const mockAdapter: DatabaseAdapter = {
- prepare: vi.fn(),
- exec: vi.fn(),
- close: vi.fn(),
- pragma: vi.fn(),
- inTransaction: false,
- transaction: vi.fn((fn) => fn()),
- checkFTS5Support: vi.fn(() => true)
- };
-
- expect(mockAdapter).toBeDefined();
- expect(mockAdapter.prepare).toBeDefined();
- expect(mockAdapter.exec).toBeDefined();
- expect(mockAdapter.close).toBeDefined();
- expect(mockAdapter.pragma).toBeDefined();
- expect(mockAdapter.transaction).toBeDefined();
- expect(mockAdapter.checkFTS5Support).toBeDefined();
- });
- });
-
- describe('PreparedStatement Interface', () => {
- it('should define interface when statement is prepared', () => {
- // Type test for PreparedStatement
- type PreparedStatement = {
- run: (...params: any[]) => { changes: number; lastInsertRowid: number | bigint };
- get: (...params: any[]) => any;
- all: (...params: any[]) => any[];
- iterate: (...params: any[]) => IterableIterator;
- pluck: (toggle?: boolean) => PreparedStatement;
- expand: (toggle?: boolean) => PreparedStatement;
- raw: (toggle?: boolean) => PreparedStatement;
- columns: () => any[];
- bind: (...params: any[]) => PreparedStatement;
- };
-
- const mockStmt: PreparedStatement = {
- run: vi.fn(() => ({ changes: 1, lastInsertRowid: 1 })),
- get: vi.fn(),
- all: vi.fn(() => []),
- iterate: vi.fn(function* () {}),
- pluck: vi.fn(function(this: any) { return this; }),
- expand: vi.fn(function(this: any) { return this; }),
- raw: vi.fn(function(this: any) { return this; }),
- columns: vi.fn(() => []),
- bind: vi.fn(function(this: any) { return this; })
- };
-
- expect(mockStmt).toBeDefined();
- expect(mockStmt.run).toBeDefined();
- expect(mockStmt.get).toBeDefined();
- expect(mockStmt.all).toBeDefined();
- expect(mockStmt.iterate).toBeDefined();
- expect(mockStmt.pluck).toBeDefined();
- expect(mockStmt.expand).toBeDefined();
- expect(mockStmt.raw).toBeDefined();
- expect(mockStmt.columns).toBeDefined();
- expect(mockStmt.bind).toBeDefined();
- });
- });
-
- describe('FTS5 Support Detection', () => {
- it('should detect support when FTS5 module is available', () => {
- const mockDb = {
- exec: vi.fn()
- };
-
- // Function to test FTS5 support detection logic
- const checkFTS5Support = (db: any): boolean => {
- try {
- db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);");
- db.exec("DROP TABLE IF EXISTS test_fts5;");
- return true;
- } catch (error) {
- return false;
- }
- };
-
- // Test when FTS5 is supported
- expect(checkFTS5Support(mockDb)).toBe(true);
- expect(mockDb.exec).toHaveBeenCalledWith(
- "CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"
- );
-
- // Test when FTS5 is not supported
- mockDb.exec.mockImplementation(() => {
- throw new Error('no such module: fts5');
- });
-
- expect(checkFTS5Support(mockDb)).toBe(false);
- });
- });
-
- describe('Transaction Handling', () => {
- it('should handle commit and rollback when transaction is executed', () => {
- // Test transaction wrapper logic
- const mockDb = {
- exec: vi.fn(),
- inTransaction: false
- };
-
- const transaction = (db: any, fn: () => T): T => {
- try {
- db.exec('BEGIN');
- db.inTransaction = true;
- const result = fn();
- db.exec('COMMIT');
- db.inTransaction = false;
- return result;
- } catch (error) {
- db.exec('ROLLBACK');
- db.inTransaction = false;
- throw error;
- }
- };
-
- // Test successful transaction
- const result = transaction(mockDb, () => 'success');
- expect(result).toBe('success');
- expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
- expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
- expect(mockDb.inTransaction).toBe(false);
-
- // Reset mocks
- mockDb.exec.mockClear();
-
- // Test failed transaction
- expect(() => {
- transaction(mockDb, () => {
- throw new Error('transaction error');
- });
- }).toThrow('transaction error');
-
- expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
- expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
- expect(mockDb.inTransaction).toBe(false);
- });
- });
-
- describe('Pragma Handling', () => {
- it('should return values when pragma commands are executed', () => {
- const mockDb = {
- pragma: vi.fn((key: string, value?: any) => {
- if (key === 'journal_mode' && value === 'WAL') {
- return 'wal';
- }
- return null;
- })
- };
-
- expect(mockDb.pragma('journal_mode', 'WAL')).toBe('wal');
- expect(mockDb.pragma('other_key')).toBe(null);
- });
- });
-
- describe('SQLJSAdapter Save Behavior (Memory Leak Fix - Issue #330)', () => {
- it('should use default 5000ms save interval when env var not set', () => {
- // Verify default interval is 5000ms (not old 100ms)
- const DEFAULT_INTERVAL = 5000;
- expect(DEFAULT_INTERVAL).toBe(5000);
- });
-
- it('should use custom save interval from SQLJS_SAVE_INTERVAL_MS env var', () => {
- // Mock environment variable
- const originalEnv = process.env.SQLJS_SAVE_INTERVAL_MS;
- process.env.SQLJS_SAVE_INTERVAL_MS = '10000';
-
- // Test that interval would be parsed
- const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS;
- const parsedInterval = envInterval ? parseInt(envInterval, 10) : 5000;
-
- expect(parsedInterval).toBe(10000);
-
- // Restore environment
- if (originalEnv !== undefined) {
- process.env.SQLJS_SAVE_INTERVAL_MS = originalEnv;
- } else {
- delete process.env.SQLJS_SAVE_INTERVAL_MS;
- }
- });
-
- it('should fall back to default when invalid env var is provided', () => {
- // Test validation logic
- const testCases = [
- { input: 'invalid', expected: 5000 },
- { input: '50', expected: 5000 }, // Too low (< 100)
- { input: '-100', expected: 5000 }, // Negative
- { input: '0', expected: 5000 }, // Zero
- ];
-
- testCases.forEach(({ input, expected }) => {
- const parsed = parseInt(input, 10);
- const interval = (isNaN(parsed) || parsed < 100) ? 5000 : parsed;
- expect(interval).toBe(expected);
- });
- });
-
- it('should debounce multiple rapid saves using configured interval', () => {
- // Test debounce logic
- let timer: NodeJS.Timeout | null = null;
- const mockSave = vi.fn();
-
- const scheduleSave = (interval: number) => {
- if (timer) {
- clearTimeout(timer);
- }
- timer = setTimeout(() => {
- mockSave();
- }, interval);
- };
-
- // Simulate rapid operations
- scheduleSave(5000);
- scheduleSave(5000);
- scheduleSave(5000);
-
- // Should only schedule once (debounced)
- expect(mockSave).not.toHaveBeenCalled();
-
- // Cleanup
- if (timer) clearTimeout(timer);
- });
- });
-
- describe('SQLJSAdapter Memory Optimization', () => {
- it('should not use Buffer.from() copy in saveToFile()', () => {
- // Test that direct Uint8Array write logic is correct
- const mockData = new Uint8Array([1, 2, 3, 4, 5]);
-
- // Verify Uint8Array can be used directly
- expect(mockData).toBeInstanceOf(Uint8Array);
- expect(mockData.length).toBe(5);
-
- // This test verifies the pattern used in saveToFile()
- // The actual implementation writes mockData directly to fsSync.writeFileSync()
- // without using Buffer.from(mockData) which would double memory usage
- });
-
- it('should cleanup resources with explicit null assignment', () => {
- // Test cleanup pattern used in saveToFile()
- let data: Uint8Array | null = new Uint8Array([1, 2, 3]);
-
- try {
- // Simulate save operation
- expect(data).not.toBeNull();
- } finally {
- // Explicit cleanup helps GC
- data = null;
- }
-
- expect(data).toBeNull();
- });
-
- it('should handle save errors without leaking resources', () => {
- // Test error handling with cleanup
- let data: Uint8Array | null = null;
- let errorThrown = false;
-
- try {
- data = new Uint8Array([1, 2, 3]);
- // Simulate error
- throw new Error('Save failed');
- } catch (error) {
- errorThrown = true;
- } finally {
- // Cleanup happens even on error
- data = null;
- }
-
- expect(errorThrown).toBe(true);
- expect(data).toBeNull();
- });
- });
-
- describe('Read vs Write Operation Handling', () => {
- it('should not trigger save on read-only prepare() calls', () => {
- // Test that prepare() doesn't schedule save
- // Only exec() and SQLJSStatement.run() should trigger saves
-
- const mockScheduleSave = vi.fn();
-
- // Simulate prepare() - should NOT call scheduleSave
- // prepare() just creates statement, doesn't modify DB
-
- // Simulate exec() - SHOULD call scheduleSave
- mockScheduleSave();
-
- expect(mockScheduleSave).toHaveBeenCalledTimes(1);
- });
-
- it('should trigger save on write operations (INSERT/UPDATE/DELETE)', () => {
- const mockScheduleSave = vi.fn();
-
- // Simulate write operations
- mockScheduleSave(); // INSERT
- mockScheduleSave(); // UPDATE
- mockScheduleSave(); // DELETE
-
- expect(mockScheduleSave).toHaveBeenCalledTimes(3);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/examples/using-n8n-nodes-base-mock.test.ts b/tests/unit/examples/using-n8n-nodes-base-mock.test.ts
deleted file mode 100644
index 448d2d4..0000000
--- a/tests/unit/examples/using-n8n-nodes-base-mock.test.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { getNodeTypes, mockNodeBehavior, resetAllMocks } from '../__mocks__/n8n-nodes-base';
-
-// Example service that uses n8n-nodes-base
-class WorkflowService {
- async getNodeDescription(nodeName: string) {
- const nodeTypes = getNodeTypes();
- const node = nodeTypes.getByName(nodeName);
- return node?.description;
- }
-
- async executeNode(nodeName: string, context: any) {
- const nodeTypes = getNodeTypes();
- const node = nodeTypes.getByName(nodeName);
-
- if (!node?.execute) {
- throw new Error(`Node ${nodeName} does not have an execute method`);
- }
-
- return node.execute.call(context);
- }
-
- async validateSlackMessage(channel: string, text: string) {
- if (!channel || !text) {
- throw new Error('Channel and text are required');
- }
-
- const nodeTypes = getNodeTypes();
- const slackNode = nodeTypes.getByName('slack');
-
- if (!slackNode) {
- throw new Error('Slack node not found');
- }
-
- // Check if required properties exist
- const channelProp = slackNode.description.properties.find(p => p.name === 'channel');
- const textProp = slackNode.description.properties.find(p => p.name === 'text');
-
- return !!(channelProp && textProp);
- }
-}
-
-// Mock the module at the top level
-vi.mock('n8n-nodes-base', () => {
- const { getNodeTypes: mockGetNodeTypes } = require('../__mocks__/n8n-nodes-base');
- return {
- getNodeTypes: mockGetNodeTypes
- };
-});
-
-describe('WorkflowService with n8n-nodes-base mock', () => {
- let service: WorkflowService;
-
- beforeEach(() => {
- resetAllMocks();
- service = new WorkflowService();
- });
-
- describe('getNodeDescription', () => {
- it('should get webhook node description', async () => {
- const description = await service.getNodeDescription('webhook');
-
- expect(description).toBeDefined();
- expect(description?.name).toBe('webhook');
- expect(description?.group).toContain('trigger');
- expect(description?.webhooks).toBeDefined();
- });
-
- it('should get httpRequest node description', async () => {
- const description = await service.getNodeDescription('httpRequest');
-
- expect(description).toBeDefined();
- expect(description?.name).toBe('httpRequest');
- expect(description?.version).toBe(3);
-
- const methodProp = description?.properties.find(p => p.name === 'method');
- expect(methodProp).toBeDefined();
- expect(methodProp?.options).toHaveLength(6);
- });
- });
-
- describe('executeNode', () => {
- it('should execute httpRequest node with custom response', async () => {
- // Override the httpRequest node behavior for this test
- mockNodeBehavior('httpRequest', {
- execute: vi.fn(async function(this: any) {
- const url = this.getNodeParameter('url', 0);
- return [[{
- json: {
- statusCode: 200,
- url,
- customData: 'mocked response'
- }
- }]];
- })
- });
-
- const mockContext = {
- getInputData: vi.fn(() => [{ json: { input: 'data' } }]),
- getNodeParameter: vi.fn((name: string) => {
- if (name === 'url') return 'https://test.com/api';
- return '';
- })
- };
-
- const result = await service.executeNode('httpRequest', mockContext);
-
- expect(result).toBeDefined();
- expect(result[0][0].json).toMatchObject({
- statusCode: 200,
- url: 'https://test.com/api',
- customData: 'mocked response'
- });
- });
-
- it('should execute slack node and track calls', async () => {
- const mockContext = {
- getInputData: vi.fn(() => [{ json: { message: 'test' } }]),
- getNodeParameter: vi.fn((name: string, index: number) => {
- const params: Record = {
- resource: 'message',
- operation: 'post',
- channel: '#general',
- text: 'Hello from test!'
- };
- return params[name] || '';
- }),
- getCredentials: vi.fn(async () => ({ token: 'mock-token' }))
- };
-
- const result = await service.executeNode('slack', mockContext);
-
- expect(result).toBeDefined();
- expect(result[0][0].json).toMatchObject({
- ok: true,
- channel: '#general',
- message: {
- text: 'Hello from test!'
- }
- });
-
- // Verify the mock was called
- expect(mockContext.getNodeParameter).toHaveBeenCalledWith('channel', 0, '');
- expect(mockContext.getNodeParameter).toHaveBeenCalledWith('text', 0, '');
- });
-
- it('should throw error for non-executable node', async () => {
- // Create a trigger-only node
- mockNodeBehavior('webhook', {
- execute: undefined // Remove execute method
- });
-
- await expect(
- service.executeNode('webhook', {})
- ).rejects.toThrow('Node webhook does not have an execute method');
- });
- });
-
- describe('validateSlackMessage', () => {
- it('should validate slack message parameters', async () => {
- const isValid = await service.validateSlackMessage('#general', 'Hello');
- expect(isValid).toBe(true);
- });
-
- it('should throw error for missing parameters', async () => {
- await expect(
- service.validateSlackMessage('', 'Hello')
- ).rejects.toThrow('Channel and text are required');
-
- await expect(
- service.validateSlackMessage('#general', '')
- ).rejects.toThrow('Channel and text are required');
- });
-
- it('should handle missing slack node', async () => {
- // Save the original mock implementation
- const originalImplementation = vi.mocked(getNodeTypes).getMockImplementation();
-
- // Override getNodeTypes to return undefined for slack
- vi.mocked(getNodeTypes).mockImplementation(() => ({
- getByName: vi.fn((name: string) => {
- if (name === 'slack') return undefined;
- // Return the actual mock implementation for other nodes
- const actualRegistry = originalImplementation ? originalImplementation() : getNodeTypes();
- return actualRegistry.getByName(name);
- }),
- getByNameAndVersion: vi.fn()
- }));
-
- await expect(
- service.validateSlackMessage('#general', 'Hello')
- ).rejects.toThrow('Slack node not found');
-
- // Restore the original implementation
- if (originalImplementation) {
- vi.mocked(getNodeTypes).mockImplementation(originalImplementation);
- }
- });
- });
-
- describe('complex workflow scenarios', () => {
- it('should handle if node branching', async () => {
- const mockContext = {
- getInputData: vi.fn(() => [
- { json: { status: 'active' } },
- { json: { status: 'inactive' } },
- { json: { status: 'active' } },
- ]),
- getNodeParameter: vi.fn()
- };
-
- const result = await service.executeNode('if', mockContext);
-
- expect(result).toHaveLength(2); // true and false branches
- expect(result[0]).toHaveLength(2); // items at index 0 and 2
- expect(result[1]).toHaveLength(1); // item at index 1
- });
-
- it('should handle merge node combining inputs', async () => {
- const mockContext = {
- getInputData: vi.fn((inputIndex?: number) => {
- if (inputIndex === 0) return [{ json: { source: 'input1' } }];
- if (inputIndex === 1) return [{ json: { source: 'input2' } }];
- return [{ json: { source: 'input1' } }];
- }),
- getNodeParameter: vi.fn(() => 'append')
- };
-
- const result = await service.executeNode('merge', mockContext);
-
- expect(result).toBeDefined();
- expect(result[0]).toHaveLength(1);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/http-server-n8n-reinit.test.ts b/tests/unit/http-server-n8n-reinit.test.ts
deleted file mode 100644
index c2a280c..0000000
--- a/tests/unit/http-server-n8n-reinit.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
-import express from 'express';
-
-describe('HTTP Server n8n Re-initialization', () => {
- let server: SingleSessionHTTPServer;
- let app: express.Application;
-
- beforeEach(() => {
- // Set required environment variables for testing
- process.env.AUTH_TOKEN = 'test-token-32-chars-minimum-length-for-security';
- process.env.NODE_DB_PATH = ':memory:';
- });
-
- afterEach(async () => {
- if (server) {
- await server.shutdown();
- }
- // Clean up environment
- delete process.env.AUTH_TOKEN;
- delete process.env.NODE_DB_PATH;
- });
-
- it('should handle re-initialization requests gracefully', async () => {
- // Create mock request and response
- const mockReq = {
- method: 'POST',
- url: '/mcp',
- headers: {},
- body: {
- jsonrpc: '2.0',
- id: 1,
- method: 'initialize',
- params: {
- protocolVersion: '2024-11-05',
- capabilities: { tools: {} },
- clientInfo: { name: 'n8n', version: '1.0.0' }
- }
- },
- get: (header: string) => {
- if (header === 'user-agent') return 'test-agent';
- if (header === 'content-length') return '100';
- if (header === 'content-type') return 'application/json';
- return undefined;
- },
- ip: '127.0.0.1'
- } as any;
-
- const mockRes = {
- headersSent: false,
- statusCode: 200,
- finished: false,
- status: (code: number) => mockRes,
- json: (data: any) => mockRes,
- setHeader: (name: string, value: string) => mockRes,
- end: () => mockRes
- } as any;
-
- try {
- server = new SingleSessionHTTPServer();
-
- // First request should work
- await server.handleRequest(mockReq, mockRes);
- expect(mockRes.statusCode).toBe(200);
-
- // Second request (re-initialization) should also work
- mockReq.body.id = 2;
- await server.handleRequest(mockReq, mockRes);
- expect(mockRes.statusCode).toBe(200);
-
- } catch (error) {
- // This test mainly ensures the logic doesn't throw errors
- // The actual MCP communication would need a more complex setup
- console.log('Expected error in unit test environment:', error);
- expect(error).toBeDefined(); // We expect some error due to simplified mock setup
- }
- });
-
- it('should identify initialize requests correctly', () => {
- const initializeRequest = {
- jsonrpc: '2.0',
- id: 1,
- method: 'initialize',
- params: {}
- };
-
- const nonInitializeRequest = {
- jsonrpc: '2.0',
- id: 1,
- method: 'tools/list'
- };
-
- // Test the logic we added for detecting initialize requests
- const isInitReq1 = initializeRequest &&
- initializeRequest.method === 'initialize' &&
- initializeRequest.jsonrpc === '2.0';
-
- const isInitReq2 = nonInitializeRequest &&
- nonInitializeRequest.method === 'initialize' &&
- nonInitializeRequest.jsonrpc === '2.0';
-
- expect(isInitReq1).toBe(true);
- expect(isInitReq2).toBe(false);
- });
-});
\ No newline at end of file
diff --git a/tests/unit/mcp/handlers-n8n-manager-simple.test.ts b/tests/unit/mcp/handlers-n8n-manager-simple.test.ts
deleted file mode 100644
index 4045a02..0000000
--- a/tests/unit/mcp/handlers-n8n-manager-simple.test.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-/**
- * Simple, focused unit tests for handlers-n8n-manager.ts coverage gaps
- *
- * This test file focuses on specific uncovered lines to achieve >95% coverage
- */
-
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { createHash } from 'crypto';
-
-describe('handlers-n8n-manager Simple Coverage Tests', () => {
- beforeEach(() => {
- vi.resetAllMocks();
- vi.resetModules();
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe('Cache Key Generation', () => {
- it('should generate deterministic SHA-256 hashes', () => {
- const input1 = 'https://api.n8n.cloud:key123:instance1';
- const input2 = 'https://api.n8n.cloud:key123:instance1';
- const input3 = 'https://api.n8n.cloud:key456:instance2';
-
- const hash1 = createHash('sha256').update(input1).digest('hex');
- const hash2 = createHash('sha256').update(input2).digest('hex');
- const hash3 = createHash('sha256').update(input3).digest('hex');
-
- // Same input should produce same hash
- expect(hash1).toBe(hash2);
- // Different input should produce different hash
- expect(hash1).not.toBe(hash3);
- // Hash should be 64 characters (SHA-256)
- expect(hash1).toHaveLength(64);
- expect(hash1).toMatch(/^[a-f0-9]{64}$/);
- });
-
- it('should handle empty instanceId in cache key generation', () => {
- const url = 'https://api.n8n.cloud';
- const key = 'test-key';
- const instanceId = '';
-
- const cacheInput = `${url}:${key}:${instanceId}`;
- const hash = createHash('sha256').update(cacheInput).digest('hex');
-
- expect(hash).toBeDefined();
- expect(hash).toHaveLength(64);
- });
-
- it('should handle undefined values in cache key generation', () => {
- const url = 'https://api.n8n.cloud';
- const key = 'test-key';
- const instanceId = undefined;
-
- // This simulates the actual cache key generation in the code
- const cacheInput = `${url}:${key}:${instanceId || ''}`;
- const hash = createHash('sha256').update(cacheInput).digest('hex');
-
- expect(hash).toBeDefined();
- expect(cacheInput).toBe('https://api.n8n.cloud:test-key:');
- });
- });
-
- describe('URL Sanitization', () => {
- it('should sanitize URLs for logging', () => {
- const fullUrl = 'https://secret.example.com/api/v1/private';
-
- // This simulates the URL sanitization in the logging code
- const sanitizedUrl = fullUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1');
-
- expect(sanitizedUrl).toBe('https://secret.example.com');
- expect(sanitizedUrl).not.toContain('/api/v1/private');
- });
-
- it('should handle various URL formats in sanitization', () => {
- const testUrls = [
- 'https://api.n8n.cloud',
- 'https://api.n8n.cloud/',
- 'https://api.n8n.cloud/webhook/abc123',
- 'http://localhost:5678/api/v1',
- 'https://subdomain.domain.com/path/to/resource'
- ];
-
- testUrls.forEach(url => {
- const sanitized = url.replace(/^(https?:\/\/[^\/]+).*/, '$1');
-
- // Should contain protocol and domain only
- expect(sanitized).toMatch(/^https?:\/\/[^\/]+$/);
- // Should not contain paths (but domain names containing 'api' are OK)
- expect(sanitized).not.toContain('/webhook');
- if (!sanitized.includes('api.n8n.cloud')) {
- expect(sanitized).not.toContain('/api');
- }
- expect(sanitized).not.toContain('/path');
- });
- });
- });
-
- describe('Cache Key Partial Logging', () => {
- it('should create partial cache key for logging', () => {
- const fullHash = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
-
- // This simulates the partial key logging in the dispose callback
- const partialKey = fullHash.substring(0, 8) + '...';
-
- expect(partialKey).toBe('abcdef12...');
- expect(partialKey).toHaveLength(11);
- expect(partialKey).toMatch(/^[a-f0-9]{8}\.\.\.$/);
- });
-
- it('should handle various hash lengths for partial logging', () => {
- const hashes = [
- 'a'.repeat(64),
- 'b'.repeat(32),
- 'c'.repeat(16),
- 'd'.repeat(8)
- ];
-
- hashes.forEach(hash => {
- const partial = hash.substring(0, 8) + '...';
- expect(partial).toHaveLength(11);
- expect(partial.endsWith('...')).toBe(true);
- });
- });
- });
-
- describe('Error Message Handling', () => {
- it('should handle different error types correctly', () => {
- // Test the error handling patterns used in the handlers
- const errorTypes = [
- new Error('Standard error'),
- 'String error',
- { message: 'Object error' },
- null,
- undefined
- ];
-
- errorTypes.forEach(error => {
- // This simulates the error handling in handlers
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
-
- if (error instanceof Error) {
- expect(errorMessage).toBe(error.message);
- } else {
- expect(errorMessage).toBe('Unknown error occurred');
- }
- });
- });
-
- it('should handle error objects without message property', () => {
- const errorLikeObject = { code: 500, details: 'Some details' };
-
- // This simulates error handling for non-Error objects
- const errorMessage = errorLikeObject instanceof Error ?
- errorLikeObject.message : 'Unknown error occurred';
-
- expect(errorMessage).toBe('Unknown error occurred');
- });
- });
-
- describe('Configuration Fallbacks', () => {
- it('should handle null config scenarios', () => {
- // Test configuration fallback logic
- const config = null;
- const apiConfigured = config !== null;
-
- expect(apiConfigured).toBe(false);
- });
-
- it('should handle undefined config values', () => {
- const contextWithUndefined = {
- n8nApiUrl: 'https://api.n8n.cloud',
- n8nApiKey: 'test-key',
- n8nApiTimeout: undefined,
- n8nApiMaxRetries: undefined
- };
-
- // Test default value assignment using nullish coalescing
- const timeout = contextWithUndefined.n8nApiTimeout ?? 30000;
- const maxRetries = contextWithUndefined.n8nApiMaxRetries ?? 3;
-
- expect(timeout).toBe(30000);
- expect(maxRetries).toBe(3);
- });
- });
-
- describe('Array and Object Handling', () => {
- it('should handle undefined array lengths', () => {
- const workflowData: { nodes?: any[] } = {
- nodes: undefined
- };
-
- // This simulates the nodeCount calculation in list workflows
- const nodeCount = workflowData.nodes?.length || 0;
-
- expect(nodeCount).toBe(0);
- });
-
- it('should handle empty arrays', () => {
- const workflowData = {
- nodes: []
- };
-
- const nodeCount = workflowData.nodes?.length || 0;
-
- expect(nodeCount).toBe(0);
- });
-
- it('should handle arrays with elements', () => {
- const workflowData = {
- nodes: [{ id: 'node1' }, { id: 'node2' }]
- };
-
- const nodeCount = workflowData.nodes?.length || 0;
-
- expect(nodeCount).toBe(2);
- });
- });
-
- describe('Conditional Logic Coverage', () => {
- it('should handle truthy cursor values', () => {
- const response = {
- nextCursor: 'abc123'
- };
-
- // This simulates the cursor handling logic
- const hasMore = !!response.nextCursor;
- const noteCondition = response.nextCursor ? {
- _note: "More workflows available. Use cursor to get next page."
- } : {};
-
- expect(hasMore).toBe(true);
- expect(noteCondition._note).toBeDefined();
- });
-
- it('should handle falsy cursor values', () => {
- const response = {
- nextCursor: null
- };
-
- const hasMore = !!response.nextCursor;
- const noteCondition = response.nextCursor ? {
- _note: "More workflows available. Use cursor to get next page."
- } : {};
-
- expect(hasMore).toBe(false);
- expect(noteCondition._note).toBeUndefined();
- });
- });
-
- describe('String Manipulation', () => {
- it('should handle environment variable filtering', () => {
- const envKeys = [
- 'N8N_API_URL',
- 'N8N_API_KEY',
- 'MCP_MODE',
- 'NODE_ENV',
- 'PATH',
- 'HOME',
- 'N8N_CUSTOM_VAR'
- ];
-
- // This simulates the environment variable filtering in diagnostic
- const filtered = envKeys.filter(key =>
- key.startsWith('N8N_') || key.startsWith('MCP_')
- );
-
- expect(filtered).toEqual(['N8N_API_URL', 'N8N_API_KEY', 'MCP_MODE', 'N8N_CUSTOM_VAR']);
- });
-
- it('should handle version string extraction', () => {
- const packageJson = {
- dependencies: {
- n8n: '^1.111.0'
- }
- };
-
- // This simulates the version extraction logic
- const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || '';
-
- expect(supportedVersion).toBe('1.111.0');
- });
-
- it('should handle missing dependencies', () => {
- const packageJson: { dependencies?: { n8n?: string } } = {};
-
- const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || '';
-
- expect(supportedVersion).toBe('');
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/ai-node-validator.test.ts b/tests/unit/services/ai-node-validator.test.ts
deleted file mode 100644
index cbd9442..0000000
--- a/tests/unit/services/ai-node-validator.test.ts
+++ /dev/null
@@ -1,752 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- validateAIAgent,
- validateChatTrigger,
- validateBasicLLMChain,
- buildReverseConnectionMap,
- getAIConnections,
- validateAISpecificNodes,
- type WorkflowNode,
- type WorkflowJson
-} from '@/services/ai-node-validator';
-
-describe('AI Node Validator', () => {
- describe('buildReverseConnectionMap', () => {
- it('should build reverse connections for AI language model', () => {
- const workflow: WorkflowJson = {
- nodes: [],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
-
- expect(reverseMap.get('AI Agent')).toEqual([
- {
- sourceName: 'OpenAI',
- sourceType: 'ai_languageModel',
- type: 'ai_languageModel',
- index: 0
- }
- ]);
- });
-
- it('should handle multiple AI connections to same node', () => {
- const workflow: WorkflowJson = {
- nodes: [],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'HTTP Request Tool': {
- 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- },
- 'Window Buffer Memory': {
- 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const agentConnections = reverseMap.get('AI Agent');
-
- expect(agentConnections).toHaveLength(3);
- expect(agentConnections).toContainEqual(
- expect.objectContaining({ type: 'ai_languageModel' })
- );
- expect(agentConnections).toContainEqual(
- expect.objectContaining({ type: 'ai_tool' })
- );
- expect(agentConnections).toContainEqual(
- expect.objectContaining({ type: 'ai_memory' })
- );
- });
-
- it('should skip empty source names', () => {
- const workflow: WorkflowJson = {
- nodes: [],
- connections: {
- '': {
- 'main': [[{ node: 'Target', type: 'main', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
-
- expect(reverseMap.has('Target')).toBe(false);
- });
-
- it('should skip empty target node names', () => {
- const workflow: WorkflowJson = {
- nodes: [],
- connections: {
- 'Source': {
- 'main': [[{ node: '', type: 'main', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
-
- expect(reverseMap.size).toBe(0);
- });
- });
-
- describe('getAIConnections', () => {
- it('should filter AI connections from all incoming connections', () => {
- const reverseMap = new Map();
- reverseMap.set('AI Agent', [
- { sourceName: 'Chat Trigger', type: 'main', index: 0 },
- { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
- { sourceName: 'HTTP Tool', type: 'ai_tool', index: 0 }
- ]);
-
- const aiConnections = getAIConnections('AI Agent', reverseMap);
-
- expect(aiConnections).toHaveLength(2);
- expect(aiConnections).not.toContainEqual(
- expect.objectContaining({ type: 'main' })
- );
- });
-
- it('should filter by specific AI connection type', () => {
- const reverseMap = new Map();
- reverseMap.set('AI Agent', [
- { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
- { sourceName: 'Tool1', type: 'ai_tool', index: 0 },
- { sourceName: 'Tool2', type: 'ai_tool', index: 1 }
- ]);
-
- const toolConnections = getAIConnections('AI Agent', reverseMap, 'ai_tool');
-
- expect(toolConnections).toHaveLength(2);
- expect(toolConnections.every(c => c.type === 'ai_tool')).toBe(true);
- });
-
- it('should return empty array for node with no connections', () => {
- const reverseMap = new Map();
-
- const connections = getAIConnections('Unknown Node', reverseMap);
-
- expect(connections).toEqual([]);
- });
- });
-
- describe('validateAIAgent', () => {
- it('should error on missing language model connection', () => {
- const node: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [node],
- connections: {}
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(node, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- message: expect.stringContaining('language model')
- })
- );
- });
-
- it('should accept single language model connection', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: { promptType: 'auto' }
- };
-
- const model: WorkflowNode = {
- id: 'llm1',
- name: 'OpenAI',
- type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
- position: [0, -100],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent, model],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- const languageModelErrors = issues.filter(i =>
- i.severity === 'error' && i.message.includes('language model')
- );
- expect(languageModelErrors).toHaveLength(0);
- });
-
- it('should accept dual language model connection for fallback', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: { promptType: 'auto' },
- typeVersion: 1.7
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI GPT-4': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'OpenAI GPT-3.5': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- const excessModelErrors = issues.filter(i =>
- i.severity === 'error' && i.message.includes('more than 2')
- );
- expect(excessModelErrors).toHaveLength(0);
- });
-
- it('should error on more than 2 language model connections', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'Model1': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'Model2': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
- },
- 'Model3': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 2 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'TOO_MANY_LANGUAGE_MODELS'
- })
- );
- });
-
- it('should error on streaming mode with main output connections', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {
- promptType: 'auto',
- options: { streamResponse: true }
- }
- };
-
- const responseNode: WorkflowNode = {
- id: 'response1',
- name: 'Response Node',
- type: 'n8n-nodes-base.respondToWebhook',
- position: [200, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent, responseNode],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'AI Agent': {
- 'main': [[{ node: 'Response Node', type: 'main', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'STREAMING_WITH_MAIN_OUTPUT'
- })
- );
- });
-
- it('should error on missing prompt text for define promptType', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {
- promptType: 'define'
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'MISSING_PROMPT_TEXT'
- })
- );
- });
-
- it('should info on short systemMessage', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {
- promptType: 'auto',
- systemMessage: 'Help user' // Too short (< 20 chars)
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'info',
- message: expect.stringContaining('systemMessage is very short')
- })
- );
- });
-
- it('should error on multiple memory connections', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: { promptType: 'auto' }
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'Memory1': {
- 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
- },
- 'Memory2': {
- 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 1 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'MULTIPLE_MEMORY_CONNECTIONS'
- })
- );
- });
-
- it('should warn on high maxIterations', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {
- promptType: 'auto',
- maxIterations: 60 // Exceeds threshold of 50
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'warning',
- message: expect.stringContaining('maxIterations')
- })
- );
- });
-
- it('should validate output parser with hasOutputParser flag', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {
- promptType: 'auto',
- hasOutputParser: true
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateAIAgent(agent, reverseMap, workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- message: expect.stringContaining('output parser')
- })
- );
- });
- });
-
- describe('validateChatTrigger', () => {
- it('should error on streaming mode to non-AI-Agent target', () => {
- const trigger: WorkflowNode = {
- id: 'chat1',
- name: 'Chat Trigger',
- type: '@n8n/n8n-nodes-langchain.chatTrigger',
- position: [0, 0],
- parameters: {
- options: { responseMode: 'streaming' }
- }
- };
-
- const codeNode: WorkflowNode = {
- id: 'code1',
- name: 'Code',
- type: 'n8n-nodes-base.code',
- position: [200, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [trigger, codeNode],
- connections: {
- 'Chat Trigger': {
- 'main': [[{ node: 'Code', type: 'main', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateChatTrigger(trigger, workflow, reverseMap);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'STREAMING_WRONG_TARGET'
- })
- );
- });
-
- it('should pass valid Chat Trigger with streaming to AI Agent', () => {
- const trigger: WorkflowNode = {
- id: 'chat1',
- name: 'Chat Trigger',
- type: '@n8n/n8n-nodes-langchain.chatTrigger',
- position: [0, 0],
- parameters: {
- options: { responseMode: 'streaming' }
- }
- };
-
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [200, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [trigger, agent],
- connections: {
- 'Chat Trigger': {
- 'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateChatTrigger(trigger, workflow, reverseMap);
-
- const errors = issues.filter(i => i.severity === 'error');
- expect(errors).toHaveLength(0);
- });
-
- it('should error on missing outgoing connections', () => {
- const trigger: WorkflowNode = {
- id: 'chat1',
- name: 'Chat Trigger',
- type: '@n8n/n8n-nodes-langchain.chatTrigger',
- position: [0, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [trigger],
- connections: {}
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateChatTrigger(trigger, workflow, reverseMap);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- code: 'MISSING_CONNECTIONS'
- })
- );
- });
- });
-
- describe('validateBasicLLMChain', () => {
- it('should error on missing language model connection', () => {
- const chain: WorkflowNode = {
- id: 'chain1',
- name: 'LLM Chain',
- type: '@n8n/n8n-nodes-langchain.chainLlm',
- position: [0, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [chain],
- connections: {}
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateBasicLLMChain(chain, reverseMap);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- message: expect.stringContaining('language model')
- })
- );
- });
-
- it('should pass valid LLM Chain', () => {
- const chain: WorkflowNode = {
- id: 'chain1',
- name: 'LLM Chain',
- type: '@n8n/n8n-nodes-langchain.chainLlm',
- position: [0, 0],
- parameters: {
- prompt: 'Summarize the following text: {{$json.text}}'
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [chain],
- connections: {
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'LLM Chain', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const reverseMap = buildReverseConnectionMap(workflow);
- const issues = validateBasicLLMChain(chain, reverseMap);
-
- const errors = issues.filter(i => i.severity === 'error');
- expect(errors).toHaveLength(0);
- });
- });
-
- describe('validateAISpecificNodes', () => {
- it('should validate complete AI Agent workflow', () => {
- const chatTrigger: WorkflowNode = {
- id: 'chat1',
- name: 'Chat Trigger',
- type: '@n8n/n8n-nodes-langchain.chatTrigger',
- position: [0, 0],
- parameters: {}
- };
-
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [200, 0],
- parameters: {
- promptType: 'auto'
- }
- };
-
- const model: WorkflowNode = {
- id: 'llm1',
- name: 'OpenAI',
- type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
- position: [200, -100],
- parameters: {}
- };
-
- const httpTool: WorkflowNode = {
- id: 'tool1',
- name: 'Weather API',
- type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
- position: [200, 100],
- parameters: {
- toolDescription: 'Get current weather for a city',
- method: 'GET',
- url: 'https://api.weather.com/v1/current?city={city}',
- placeholderDefinitions: {
- values: [
- { name: 'city', description: 'City name' }
- ]
- }
- }
- };
-
- const workflow: WorkflowJson = {
- nodes: [chatTrigger, agent, model, httpTool],
- connections: {
- 'Chat Trigger': {
- 'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
- },
- 'OpenAI': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'Weather API': {
- 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const issues = validateAISpecificNodes(workflow);
-
- const errors = issues.filter(i => i.severity === 'error');
- expect(errors).toHaveLength(0);
- });
-
- it('should detect missing language model in workflow', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: {}
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent],
- connections: {}
- };
-
- const issues = validateAISpecificNodes(workflow);
-
- expect(issues).toContainEqual(
- expect.objectContaining({
- severity: 'error',
- message: expect.stringContaining('language model')
- })
- );
- });
-
- it('should validate all AI tool sub-nodes in workflow', () => {
- const agent: WorkflowNode = {
- id: 'agent1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [0, 0],
- parameters: { promptType: 'auto' }
- };
-
- const invalidTool: WorkflowNode = {
- id: 'tool1',
- name: 'Bad Tool',
- type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
- position: [0, 100],
- parameters: {} // Missing toolDescription and url
- };
-
- const workflow: WorkflowJson = {
- nodes: [agent, invalidTool],
- connections: {
- 'Model': {
- 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- },
- 'Bad Tool': {
- 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const issues = validateAISpecificNodes(workflow);
-
- // Should have errors from missing toolDescription and url
- expect(issues.filter(i => i.severity === 'error').length).toBeGreaterThan(0);
- });
- });
-});
diff --git a/tests/unit/services/ai-tool-validators.test.ts b/tests/unit/services/ai-validators.test.ts
similarity index 52%
rename from tests/unit/services/ai-tool-validators.test.ts
rename to tests/unit/services/ai-validators.test.ts
index deaadb3..78ea593 100644
--- a/tests/unit/services/ai-tool-validators.test.ts
+++ b/tests/unit/services/ai-validators.test.ts
@@ -1,4 +1,14 @@
import { describe, it, expect } from 'vitest';
+import {
+ validateAIAgent,
+ validateChatTrigger,
+ validateBasicLLMChain,
+ buildReverseConnectionMap,
+ getAIConnections,
+ validateAISpecificNodes,
+ type WorkflowNode,
+ type WorkflowJson
+} from '@/services/ai-node-validator';
import {
validateHTTPRequestTool,
validateCodeTool,
@@ -12,9 +22,748 @@ import {
validateWikipediaTool,
validateSearXngTool,
validateWolframAlphaTool,
- type WorkflowNode
} from '@/services/ai-tool-validators';
+describe('AI Node Validator', () => {
+ describe('buildReverseConnectionMap', () => {
+ it('should build reverse connections for AI language model', () => {
+ const workflow: WorkflowJson = {
+ nodes: [],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+
+ expect(reverseMap.get('AI Agent')).toEqual([
+ {
+ sourceName: 'OpenAI',
+ sourceType: 'ai_languageModel',
+ type: 'ai_languageModel',
+ index: 0
+ }
+ ]);
+ });
+
+ it('should handle multiple AI connections to same node', () => {
+ const workflow: WorkflowJson = {
+ nodes: [],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'HTTP Request Tool': {
+ 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
+ },
+ 'Window Buffer Memory': {
+ 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const agentConnections = reverseMap.get('AI Agent');
+
+ expect(agentConnections).toHaveLength(3);
+ expect(agentConnections).toContainEqual(
+ expect.objectContaining({ type: 'ai_languageModel' })
+ );
+ expect(agentConnections).toContainEqual(
+ expect.objectContaining({ type: 'ai_tool' })
+ );
+ expect(agentConnections).toContainEqual(
+ expect.objectContaining({ type: 'ai_memory' })
+ );
+ });
+
+ it('should skip empty source names', () => {
+ const workflow: WorkflowJson = {
+ nodes: [],
+ connections: {
+ '': {
+ 'main': [[{ node: 'Target', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+
+ expect(reverseMap.has('Target')).toBe(false);
+ });
+
+ it('should skip empty target node names', () => {
+ const workflow: WorkflowJson = {
+ nodes: [],
+ connections: {
+ 'Source': {
+ 'main': [[{ node: '', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+
+ expect(reverseMap.size).toBe(0);
+ });
+ });
+
+ describe('getAIConnections', () => {
+ it('should filter AI connections from all incoming connections', () => {
+ const reverseMap = new Map();
+ reverseMap.set('AI Agent', [
+ { sourceName: 'Chat Trigger', type: 'main', index: 0 },
+ { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
+ { sourceName: 'HTTP Tool', type: 'ai_tool', index: 0 }
+ ]);
+
+ const aiConnections = getAIConnections('AI Agent', reverseMap);
+
+ expect(aiConnections).toHaveLength(2);
+ expect(aiConnections).not.toContainEqual(
+ expect.objectContaining({ type: 'main' })
+ );
+ });
+
+ it('should filter by specific AI connection type', () => {
+ const reverseMap = new Map();
+ reverseMap.set('AI Agent', [
+ { sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
+ { sourceName: 'Tool1', type: 'ai_tool', index: 0 },
+ { sourceName: 'Tool2', type: 'ai_tool', index: 1 }
+ ]);
+
+ const toolConnections = getAIConnections('AI Agent', reverseMap, 'ai_tool');
+
+ expect(toolConnections).toHaveLength(2);
+ expect(toolConnections.every(c => c.type === 'ai_tool')).toBe(true);
+ });
+
+ it('should return empty array for node with no connections', () => {
+ const reverseMap = new Map();
+
+ const connections = getAIConnections('Unknown Node', reverseMap);
+
+ expect(connections).toEqual([]);
+ });
+ });
+
+ describe('validateAIAgent', () => {
+ it('should error on missing language model connection', () => {
+ const node: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [node],
+ connections: {}
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(node, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ message: expect.stringContaining('language model')
+ })
+ );
+ });
+
+ it('should accept single language model connection', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: { promptType: 'auto' }
+ };
+
+ const model: WorkflowNode = {
+ id: 'llm1',
+ name: 'OpenAI',
+ type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
+ position: [0, -100],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent, model],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ const languageModelErrors = issues.filter(i =>
+ i.severity === 'error' && i.message.includes('language model')
+ );
+ expect(languageModelErrors).toHaveLength(0);
+ });
+
+ it('should accept dual language model connection for fallback', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: { promptType: 'auto' },
+ typeVersion: 1.7
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI GPT-4': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'OpenAI GPT-3.5': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ const excessModelErrors = issues.filter(i =>
+ i.severity === 'error' && i.message.includes('more than 2')
+ );
+ expect(excessModelErrors).toHaveLength(0);
+ });
+
+ it('should error on more than 2 language model connections', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'Model1': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'Model2': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
+ },
+ 'Model3': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 2 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'TOO_MANY_LANGUAGE_MODELS'
+ })
+ );
+ });
+
+ it('should error on streaming mode with main output connections', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {
+ promptType: 'auto',
+ options: { streamResponse: true }
+ }
+ };
+
+ const responseNode: WorkflowNode = {
+ id: 'response1',
+ name: 'Response Node',
+ type: 'n8n-nodes-base.respondToWebhook',
+ position: [200, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent, responseNode],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'AI Agent': {
+ 'main': [[{ node: 'Response Node', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'STREAMING_WITH_MAIN_OUTPUT'
+ })
+ );
+ });
+
+ it('should error on missing prompt text for define promptType', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {
+ promptType: 'define'
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'MISSING_PROMPT_TEXT'
+ })
+ );
+ });
+
+ it('should info on short systemMessage', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {
+ promptType: 'auto',
+ systemMessage: 'Help user'
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'info',
+ message: expect.stringContaining('systemMessage is very short')
+ })
+ );
+ });
+
+ it('should error on multiple memory connections', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: { promptType: 'auto' }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'Memory1': {
+ 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
+ },
+ 'Memory2': {
+ 'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 1 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'MULTIPLE_MEMORY_CONNECTIONS'
+ })
+ );
+ });
+
+ it('should warn on high maxIterations', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {
+ promptType: 'auto',
+ maxIterations: 60
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'warning',
+ message: expect.stringContaining('maxIterations')
+ })
+ );
+ });
+
+ it('should validate output parser with hasOutputParser flag', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {
+ promptType: 'auto',
+ hasOutputParser: true
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateAIAgent(agent, reverseMap, workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ message: expect.stringContaining('output parser')
+ })
+ );
+ });
+ });
+
+ describe('validateChatTrigger', () => {
+ it('should error on streaming mode to non-AI-Agent target', () => {
+ const trigger: WorkflowNode = {
+ id: 'chat1',
+ name: 'Chat Trigger',
+ type: '@n8n/n8n-nodes-langchain.chatTrigger',
+ position: [0, 0],
+ parameters: {
+ options: { responseMode: 'streaming' }
+ }
+ };
+
+ const codeNode: WorkflowNode = {
+ id: 'code1',
+ name: 'Code',
+ type: 'n8n-nodes-base.code',
+ position: [200, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [trigger, codeNode],
+ connections: {
+ 'Chat Trigger': {
+ 'main': [[{ node: 'Code', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateChatTrigger(trigger, workflow, reverseMap);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'STREAMING_WRONG_TARGET'
+ })
+ );
+ });
+
+ it('should pass valid Chat Trigger with streaming to AI Agent', () => {
+ const trigger: WorkflowNode = {
+ id: 'chat1',
+ name: 'Chat Trigger',
+ type: '@n8n/n8n-nodes-langchain.chatTrigger',
+ position: [0, 0],
+ parameters: {
+ options: { responseMode: 'streaming' }
+ }
+ };
+
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [200, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [trigger, agent],
+ connections: {
+ 'Chat Trigger': {
+ 'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateChatTrigger(trigger, workflow, reverseMap);
+
+ const errors = issues.filter(i => i.severity === 'error');
+ expect(errors).toHaveLength(0);
+ });
+
+ it('should error on missing outgoing connections', () => {
+ const trigger: WorkflowNode = {
+ id: 'chat1',
+ name: 'Chat Trigger',
+ type: '@n8n/n8n-nodes-langchain.chatTrigger',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [trigger],
+ connections: {}
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateChatTrigger(trigger, workflow, reverseMap);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ code: 'MISSING_CONNECTIONS'
+ })
+ );
+ });
+ });
+
+ describe('validateBasicLLMChain', () => {
+ it('should error on missing language model connection', () => {
+ const chain: WorkflowNode = {
+ id: 'chain1',
+ name: 'LLM Chain',
+ type: '@n8n/n8n-nodes-langchain.chainLlm',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [chain],
+ connections: {}
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateBasicLLMChain(chain, reverseMap);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ message: expect.stringContaining('language model')
+ })
+ );
+ });
+
+ it('should pass valid LLM Chain', () => {
+ const chain: WorkflowNode = {
+ id: 'chain1',
+ name: 'LLM Chain',
+ type: '@n8n/n8n-nodes-langchain.chainLlm',
+ position: [0, 0],
+ parameters: {
+ prompt: 'Summarize the following text: {{$json.text}}'
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [chain],
+ connections: {
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'LLM Chain', type: 'ai_languageModel', index: 0 }]]
+ }
+ }
+ };
+
+ const reverseMap = buildReverseConnectionMap(workflow);
+ const issues = validateBasicLLMChain(chain, reverseMap);
+
+ const errors = issues.filter(i => i.severity === 'error');
+ expect(errors).toHaveLength(0);
+ });
+ });
+
+ describe('validateAISpecificNodes', () => {
+ it('should validate complete AI Agent workflow', () => {
+ const chatTrigger: WorkflowNode = {
+ id: 'chat1',
+ name: 'Chat Trigger',
+ type: '@n8n/n8n-nodes-langchain.chatTrigger',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [200, 0],
+ parameters: {
+ promptType: 'auto'
+ }
+ };
+
+ const model: WorkflowNode = {
+ id: 'llm1',
+ name: 'OpenAI',
+ type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
+ position: [200, -100],
+ parameters: {}
+ };
+
+ const httpTool: WorkflowNode = {
+ id: 'tool1',
+ name: 'Weather API',
+ type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
+ position: [200, 100],
+ parameters: {
+ toolDescription: 'Get current weather for a city',
+ method: 'GET',
+ url: 'https://api.weather.com/v1/current?city={city}',
+ placeholderDefinitions: {
+ values: [
+ { name: 'city', description: 'City name' }
+ ]
+ }
+ }
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [chatTrigger, agent, model, httpTool],
+ connections: {
+ 'Chat Trigger': {
+ 'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
+ },
+ 'OpenAI': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'Weather API': {
+ 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
+ }
+ }
+ };
+
+ const issues = validateAISpecificNodes(workflow);
+
+ const errors = issues.filter(i => i.severity === 'error');
+ expect(errors).toHaveLength(0);
+ });
+
+ it('should detect missing language model in workflow', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent],
+ connections: {}
+ };
+
+ const issues = validateAISpecificNodes(workflow);
+
+ expect(issues).toContainEqual(
+ expect.objectContaining({
+ severity: 'error',
+ message: expect.stringContaining('language model')
+ })
+ );
+ });
+
+ it('should validate all AI tool sub-nodes in workflow', () => {
+ const agent: WorkflowNode = {
+ id: 'agent1',
+ name: 'AI Agent',
+ type: '@n8n/n8n-nodes-langchain.agent',
+ position: [0, 0],
+ parameters: { promptType: 'auto' }
+ };
+
+ const invalidTool: WorkflowNode = {
+ id: 'tool1',
+ name: 'Bad Tool',
+ type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
+ position: [0, 100],
+ parameters: {}
+ };
+
+ const workflow: WorkflowJson = {
+ nodes: [agent, invalidTool],
+ connections: {
+ 'Model': {
+ 'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
+ },
+ 'Bad Tool': {
+ 'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
+ }
+ }
+ };
+
+ const issues = validateAISpecificNodes(workflow);
+
+ expect(issues.filter(i => i.severity === 'error').length).toBeGreaterThan(0);
+ });
+ });
+});
+
describe('AI Tool Validators', () => {
describe('validateHTTPRequestTool', () => {
it('should error on missing toolDescription', () => {
@@ -48,7 +797,7 @@ describe('AI Tool Validators', () => {
parameters: {
method: 'GET',
url: 'https://api.weather.com/data',
- toolDescription: 'Weather' // Too short (7 chars, need 15)
+ toolDescription: 'Weather'
}
};
@@ -120,7 +869,6 @@ describe('AI Tool Validators', () => {
const issues = validateHTTPRequestTool(node);
- // Should not error on URL format when it contains expressions
const urlErrors = issues.filter(i => i.code === 'INVALID_URL_FORMAT');
expect(urlErrors).toHaveLength(0);
});
@@ -194,7 +942,6 @@ describe('AI Tool Validators', () => {
const issues = validateHTTPRequestTool(node);
- // Should have no errors
const errors = issues.filter(i => i.severity === 'error');
expect(errors).toHaveLength(0);
});
@@ -327,7 +1074,7 @@ return { cost: cost.toFixed(2) };`,
position: [0, 0],
parameters: {
toolDescription: 'Search through product documentation',
- topK: 25 // Exceeds threshold of 20
+ topK: 25
}
};
@@ -456,7 +1203,7 @@ return { cost: cost.toFixed(2) };`,
position: [0, 0],
parameters: {
toolDescription: 'Performs complex research tasks',
- maxIterations: 60 // Exceeds threshold of 50
+ maxIterations: 60
}
};
@@ -565,7 +1312,6 @@ return { cost: cost.toFixed(2) };`,
const issues = validateCalculatorTool(node);
- // Calculator Tool has built-in description, no validation needed
expect(issues).toHaveLength(0);
});
@@ -599,7 +1345,6 @@ return { cost: cost.toFixed(2) };`,
const issues = validateThinkTool(node);
- // Think Tool has built-in description, no validation needed
expect(issues).toHaveLength(0);
});
diff --git a/tests/unit/services/config-validator-cnd.test.ts b/tests/unit/services/config-validator-cnd.test.ts
deleted file mode 100644
index 267212c..0000000
--- a/tests/unit/services/config-validator-cnd.test.ts
+++ /dev/null
@@ -1,524 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { ConfigValidator } from '../../../src/services/config-validator';
-
-describe('ConfigValidator _cnd operators', () => {
- describe('isPropertyVisible with _cnd operators', () => {
- describe('eq operator', () => {
- it('should match when values are equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { status: [{ _cnd: { eq: 'active' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
- });
-
- it('should not match when values are not equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { status: [{ _cnd: { eq: 'active' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'inactive' })).toBe(false);
- });
-
- it('should match numeric equality', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { eq: 1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
- });
- });
-
- describe('not operator', () => {
- it('should match when values are not equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { status: [{ _cnd: { not: 'disabled' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
- });
-
- it('should not match when values are equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { status: [{ _cnd: { not: 'disabled' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'disabled' })).toBe(false);
- });
- });
-
- describe('gte operator (greater than or equal)', () => {
- it('should match when value is greater', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { gte: 1.1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
- });
-
- it('should match when value is equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { gte: 1.1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(true);
- });
-
- it('should not match when value is less', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { gte: 1.1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0 })).toBe(false);
- });
- });
-
- describe('lte operator (less than or equal)', () => {
- it('should match when value is less', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { lte: 2.0 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.5 })).toBe(true);
- });
-
- it('should match when value is equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { lte: 2.0 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
- });
-
- it('should not match when value is greater', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { lte: 2.0 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.5 })).toBe(false);
- });
- });
-
- describe('gt operator (greater than)', () => {
- it('should match when value is greater', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { count: [{ _cnd: { gt: 5 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { count: 10 })).toBe(true);
- });
-
- it('should not match when value is equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { count: [{ _cnd: { gt: 5 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { count: 5 })).toBe(false);
- });
- });
-
- describe('lt operator (less than)', () => {
- it('should match when value is less', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { count: [{ _cnd: { lt: 10 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { count: 5 })).toBe(true);
- });
-
- it('should not match when value is equal', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { count: [{ _cnd: { lt: 10 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { count: 10 })).toBe(false);
- });
- });
-
- describe('between operator', () => {
- it('should match when value is within range', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4.3 })).toBe(true);
- });
-
- it('should match when value equals lower bound', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(true);
- });
-
- it('should match when value equals upper bound', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4.6 })).toBe(true);
- });
-
- it('should not match when value is below range', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 3.9 })).toBe(false);
- });
-
- it('should not match when value is above range', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 5 })).toBe(false);
- });
-
- it('should not match when between structure is null', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: null } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
- });
-
- it('should not match when between is missing from field', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { to: 5 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
- });
-
- it('should not match when between is missing to field', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { '@version': [{ _cnd: { between: { from: 3 } } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
- });
- });
-
- describe('startsWith operator', () => {
- it('should match when string starts with prefix', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { name: [{ _cnd: { startsWith: 'test' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { name: 'testUser' })).toBe(true);
- });
-
- it('should not match when string does not start with prefix', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { name: [{ _cnd: { startsWith: 'test' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { name: 'mytest' })).toBe(false);
- });
-
- it('should not match non-string values', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { value: [{ _cnd: { startsWith: 'test' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { value: 123 })).toBe(false);
- });
- });
-
- describe('endsWith operator', () => {
- it('should match when string ends with suffix', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { email: [{ _cnd: { endsWith: '@example.com' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { email: 'user@example.com' })).toBe(true);
- });
-
- it('should not match when string does not end with suffix', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { email: [{ _cnd: { endsWith: '@example.com' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { email: 'user@other.com' })).toBe(false);
- });
- });
-
- describe('includes operator', () => {
- it('should match when string contains substring', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { eventId: [{ _cnd: { includes: '_' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { eventId: 'event_123' })).toBe(true);
- });
-
- it('should not match when string does not contain substring', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { eventId: [{ _cnd: { includes: '_' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { eventId: 'event123' })).toBe(false);
- });
- });
-
- describe('regex operator', () => {
- it('should match when string matches regex pattern', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { id: 'ABC1234' })).toBe(true);
- });
-
- it('should not match when string does not match regex pattern', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { id: 'abc1234' })).toBe(false);
- });
-
- it('should not match when regex pattern is invalid', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { id: [{ _cnd: { regex: '[invalid(regex' } }] }
- }
- };
- // Invalid regex should return false without throwing
- expect(ConfigValidator.isPropertyVisible(prop, { id: 'test' })).toBe(false);
- });
-
- it('should not match non-string values', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { value: [{ _cnd: { regex: '\\d+' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { value: 123 })).toBe(false);
- });
- });
-
- describe('exists operator', () => {
- it('should match when field exists and is not null', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { optionalField: [{ _cnd: { exists: true } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { optionalField: 'value' })).toBe(true);
- });
-
- it('should match when field exists with value 0', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { optionalField: [{ _cnd: { exists: true } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { optionalField: 0 })).toBe(true);
- });
-
- it('should match when field exists with empty string', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { optionalField: [{ _cnd: { exists: true } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { optionalField: '' })).toBe(true);
- });
-
- it('should not match when field is undefined', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { optionalField: [{ _cnd: { exists: true } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { otherField: 'value' })).toBe(false);
- });
-
- it('should not match when field is null', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { optionalField: [{ _cnd: { exists: true } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { optionalField: null })).toBe(false);
- });
- });
-
- describe('mixed plain values and _cnd conditions', () => {
- it('should match plain value in array with _cnd', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { status: ['active', { _cnd: { eq: 'pending' } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'pending' })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { status: 'disabled' })).toBe(false);
- });
-
- it('should handle multiple conditions with AND logic', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: {
- '@version': [{ _cnd: { gte: 1.1 } }],
- mode: ['advanced']
- }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'advanced' })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'basic' })).toBe(false);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0, mode: 'advanced' })).toBe(false);
- });
- });
-
- describe('hide conditions with _cnd', () => {
- it('should hide property when _cnd condition matches', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- hide: { '@version': [{ _cnd: { lt: 2.0 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.5 })).toBe(false);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.5 })).toBe(true);
- });
- });
-
- describe('Execute Workflow Trigger scenario', () => {
- it('should show property when @version >= 1.1', () => {
- const prop = {
- name: 'inputSource',
- displayOptions: {
- show: { '@version': [{ _cnd: { gte: 1.1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.2 })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
- });
-
- it('should hide property when @version < 1.1', () => {
- const prop = {
- name: 'inputSource',
- displayOptions: {
- show: { '@version': [{ _cnd: { gte: 1.1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0 })).toBe(false);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(false);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 0.9 })).toBe(false);
- });
-
- it('should show outdated version warning only for v1', () => {
- const prop = {
- name: 'outdatedVersionWarning',
- displayOptions: {
- show: { '@version': [{ _cnd: { eq: 1 } }] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(false);
- expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
- });
- });
- });
-
- describe('backward compatibility with plain values', () => {
- it('should continue to work with plain value arrays', () => {
- const prop = {
- name: 'testField',
- displayOptions: {
- show: { resource: ['user', 'message'] }
- }
- };
- expect(ConfigValidator.isPropertyVisible(prop, { resource: 'user' })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { resource: 'message' })).toBe(true);
- expect(ConfigValidator.isPropertyVisible(prop, { resource: 'channel' })).toBe(false);
- });
-
- it('should work with properties without displayOptions', () => {
- const prop = {
- name: 'testField'
- };
- expect(ConfigValidator.isPropertyVisible(prop, {})).toBe(true);
- });
- });
-});
diff --git a/tests/unit/services/config-validator-edge-cases.test.ts b/tests/unit/services/config-validator-edge-cases.test.ts
deleted file mode 100644
index 1d33145..0000000
--- a/tests/unit/services/config-validator-edge-cases.test.ts
+++ /dev/null
@@ -1,387 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { ConfigValidator } from '@/services/config-validator';
-import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
-
-// Mock the database
-vi.mock('better-sqlite3');
-
-describe('ConfigValidator - Edge Cases', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe('Null and Undefined Handling', () => {
- it('should handle null config gracefully', () => {
- const nodeType = 'nodes-base.test';
- const config = null as any;
- const properties: any[] = [];
-
- expect(() => {
- ConfigValidator.validate(nodeType, config, properties);
- }).toThrow(TypeError);
- });
-
- it('should handle undefined config gracefully', () => {
- const nodeType = 'nodes-base.test';
- const config = undefined as any;
- const properties: any[] = [];
-
- expect(() => {
- ConfigValidator.validate(nodeType, config, properties);
- }).toThrow(TypeError);
- });
-
- it('should handle null properties array gracefully', () => {
- const nodeType = 'nodes-base.test';
- const config = {};
- const properties = null as any;
-
- expect(() => {
- ConfigValidator.validate(nodeType, config, properties);
- }).toThrow(TypeError);
- });
-
- it('should handle undefined properties array gracefully', () => {
- const nodeType = 'nodes-base.test';
- const config = {};
- const properties = undefined as any;
-
- expect(() => {
- ConfigValidator.validate(nodeType, config, properties);
- }).toThrow(TypeError);
- });
-
- it('should handle properties with null values in config', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- nullField: null,
- undefinedField: undefined,
- validField: 'value'
- };
- const properties = [
- { name: 'nullField', type: 'string', required: true },
- { name: 'undefinedField', type: 'string', required: true },
- { name: 'validField', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // Check that we have errors for both null and undefined required fields
- expect(result.errors.some(e => e.property === 'nullField')).toBe(true);
- expect(result.errors.some(e => e.property === 'undefinedField')).toBe(true);
-
- // The actual error types might vary, so let's just ensure we caught the errors
- const nullFieldError = result.errors.find(e => e.property === 'nullField');
- const undefinedFieldError = result.errors.find(e => e.property === 'undefinedField');
-
- expect(nullFieldError).toBeDefined();
- expect(undefinedFieldError).toBeDefined();
- });
- });
-
- describe('Boundary Value Testing', () => {
- it('should handle empty arrays', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- arrayField: []
- };
- const properties = [
- { name: 'arrayField', type: 'collection' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(true);
- });
-
- it('should handle very large property arrays', () => {
- const nodeType = 'nodes-base.test';
- const config = { field1: 'value1' };
- const properties = Array(1000).fill(null).map((_, i) => ({
- name: `field${i}`,
- type: 'string'
- }));
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(true);
- });
-
- it('should handle deeply nested displayOptions', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- level1: 'a',
- level2: 'b',
- level3: 'c',
- deepField: 'value'
- };
- const properties = [
- { name: 'level1', type: 'options', options: ['a', 'b'] },
- { name: 'level2', type: 'options', options: ['a', 'b'], displayOptions: { show: { level1: ['a'] } } },
- { name: 'level3', type: 'options', options: ['a', 'b', 'c'], displayOptions: { show: { level1: ['a'], level2: ['b'] } } },
- { name: 'deepField', type: 'string', displayOptions: { show: { level1: ['a'], level2: ['b'], level3: ['c'] } } }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.visibleProperties).toContain('deepField');
- });
-
- it('should handle extremely long string values', () => {
- const nodeType = 'nodes-base.test';
- const longString = 'a'.repeat(10000);
- const config = {
- longField: longString
- };
- const properties = [
- { name: 'longField', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('Invalid Data Type Handling', () => {
- it('should handle NaN values', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- numberField: NaN
- };
- const properties = [
- { name: 'numberField', type: 'number' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // NaN is technically type 'number' in JavaScript, so type validation passes
- // The validator might not have specific NaN checking, so we check for warnings
- // or just verify it doesn't crash
- expect(result).toBeDefined();
- expect(() => result).not.toThrow();
- });
-
- it('should handle Infinity values', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- numberField: Infinity
- };
- const properties = [
- { name: 'numberField', type: 'number' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // Infinity is technically a valid number in JavaScript
- // The validator might not flag it as an error, so just verify it handles it
- expect(result).toBeDefined();
- expect(() => result).not.toThrow();
- });
-
- it('should handle objects when expecting primitives', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- stringField: { nested: 'object' },
- numberField: { value: 123 }
- };
- const properties = [
- { name: 'stringField', type: 'string' },
- { name: 'numberField', type: 'number' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors).toHaveLength(2);
- expect(result.errors.every(e => e.type === 'invalid_type')).toBe(true);
- });
-
- it('should handle circular references in config', () => {
- const nodeType = 'nodes-base.test';
- const config: any = { field: 'value' };
- config.circular = config; // Create circular reference
- const properties = [
- { name: 'field', type: 'string' },
- { name: 'circular', type: 'json' }
- ];
-
- // Should not throw error
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result).toBeDefined();
- });
- });
-
- describe('Performance Boundaries', () => {
- it('should validate large config objects within reasonable time', () => {
- const nodeType = 'nodes-base.test';
- const config: Record = {};
- const properties: any[] = [];
-
- // Create a large config with 1000 properties
- for (let i = 0; i < 1000; i++) {
- config[`field_${i}`] = `value_${i}`;
- properties.push({
- name: `field_${i}`,
- type: 'string'
- });
- }
-
- const startTime = Date.now();
- const result = ConfigValidator.validate(nodeType, config, properties);
- const endTime = Date.now();
-
- expect(result.valid).toBe(true);
- expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
- });
- });
-
- describe('Special Characters and Encoding', () => {
- it('should handle special characters in property values', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- specialField: 'Value with special chars: <>&"\'`\n\r\t'
- };
- const properties = [
- { name: 'specialField', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(true);
- });
-
- it('should handle unicode characters', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- unicodeField: '๐ Unicode: ไฝ ๅฅฝไธ็ ู
ุฑุญุจุง ุจุงูุนุงูู
'
- };
- const properties = [
- { name: 'unicodeField', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('Complex Validation Scenarios', () => {
- it('should handle conflicting displayOptions conditions', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- mode: 'both',
- showField: true,
- conflictField: 'value'
- };
- const properties = [
- { name: 'mode', type: 'options', options: ['show', 'hide', 'both'] },
- { name: 'showField', type: 'boolean' },
- {
- name: 'conflictField',
- type: 'string',
- displayOptions: {
- show: { mode: ['show'], showField: [true] },
- hide: { mode: ['hide'] }
- }
- }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // With mode='both', the field visibility depends on implementation
- expect(result).toBeDefined();
- });
-
- it('should handle multiple validation profiles correctly', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: 'const x = 1;'
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- // Should perform node-specific validation for Code nodes
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.message.includes('No return statement found')
- )).toBe(true);
- });
- });
-
- describe('Error Recovery and Resilience', () => {
- it('should continue validation after encountering errors', () => {
- const nodeType = 'nodes-base.test';
- const config = {
- field1: 'invalid-for-number',
- field2: null, // Required field missing
- field3: 'valid'
- };
- const properties = [
- { name: 'field1', type: 'number' },
- { name: 'field2', type: 'string', required: true },
- { name: 'field3', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // Should have errors for field1 and field2, but field3 should be validated
- expect(result.errors.length).toBeGreaterThanOrEqual(2);
-
- // Check that we have errors for field1 (type error) and field2 (required field)
- const field1Error = result.errors.find(e => e.property === 'field1');
- const field2Error = result.errors.find(e => e.property === 'field2');
-
- expect(field1Error).toBeDefined();
- expect(field1Error?.type).toBe('invalid_type');
-
- expect(field2Error).toBeDefined();
- // field2 is null, which might be treated as invalid_type rather than missing_required
- expect(['missing_required', 'invalid_type']).toContain(field2Error?.type);
-
- expect(result.visibleProperties).toContain('field3');
- });
-
- it('should handle malformed property definitions gracefully', () => {
- const nodeType = 'nodes-base.test';
- const config = { field: 'value' };
- const properties = [
- { name: 'field', type: 'string' },
- { /* Malformed property without name */ type: 'string' } as any,
- { name: 'field2', /* Missing type */ } as any
- ];
-
- // Should handle malformed properties without crashing
- // Note: null properties will cause errors in the current implementation
- const result = ConfigValidator.validate(nodeType, config, properties);
- expect(result).toBeDefined();
- expect(result.valid).toBeDefined();
- });
- });
-
- describe('validateBatch method implementation', () => {
- it('should validate multiple configs in batch if method exists', () => {
- // This test is for future implementation
- const configs = [
- { nodeType: 'nodes-base.test', config: { field: 'value1' }, properties: [] },
- { nodeType: 'nodes-base.test', config: { field: 'value2' }, properties: [] }
- ];
-
- // If validateBatch method is implemented in the future
- if ('validateBatch' in ConfigValidator) {
- const results = (ConfigValidator as any).validateBatch(configs);
- expect(results).toHaveLength(2);
- } else {
- // For now, just validate individually
- const results = configs.map(c =>
- ConfigValidator.validate(c.nodeType, c.config, c.properties)
- );
- expect(results).toHaveLength(2);
- }
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/config-validator-node-specific.test.ts b/tests/unit/services/config-validator-node-specific.test.ts
deleted file mode 100644
index 36f446f..0000000
--- a/tests/unit/services/config-validator-node-specific.test.ts
+++ /dev/null
@@ -1,589 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { ConfigValidator } from '@/services/config-validator';
-import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
-
-// Mock the database
-vi.mock('better-sqlite3');
-
-describe('ConfigValidator - Node-Specific Validation', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe('HTTP Request node validation', () => {
- it('should perform HTTP Request specific validation', () => {
- const nodeType = 'nodes-base.httpRequest';
- const config = {
- method: 'POST',
- url: 'invalid-url', // Missing protocol
- sendBody: false
- };
- const properties = [
- { name: 'method', type: 'options' },
- { name: 'url', type: 'string' },
- { name: 'sendBody', type: 'boolean' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(false);
- expect(result.errors).toHaveLength(1);
- expect(result.errors[0]).toMatchObject({
- type: 'invalid_value',
- property: 'url',
- message: 'URL must start with http:// or https://'
- });
- expect(result.warnings).toHaveLength(1);
- expect(result.warnings[0]).toMatchObject({
- type: 'missing_common',
- property: 'sendBody',
- message: 'POST requests typically send a body'
- });
- expect(result.autofix).toMatchObject({
- sendBody: true,
- contentType: 'json'
- });
- });
-
- it('should validate HTTP Request with authentication in API URLs', () => {
- const nodeType = 'nodes-base.httpRequest';
- const config = {
- method: 'GET',
- url: 'https://api.github.com/user/repos',
- authentication: 'none'
- };
- const properties = [
- { name: 'method', type: 'options' },
- { name: 'url', type: 'string' },
- { name: 'authentication', type: 'options' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'security' &&
- w.message.includes('API endpoints typically require authentication')
- )).toBe(true);
- });
-
- it('should validate JSON in HTTP Request body', () => {
- const nodeType = 'nodes-base.httpRequest';
- const config = {
- method: 'POST',
- url: 'https://api.example.com',
- contentType: 'json',
- body: '{"invalid": json}' // Invalid JSON
- };
- const properties = [
- { name: 'method', type: 'options' },
- { name: 'url', type: 'string' },
- { name: 'contentType', type: 'options' },
- { name: 'body', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors.some(e =>
- e.property === 'body' &&
- e.message.includes('Invalid JSON')
- ));
- });
-
- it('should handle webhook-specific validation', () => {
- const nodeType = 'nodes-base.webhook';
- const config = {
- httpMethod: 'GET',
- path: 'webhook-endpoint' // Missing leading slash
- };
- const properties = [
- { name: 'httpMethod', type: 'options' },
- { name: 'path', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.property === 'path' &&
- w.message.includes('should start with /')
- ));
- });
- });
-
- describe('Code node validation', () => {
- it('should validate Code node configurations', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: '' // Empty code
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(false);
- expect(result.errors).toHaveLength(1);
- expect(result.errors[0]).toMatchObject({
- type: 'missing_required',
- property: 'jsCode',
- message: 'Code cannot be empty'
- });
- });
-
- it('should validate JavaScript syntax in Code node', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const data = { foo: "bar" };
- if (data.foo { // Missing closing parenthesis
- return [{json: data}];
- }
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors.some(e => e.message.includes('Unbalanced')));
- expect(result.warnings).toHaveLength(1);
- });
-
- it('should validate n8n-specific patterns in Code node', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- // Process data without returning
- const processedData = items.map(item => ({
- ...item.json,
- processed: true
- }));
- // No output provided
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // The warning should be about missing return statement
- expect(result.warnings.some(w => w.type === 'missing_common' && w.message.includes('No return statement found'))).toBe(true);
- });
-
- it('should handle empty code in Code node', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: ' \n \t \n ' // Just whitespace
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e =>
- e.type === 'missing_required' &&
- e.message.includes('Code cannot be empty')
- )).toBe(true);
- });
-
- it('should validate complex return patterns in Code node', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- return ["string1", "string2", "string3"];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'invalid_value' &&
- w.message.includes('Items must be objects with json property')
- )).toBe(true);
- });
-
- it('should validate Code node with $helpers usage', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const workflow = $helpers.getWorkflowStaticData();
- workflow.counter = (workflow.counter || 0) + 1;
- return [{json: {count: workflow.counter}}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'best_practice' &&
- w.message.includes('$helpers is only available in Code nodes')
- )).toBe(true);
- });
-
- it('should detect incorrect $helpers.getWorkflowStaticData usage', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const data = $helpers.getWorkflowStaticData; // Missing parentheses
- return [{json: {data}}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors.some(e =>
- e.type === 'invalid_value' &&
- e.message.includes('getWorkflowStaticData requires parentheses')
- )).toBe(true);
- });
-
- it('should validate console.log usage', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- console.log('Debug info:', items);
- return items;
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'best_practice' &&
- w.message.includes('console.log output appears in n8n execution logs')
- )).toBe(true);
- });
-
- it('should validate $json usage warning', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const data = $json.myField;
- return [{json: {processed: data}}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'best_practice' &&
- w.message.includes('$json only works in "Run Once for Each Item" mode')
- )).toBe(true);
- });
-
- it('should not warn about properties for Code nodes', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: 'return items;',
- unusedProperty: 'this should not generate a warning for Code nodes'
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- // Code nodes should skip the common issues check that warns about unused properties
- expect(result.warnings.some(w =>
- w.type === 'inefficient' &&
- w.property === 'unusedProperty'
- )).toBe(false);
- });
-
- it('should validate crypto module usage', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const uuid = crypto.randomUUID();
- return [{json: {id: uuid}}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'invalid_value' &&
- w.message.includes('Using crypto without require')
- )).toBe(true);
- });
-
- it('should suggest error handling for complex code', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const apiUrl = items[0].json.url;
- const response = await fetch(apiUrl);
- const data = await response.json();
- return [{json: data}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.suggestions.some(s =>
- s.includes('Consider adding error handling')
- ));
- });
-
- it('should suggest error handling for non-trivial code', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: Array(10).fill('const x = 1;').join('\n') + '\nreturn items;'
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.suggestions.some(s => s.includes('error handling')));
- });
-
- it('should validate async operations without await', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'javascript',
- jsCode: `
- const promise = fetch('https://api.example.com');
- return [{json: {data: promise}}];
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'jsCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'best_practice' &&
- w.message.includes('Async operation without await')
- )).toBe(true);
- });
- });
-
- describe('Python Code node validation', () => {
- it('should validate Python code syntax', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'python',
- pythonCode: `
-def process_data():
- return [{"json": {"test": True}] # Missing closing bracket
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'pythonCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors.some(e =>
- e.type === 'syntax_error' &&
- e.message.includes('Unmatched bracket')
- )).toBe(true);
- });
-
- it('should detect mixed indentation in Python code', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'python',
- pythonCode: `
-def process():
- x = 1
- y = 2 # This line uses tabs
- return [{"json": {"x": x, "y": y}}]
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'pythonCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.errors.some(e =>
- e.type === 'syntax_error' &&
- e.message.includes('Mixed indentation')
- )).toBe(true);
- });
-
- it('should warn about incorrect n8n return patterns', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'python',
- pythonCode: `
-result = {"data": "value"}
-return result # Should return array of objects with json key
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'pythonCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'invalid_value' &&
- w.message.includes('Must return array of objects with json key')
- )).toBe(true);
- });
-
- it('should warn about using external libraries in Python code', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'python',
- pythonCode: `
- import pandas as pd
- import requests
-
- df = pd.DataFrame(items)
- response = requests.get('https://api.example.com')
- return [{"json": {"data": response.json()}}]
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'pythonCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'invalid_value' &&
- w.message.includes('External libraries not available')
- )).toBe(true);
- });
-
- it('should validate Python code with print statements', () => {
- const nodeType = 'nodes-base.code';
- const config = {
- language: 'python',
- pythonCode: `
-print("Debug:", items)
-processed = []
-for item in items:
- print(f"Processing: {item}")
- processed.append({"json": item["json"]})
-return processed
- `
- };
- const properties = [
- { name: 'language', type: 'options' },
- { name: 'pythonCode', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'best_practice' &&
- w.message.includes('print() output appears in n8n execution logs')
- )).toBe(true);
- });
- });
-
- describe('Database node validation', () => {
- it('should validate database query security', () => {
- const nodeType = 'nodes-base.postgres';
- const config = {
- query: 'DELETE FROM users;' // Missing WHERE clause
- };
- const properties = [
- { name: 'query', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'security' &&
- w.message.includes('DELETE query without WHERE clause')
- )).toBe(true);
- });
-
- it('should check for SQL injection vulnerabilities', () => {
- const nodeType = 'nodes-base.mysql';
- const config = {
- query: 'SELECT * FROM users WHERE id = ${userId}'
- };
- const properties = [
- { name: 'query', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.warnings.some(w =>
- w.type === 'security' &&
- w.message.includes('SQL injection')
- )).toBe(true);
- });
-
- it('should validate SQL SELECT * performance warning', () => {
- const nodeType = 'nodes-base.postgres';
- const config = {
- query: 'SELECT * FROM large_table WHERE status = "active"'
- };
- const properties = [
- { name: 'query', type: 'string' }
- ];
-
- const result = ConfigValidator.validate(nodeType, config, properties);
-
- expect(result.suggestions.some(s =>
- s.includes('Consider selecting specific columns')
- )).toBe(true);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/config-validator-basic.test.ts b/tests/unit/services/config-validator.test.ts
similarity index 100%
rename from tests/unit/services/config-validator-basic.test.ts
rename to tests/unit/services/config-validator.test.ts
diff --git a/tests/unit/services/enhanced-config-validator-integration.test.ts b/tests/unit/services/enhanced-config-validator-integration.test.ts
deleted file mode 100644
index 2ed8f71..0000000
--- a/tests/unit/services/enhanced-config-validator-integration.test.ts
+++ /dev/null
@@ -1,714 +0,0 @@
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-import { ResourceSimilarityService } from '@/services/resource-similarity-service';
-import { OperationSimilarityService } from '@/services/operation-similarity-service';
-import { NodeRepository } from '@/database/node-repository';
-
-// Mock similarity services
-vi.mock('@/services/resource-similarity-service');
-vi.mock('@/services/operation-similarity-service');
-
-describe('EnhancedConfigValidator - Integration Tests', () => {
- let mockResourceService: any;
- let mockOperationService: any;
- let mockRepository: any;
-
- beforeEach(() => {
- mockRepository = {
- getNode: vi.fn(),
- getNodeOperations: vi.fn().mockReturnValue([]),
- getNodeResources: vi.fn().mockReturnValue([]),
- getOperationsForResource: vi.fn().mockReturnValue([]),
- getDefaultOperationForResource: vi.fn().mockReturnValue(undefined),
- getNodePropertyDefaults: vi.fn().mockReturnValue({})
- };
-
- mockResourceService = {
- findSimilarResources: vi.fn().mockReturnValue([])
- };
-
- mockOperationService = {
- findSimilarOperations: vi.fn().mockReturnValue([])
- };
-
- // Mock the constructors to return our mock services
- vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService);
- vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService);
-
- // Initialize the similarity services (this will create the service instances)
- EnhancedConfigValidator.initializeSimilarityServices(mockRepository);
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe('similarity service integration', () => {
- it('should initialize similarity services when initializeSimilarityServices is called', () => {
- // Services should be created when initializeSimilarityServices was called in beforeEach
- expect(ResourceSimilarityService).toHaveBeenCalled();
- expect(OperationSimilarityService).toHaveBeenCalled();
- });
-
- it('should use resource similarity service for invalid resource errors', () => {
- const config = {
- resource: 'invalidResource',
- operation: 'send'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' },
- { value: 'channel', name: 'Channel' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' }
- ]
- }
- ];
-
- // Mock resource similarity suggestions
- mockResourceService.findSimilarResources.mockReturnValue([
- {
- value: 'message',
- confidence: 0.8,
- reason: 'Similar resource name',
- availableOperations: ['send', 'update']
- }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith(
- 'nodes-base.slack',
- 'invalidResource',
- expect.any(Number)
- );
-
- // Should have suggestions in the result
- expect(result.suggestions).toBeDefined();
- expect(result.suggestions.length).toBeGreaterThan(0);
- });
-
- it('should use operation similarity service for invalid operation errors', () => {
- const config = {
- resource: 'message',
- operation: 'invalidOperation'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' },
- { value: 'update', name: 'Update Message' }
- ]
- }
- ];
-
- // Mock operation similarity suggestions
- mockOperationService.findSimilarOperations.mockReturnValue([
- {
- value: 'send',
- confidence: 0.9,
- reason: 'Very similar - likely a typo',
- resource: 'message'
- }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith(
- 'nodes-base.slack',
- 'invalidOperation',
- 'message',
- expect.any(Number)
- );
-
- // Should have suggestions in the result
- expect(result.suggestions).toBeDefined();
- expect(result.suggestions.length).toBeGreaterThan(0);
- });
-
- it('should handle similarity service errors gracefully', () => {
- const config = {
- resource: 'invalidResource',
- operation: 'send'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- // Mock service to throw error
- mockResourceService.findSimilarResources.mockImplementation(() => {
- throw new Error('Service error');
- });
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should not crash and still provide basic validation
- expect(result).toBeDefined();
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('should not call similarity services for valid configurations', () => {
- // Mock repository to return valid resources for this test
- mockRepository.getNodeResources.mockReturnValue([
- { value: 'message', name: 'Message' },
- { value: 'channel', name: 'Channel' }
- ]);
- // Mock getNodeOperations to return valid operations
- mockRepository.getNodeOperations.mockReturnValue([
- { value: 'send', name: 'Send Message' }
- ]);
-
- const config = {
- resource: 'message',
- operation: 'send',
- channel: '#general', // Add required field for Slack send
- text: 'Test message' // Add required field for Slack send
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' }
- ]
- }
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should not call similarity services for valid config
- expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled();
- expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled();
- expect(result.valid).toBe(true);
- });
-
- it('should limit suggestion count when calling similarity services', () => {
- const config = {
- resource: 'invalidResource'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith(
- 'nodes-base.slack',
- 'invalidResource',
- 3 // Should limit to 3 suggestions
- );
- });
- });
-
- describe('error enhancement with suggestions', () => {
- it('should enhance resource validation errors with suggestions', () => {
- const config = {
- resource: 'msgs' // Typo for 'message'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' },
- { value: 'channel', name: 'Channel' }
- ]
- }
- ];
-
- // Mock high-confidence suggestion
- mockResourceService.findSimilarResources.mockReturnValue([
- {
- value: 'message',
- confidence: 0.85,
- reason: 'Very similar - likely a typo',
- availableOperations: ['send', 'update', 'delete']
- }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should have enhanced error with suggestion
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.suggestion).toBeDefined();
- expect(resourceError!.suggestion).toContain('message');
- });
-
- it('should enhance operation validation errors with suggestions', () => {
- const config = {
- resource: 'message',
- operation: 'sned' // Typo for 'send'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' },
- { value: 'update', name: 'Update Message' }
- ]
- }
- ];
-
- // Mock high-confidence suggestion
- mockOperationService.findSimilarOperations.mockReturnValue([
- {
- value: 'send',
- confidence: 0.9,
- reason: 'Almost exact match - likely a typo',
- resource: 'message',
- description: 'Send Message'
- }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should have enhanced error with suggestion
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.suggestion).toBeDefined();
- expect(operationError!.suggestion).toContain('send');
- });
-
- it('should not enhance errors when no good suggestions are available', () => {
- const config = {
- resource: 'completelyWrongValue'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- // Mock low-confidence suggestions
- mockResourceService.findSimilarResources.mockReturnValue([
- {
- value: 'message',
- confidence: 0.2, // Too low confidence
- reason: 'Possibly related resource'
- }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should not enhance error due to low confidence
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.suggestion).toBeUndefined();
- });
-
- it('should provide multiple operation suggestions when resource is known', () => {
- const config = {
- resource: 'message',
- operation: 'invalidOp'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' },
- { value: 'update', name: 'Update Message' },
- { value: 'delete', name: 'Delete Message' }
- ]
- }
- ];
-
- // Mock multiple suggestions
- mockOperationService.findSimilarOperations.mockReturnValue([
- { value: 'send', confidence: 0.7, reason: 'Similar operation' },
- { value: 'update', confidence: 0.6, reason: 'Similar operation' },
- { value: 'delete', confidence: 0.5, reason: 'Similar operation' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should include multiple suggestions in the result
- expect(result.suggestions.length).toBeGreaterThan(2);
- const operationSuggestions = result.suggestions.filter(s =>
- s.includes('send') || s.includes('update') || s.includes('delete')
- );
- expect(operationSuggestions.length).toBeGreaterThan(0);
- });
- });
-
- describe('confidence thresholds and filtering', () => {
- it('should only use high confidence resource suggestions', () => {
- const config = {
- resource: 'invalidResource'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- // Mock mixed confidence suggestions
- mockResourceService.findSimilarResources.mockReturnValue([
- { value: 'message1', confidence: 0.9, reason: 'High confidence' },
- { value: 'message2', confidence: 0.4, reason: 'Low confidence' },
- { value: 'message3', confidence: 0.7, reason: 'Medium confidence' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should only use suggestions above threshold
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError?.suggestion).toBeDefined();
- // Should prefer high confidence suggestion
- expect(resourceError!.suggestion).toContain('message1');
- });
-
- it('should only use high confidence operation suggestions', () => {
- const config = {
- resource: 'message',
- operation: 'invalidOperation'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' }
- ]
- }
- ];
-
- // Mock mixed confidence suggestions
- mockOperationService.findSimilarOperations.mockReturnValue([
- { value: 'send', confidence: 0.95, reason: 'Very high confidence' },
- { value: 'post', confidence: 0.3, reason: 'Low confidence' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should only use high confidence suggestion
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError?.suggestion).toBeDefined();
- expect(operationError!.suggestion).toContain('send');
- expect(operationError!.suggestion).not.toContain('post');
- });
- });
-
- describe('integration with existing validation logic', () => {
- it('should work with minimal validation mode', () => {
- // Mock repository to return empty resources
- mockRepository.getNodeResources.mockReturnValue([]);
-
- const config = {
- resource: 'invalidResource'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- mockResourceService.findSimilarResources.mockReturnValue([
- { value: 'message', confidence: 0.8, reason: 'Similar' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'minimal',
- 'ai-friendly'
- );
-
- // Should still enhance errors in minimal mode
- expect(mockResourceService.findSimilarResources).toHaveBeenCalled();
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('should work with strict validation profile', () => {
- // Mock repository to return valid resource but no operations
- mockRepository.getNodeResources.mockReturnValue([
- { value: 'message', name: 'Message' }
- ]);
- mockRepository.getOperationsForResource.mockReturnValue([]);
-
- const config = {
- resource: 'message',
- operation: 'invalidOp'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send Message' }
- ]
- }
- ];
-
- mockOperationService.findSimilarOperations.mockReturnValue([
- { value: 'send', confidence: 0.8, reason: 'Similar' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'strict'
- );
-
- // Should enhance errors regardless of profile
- expect(mockOperationService.findSimilarOperations).toHaveBeenCalled();
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError?.suggestion).toBeDefined();
- });
-
- it('should preserve original error properties when enhancing', () => {
- const config = {
- resource: 'invalidResource'
- };
-
- const properties = [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'message', name: 'Message' }
- ]
- }
- ];
-
- mockResourceService.findSimilarResources.mockReturnValue([
- { value: 'message', confidence: 0.8, reason: 'Similar' }
- ]);
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- const resourceError = result.errors.find(e => e.property === 'resource');
-
- // Should preserve original error properties
- expect(resourceError?.type).toBeDefined();
- expect(resourceError?.property).toBe('resource');
- expect(resourceError?.message).toBeDefined();
-
- // Should add suggestion without overriding other properties
- expect(resourceError?.suggestion).toBeDefined();
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/enhanced-config-validator-operations.test.ts b/tests/unit/services/enhanced-config-validator-operations.test.ts
deleted file mode 100644
index 54a2dc4..0000000
--- a/tests/unit/services/enhanced-config-validator-operations.test.ts
+++ /dev/null
@@ -1,421 +0,0 @@
-/**
- * Tests for EnhancedConfigValidator operation and resource validation
- */
-
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
-import { NodeRepository } from '../../../src/database/node-repository';
-import { createTestDatabase } from '../../utils/database-utils';
-
-describe('EnhancedConfigValidator - Operation and Resource Validation', () => {
- let repository: NodeRepository;
- let testDb: any;
-
- beforeEach(async () => {
- testDb = await createTestDatabase();
- repository = testDb.nodeRepository;
-
- // Initialize similarity services
- EnhancedConfigValidator.initializeSimilarityServices(repository);
-
- // Add Google Drive test node
- const googleDriveNode = {
- nodeType: 'nodes-base.googleDrive',
- packageName: 'n8n-nodes-base',
- displayName: 'Google Drive',
- description: 'Access Google Drive',
- category: 'transform',
- style: 'declarative' as const,
- isAITool: false,
- isTrigger: false,
- isWebhook: false,
- isVersioned: true,
- version: '1',
- properties: [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'file', name: 'File' },
- { value: 'folder', name: 'Folder' },
- { value: 'fileFolder', name: 'File & Folder' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['file']
- }
- },
- options: [
- { value: 'copy', name: 'Copy' },
- { value: 'delete', name: 'Delete' },
- { value: 'download', name: 'Download' },
- { value: 'list', name: 'List' },
- { value: 'share', name: 'Share' },
- { value: 'update', name: 'Update' },
- { value: 'upload', name: 'Upload' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['folder']
- }
- },
- options: [
- { value: 'create', name: 'Create' },
- { value: 'delete', name: 'Delete' },
- { value: 'share', name: 'Share' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['fileFolder']
- }
- },
- options: [
- { value: 'search', name: 'Search' }
- ]
- }
- ],
- operations: [],
- credentials: []
- };
-
- repository.saveNode(googleDriveNode);
-
- // Add Slack test node
- const slackNode = {
- nodeType: 'nodes-base.slack',
- packageName: 'n8n-nodes-base',
- displayName: 'Slack',
- description: 'Send messages to Slack',
- category: 'communication',
- style: 'declarative' as const,
- isAITool: false,
- isTrigger: false,
- isWebhook: false,
- isVersioned: true,
- version: '2',
- properties: [
- {
- name: 'resource',
- type: 'options',
- required: true,
- options: [
- { value: 'channel', name: 'Channel' },
- { value: 'message', name: 'Message' },
- { value: 'user', name: 'User' }
- ]
- },
- {
- name: 'operation',
- type: 'options',
- required: true,
- displayOptions: {
- show: {
- resource: ['message']
- }
- },
- options: [
- { value: 'send', name: 'Send' },
- { value: 'update', name: 'Update' },
- { value: 'delete', name: 'Delete' }
- ]
- }
- ],
- operations: [],
- credentials: []
- };
-
- repository.saveNode(slackNode);
- });
-
- afterEach(async () => {
- // Clean up database
- if (testDb) {
- await testDb.cleanup();
- }
- });
-
- describe('Invalid Operations', () => {
- it('should detect invalid operation "listFiles" for Google Drive', () => {
- const config = {
- resource: 'fileFolder',
- operation: 'listFiles'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- // Should have an error for invalid operation
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.message).toContain('Invalid operation "listFiles"');
- expect(operationError!.message).toContain('Did you mean');
- expect(operationError!.fix).toContain('search'); // Should suggest 'search' for fileFolder resource
- });
-
- it('should provide suggestions for typos in operations', () => {
- const config = {
- resource: 'file',
- operation: 'downlod' // Typo: missing 'a'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.message).toContain('Did you mean "download"');
- });
-
- it('should list valid operations for the resource', () => {
- const config = {
- resource: 'folder',
- operation: 'upload' // Invalid for folder resource
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.fix).toContain('Valid operations for resource "folder"');
- expect(operationError!.fix).toContain('create');
- expect(operationError!.fix).toContain('delete');
- expect(operationError!.fix).toContain('share');
- });
- });
-
- describe('Invalid Resources', () => {
- it('should detect plural resource "files" and suggest singular', () => {
- const config = {
- resource: 'files', // Should be 'file'
- operation: 'list'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.message).toContain('Invalid resource "files"');
- expect(resourceError!.message).toContain('Did you mean "file"');
- expect(resourceError!.fix).toContain('Use singular');
- });
-
- it('should suggest similar resources for typos', () => {
- const config = {
- resource: 'flie', // Typo
- operation: 'download'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.message).toContain('Did you mean "file"');
- });
-
- it('should list valid resources when no match found', () => {
- const config = {
- resource: 'document', // Not a valid resource
- operation: 'create'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.fix).toContain('Valid resources:');
- expect(resourceError!.fix).toContain('file');
- expect(resourceError!.fix).toContain('folder');
- });
- });
-
- describe('Combined Resource and Operation Validation', () => {
- it('should validate both resource and operation together', () => {
- const config = {
- resource: 'files', // Invalid: should be singular
- operation: 'listFiles' // Invalid: should be 'list' or 'search'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThanOrEqual(2);
-
- // Should have error for resource
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.message).toContain('files');
-
- // Should have error for operation
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.message).toContain('listFiles');
- });
- });
-
- describe('Slack Node Validation', () => {
- it('should suggest "send" instead of "sendMessage"', () => {
- const config = {
- resource: 'message',
- operation: 'sendMessage' // Common mistake
- };
-
- const node = repository.getNode('nodes-base.slack');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(operationError).toBeDefined();
- expect(operationError!.message).toContain('Did you mean "send"');
- });
-
- it('should suggest singular "channel" instead of "channels"', () => {
- const config = {
- resource: 'channels', // Should be singular
- operation: 'create'
- };
-
- const node = repository.getNode('nodes-base.slack');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
-
- const resourceError = result.errors.find(e => e.property === 'resource');
- expect(resourceError).toBeDefined();
- expect(resourceError!.message).toContain('Did you mean "channel"');
- });
- });
-
- describe('Valid Configurations', () => {
- it('should accept valid Google Drive configuration', () => {
- const config = {
- resource: 'file',
- operation: 'download'
- };
-
- const node = repository.getNode('nodes-base.googleDrive');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleDrive',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should not have errors for resource or operation
- const resourceError = result.errors.find(e => e.property === 'resource');
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(resourceError).toBeUndefined();
- expect(operationError).toBeUndefined();
- });
-
- it('should accept valid Slack configuration', () => {
- const config = {
- resource: 'message',
- operation: 'send'
- };
-
- const node = repository.getNode('nodes-base.slack');
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.slack',
- config,
- node.properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Should not have errors for resource or operation
- const resourceError = result.errors.find(e => e.property === 'resource');
- const operationError = result.errors.find(e => e.property === 'operation');
- expect(resourceError).toBeUndefined();
- expect(operationError).toBeUndefined();
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/enhanced-config-validator-type-structures.test.ts b/tests/unit/services/enhanced-config-validator-type-structures.test.ts
deleted file mode 100644
index 9d13ef6..0000000
--- a/tests/unit/services/enhanced-config-validator-type-structures.test.ts
+++ /dev/null
@@ -1,684 +0,0 @@
-/**
- * Tests for EnhancedConfigValidator - Type Structure Validation
- *
- * Tests the integration of TypeStructureService into EnhancedConfigValidator
- * for validating complex types: filter, resourceMapper, assignmentCollection, resourceLocator
- *
- * @group unit
- * @group services
- * @group validation
- */
-
-import { describe, it, expect } from 'vitest';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-describe('EnhancedConfigValidator - Type Structure Validation', () => {
- describe('Filter Type Validation', () => {
- it('should validate valid filter configuration', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- leftValue: '{{ $json.name }}',
- operator: { type: 'string', operation: 'equals' },
- rightValue: 'John',
- },
- ],
- },
- };
- const properties = [
- {
- name: 'conditions',
- type: 'filter',
- required: true,
- displayName: 'Conditions',
- default: {},
- },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it('should validate filter with multiple conditions', () => {
- const config = {
- conditions: {
- combinator: 'or',
- conditions: [
- {
- id: '1',
- leftValue: '{{ $json.age }}',
- operator: { type: 'number', operation: 'gt' },
- rightValue: 18,
- },
- {
- id: '2',
- leftValue: '{{ $json.country }}',
- operator: { type: 'string', operation: 'equals' },
- rightValue: 'US',
- },
- ],
- },
- };
- const properties = [
- { name: 'conditions', type: 'filter', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should detect missing combinator in filter', () => {
- const config = {
- conditions: {
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation: 'equals' },
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- // Missing combinator
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- expect(result.errors).toContainEqual(
- expect.objectContaining({
- property: expect.stringMatching(/conditions/),
- type: 'invalid_configuration',
- })
- );
- });
-
- it('should detect invalid combinator value', () => {
- const config = {
- conditions: {
- combinator: 'invalid', // Should be 'and' or 'or'
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation: 'equals' },
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- });
- });
-
- describe('Filter Operation Validation', () => {
- it('should validate string operations correctly', () => {
- const validOperations = [
- 'equals',
- 'notEquals',
- 'contains',
- 'notContains',
- 'startsWith',
- 'endsWith',
- 'regex',
- ];
-
- for (const operation of validOperations) {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation },
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- }
- });
-
- it('should reject invalid operation for string type', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation: 'gt' }, // 'gt' is for numbers
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- expect(result.errors).toContainEqual(
- expect.objectContaining({
- property: expect.stringContaining('operator.operation'),
- message: expect.stringContaining('not valid for type'),
- })
- );
- });
-
- it('should validate number operations correctly', () => {
- const validOperations = ['equals', 'notEquals', 'gt', 'lt', 'gte', 'lte'];
-
- for (const operation of validOperations) {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'number', operation },
- leftValue: 10,
- rightValue: 20,
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- }
- });
-
- it('should reject string operations for number type', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'number', operation: 'contains' }, // 'contains' is for strings
- leftValue: 10,
- rightValue: 20,
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- });
-
- it('should validate boolean operations', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'boolean', operation: 'true' },
- leftValue: '{{ $json.isActive }}',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should validate dateTime operations', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'dateTime', operation: 'after' },
- leftValue: '{{ $json.createdAt }}',
- rightValue: '2024-01-01',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should validate array operations', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'array', operation: 'contains' },
- leftValue: '{{ $json.tags }}',
- rightValue: 'urgent',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('ResourceMapper Type Validation', () => {
- it('should validate valid resourceMapper configuration', () => {
- const config = {
- mapping: {
- mappingMode: 'defineBelow',
- value: {
- name: '{{ $json.fullName }}',
- email: '{{ $json.emailAddress }}',
- status: 'active',
- },
- },
- };
- const properties = [
- { name: 'mapping', type: 'resourceMapper', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.httpRequest',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should validate autoMapInputData mode', () => {
- const config = {
- mapping: {
- mappingMode: 'autoMapInputData',
- value: {},
- },
- };
- const properties = [
- { name: 'mapping', type: 'resourceMapper', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.httpRequest',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('AssignmentCollection Type Validation', () => {
- it('should validate valid assignmentCollection configuration', () => {
- const config = {
- assignments: {
- assignments: [
- {
- id: '1',
- name: 'userName',
- value: '{{ $json.name }}',
- type: 'string',
- },
- {
- id: '2',
- name: 'userAge',
- value: 30,
- type: 'number',
- },
- ],
- },
- };
- const properties = [
- { name: 'assignments', type: 'assignmentCollection', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.set',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should detect missing assignments array', () => {
- const config = {
- assignments: {
- // Missing assignments array
- },
- };
- const properties = [
- { name: 'assignments', type: 'assignmentCollection', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.set',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(false);
- });
- });
-
- describe('ResourceLocator Type Validation', () => {
- // TODO: Debug why resourceLocator tests fail - issue appears to be with base validator, not the new validation logic
- it.skip('should validate valid resourceLocator by ID', () => {
- const config = {
- resource: {
- mode: 'id',
- value: 'abc123',
- },
- };
- const properties = [
- {
- name: 'resource',
- type: 'resourceLocator',
- required: true,
- displayName: 'Resource',
- default: { mode: 'list', value: '' },
- },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleSheets',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- if (!result.valid) {
- console.log('DEBUG - ResourceLocator validation failed:');
- console.log('Errors:', JSON.stringify(result.errors, null, 2));
- }
-
- expect(result.valid).toBe(true);
- });
-
- it.skip('should validate resourceLocator by URL', () => {
- const config = {
- resource: {
- mode: 'url',
- value: 'https://example.com/resource/123',
- },
- };
- const properties = [
- {
- name: 'resource',
- type: 'resourceLocator',
- required: true,
- displayName: 'Resource',
- default: { mode: 'list', value: '' },
- },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleSheets',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it.skip('should validate resourceLocator by list', () => {
- const config = {
- resource: {
- mode: 'list',
- value: 'item-from-dropdown',
- },
- };
- const properties = [
- {
- name: 'resource',
- type: 'resourceLocator',
- required: true,
- displayName: 'Resource',
- default: { mode: 'list', value: '' },
- },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.googleSheets',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('Edge Cases', () => {
- it('should handle null values gracefully', () => {
- const config = {
- conditions: null,
- };
- const properties = [{ name: 'conditions', type: 'filter', required: false }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- // Null is acceptable for non-required fields
- expect(result.valid).toBe(true);
- });
-
- it('should handle undefined values gracefully', () => {
- const config = {};
- const properties = [{ name: 'conditions', type: 'filter', required: false }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
-
- it('should handle multiple special types in same config', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation: 'equals' },
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- },
- assignments: {
- assignments: [
- {
- id: '1',
- name: 'result',
- value: 'processed',
- type: 'string',
- },
- ],
- },
- };
- const properties = [
- { name: 'conditions', type: 'filter', required: true },
- { name: 'assignments', type: 'assignmentCollection', required: true },
- ];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.custom',
- config,
- properties,
- 'operation',
- 'ai-friendly'
- );
-
- expect(result.valid).toBe(true);
- });
- });
-
- describe('Validation Profiles', () => {
- it('should respect strict profile for type validation', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [
- {
- id: '1',
- operator: { type: 'string', operation: 'gt' }, // Invalid operation
- leftValue: 'test',
- rightValue: 'value',
- },
- ],
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'strict'
- );
-
- expect(result.valid).toBe(false);
- expect(result.profile).toBe('strict');
- });
-
- it('should respect minimal profile (less strict)', () => {
- const config = {
- conditions: {
- combinator: 'and',
- conditions: [], // Empty but valid
- },
- };
- const properties = [{ name: 'conditions', type: 'filter', required: true }];
-
- const result = EnhancedConfigValidator.validateWithMode(
- 'nodes-base.filter',
- config,
- properties,
- 'operation',
- 'minimal'
- );
-
- expect(result.profile).toBe('minimal');
- });
- });
-});
diff --git a/tests/unit/services/enhanced-config-validator.test.ts b/tests/unit/services/enhanced-config-validator.test.ts
index ac667cf..6b2b659 100644
--- a/tests/unit/services/enhanced-config-validator.test.ts
+++ b/tests/unit/services/enhanced-config-validator.test.ts
@@ -2,7 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '@/services/enhanced-config-validator';
import { ValidationError } from '@/services/config-validator';
import { NodeSpecificValidators } from '@/services/node-specific-validators';
+import { ResourceSimilarityService } from '@/services/resource-similarity-service';
+import { OperationSimilarityService } from '@/services/operation-similarity-service';
+import { NodeRepository } from '@/database/node-repository';
import { nodeFactory } from '@tests/fixtures/factories/node.factory';
+import { createTestDatabase } from '@tests/utils/database-utils';
+
+// Mock similarity services
+vi.mock('@/services/resource-similarity-service');
+vi.mock('@/services/operation-similarity-service');
// Mock node-specific validators
vi.mock('@/services/node-specific-validators', () => ({
@@ -15,7 +23,8 @@ vi.mock('@/services/node-specific-validators', () => ({
validateWebhook: vi.fn(),
validatePostgres: vi.fn(),
validateMySQL: vi.fn(),
- validateAIAgent: vi.fn()
+ validateAIAgent: vi.fn(),
+ validateSet: vi.fn()
}
}));
@@ -1168,4 +1177,506 @@ describe('EnhancedConfigValidator', () => {
});
});
});
+
+ // โโโ Type Structure Validation (from enhanced-config-validator-type-structures) โโโ
+
+ describe('type structure validation', () => {
+ describe('Filter Type Validation', () => {
+ it('should validate valid filter configuration', () => {
+ const config = {
+ conditions: {
+ combinator: 'and',
+ conditions: [{ id: '1', leftValue: '{{ $json.name }}', operator: { type: 'string', operation: 'equals' }, rightValue: 'John' }],
+ },
+ };
+ const properties = [{ name: 'conditions', type: 'filter', required: true, displayName: 'Conditions', default: {} }];
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('should validate filter with multiple conditions', () => {
+ const config = {
+ conditions: {
+ combinator: 'or',
+ conditions: [
+ { id: '1', leftValue: '{{ $json.age }}', operator: { type: 'number', operation: 'gt' }, rightValue: 18 },
+ { id: '2', leftValue: '{{ $json.country }}', operator: { type: 'string', operation: 'equals' }, rightValue: 'US' },
+ ],
+ },
+ };
+ const properties = [{ name: 'conditions', type: 'filter', required: true }];
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should detect missing combinator in filter', () => {
+ const config = {
+ conditions: {
+ conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }],
+ },
+ };
+ const properties = [{ name: 'conditions', type: 'filter', required: true }];
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContainEqual(expect.objectContaining({ property: expect.stringMatching(/conditions/), type: 'invalid_configuration' }));
+ });
+
+ it('should detect invalid combinator value', () => {
+ const config = {
+ conditions: {
+ combinator: 'invalid',
+ conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }],
+ },
+ };
+ const properties = [{ name: 'conditions', type: 'filter', required: true }];
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ });
+ });
+
+ describe('Filter Operation Validation', () => {
+ it('should validate string operations correctly', () => {
+ for (const operation of ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'regex']) {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation }, leftValue: 'test', rightValue: 'value' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ }
+ });
+
+ it('should reject invalid operation for string type', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'gt' }, leftValue: 'test', rightValue: 'value' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContainEqual(expect.objectContaining({ property: expect.stringContaining('operator.operation'), message: expect.stringContaining('not valid for type') }));
+ });
+
+ it('should validate number operations correctly', () => {
+ for (const operation of ['equals', 'notEquals', 'gt', 'lt', 'gte', 'lte']) {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'number', operation }, leftValue: 10, rightValue: 20 }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ }
+ });
+
+ it('should reject string operations for number type', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'number', operation: 'contains' }, leftValue: 10, rightValue: 20 }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ });
+
+ it('should validate boolean operations', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'boolean', operation: 'true' }, leftValue: '{{ $json.isActive }}' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should validate dateTime operations', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'dateTime', operation: 'after' }, leftValue: '{{ $json.createdAt }}', rightValue: '2024-01-01' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should validate array operations', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'array', operation: 'contains' }, leftValue: '{{ $json.tags }}', rightValue: 'urgent' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe('ResourceMapper Type Validation', () => {
+ it('should validate valid resourceMapper configuration', () => {
+ const config = { mapping: { mappingMode: 'defineBelow', value: { name: '{{ $json.fullName }}', email: '{{ $json.emailAddress }}', status: 'active' } } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.httpRequest', config, [{ name: 'mapping', type: 'resourceMapper', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should validate autoMapInputData mode', () => {
+ const config = { mapping: { mappingMode: 'autoMapInputData', value: {} } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.httpRequest', config, [{ name: 'mapping', type: 'resourceMapper', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe('AssignmentCollection Type Validation', () => {
+ it('should validate valid assignmentCollection configuration', () => {
+ const config = { assignments: { assignments: [{ id: '1', name: 'userName', value: '{{ $json.name }}', type: 'string' }, { id: '2', name: 'userAge', value: 30, type: 'number' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.set', config, [{ name: 'assignments', type: 'assignmentCollection', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should detect missing assignments array', () => {
+ const config = { assignments: {} };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.set', config, [{ name: 'assignments', type: 'assignmentCollection', required: true }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ });
+ });
+
+ describe('ResourceLocator Type Validation', () => {
+ it.skip('should validate valid resourceLocator by ID', () => {
+ const config = { resource: { mode: 'id', value: 'abc123' } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it.skip('should validate resourceLocator by URL', () => {
+ const config = { resource: { mode: 'url', value: 'https://example.com/resource/123' } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it.skip('should validate resourceLocator by list', () => {
+ const config = { resource: { mode: 'list', value: 'item-from-dropdown' } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleSheets', config, [{ name: 'resource', type: 'resourceLocator', required: true, displayName: 'Resource', default: { mode: 'list', value: '' } }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe('Type Structure Edge Cases', () => {
+ it('should handle null values gracefully', () => {
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', { conditions: null }, [{ name: 'conditions', type: 'filter', required: false }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should handle undefined values gracefully', () => {
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', {}, [{ name: 'conditions', type: 'filter', required: false }], 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+
+ it('should handle multiple special types in same config', () => {
+ const config = {
+ conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'equals' }, leftValue: 'test', rightValue: 'value' }] },
+ assignments: { assignments: [{ id: '1', name: 'result', value: 'processed', type: 'string' }] },
+ };
+ const properties = [{ name: 'conditions', type: 'filter', required: true }, { name: 'assignments', type: 'assignmentCollection', required: true }];
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.custom', config, properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(true);
+ });
+ });
+
+ describe('Validation Profiles for Type Structures', () => {
+ it('should respect strict profile for type validation', () => {
+ const config = { conditions: { combinator: 'and', conditions: [{ id: '1', operator: { type: 'string', operation: 'gt' }, leftValue: 'test', rightValue: 'value' }] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'strict');
+ expect(result.valid).toBe(false);
+ expect(result.profile).toBe('strict');
+ });
+
+ it('should respect minimal profile (less strict)', () => {
+ const config = { conditions: { combinator: 'and', conditions: [] } };
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.filter', config, [{ name: 'conditions', type: 'filter', required: true }], 'operation', 'minimal');
+ expect(result.profile).toBe('minimal');
+ });
+ });
+ });
+});
+
+// โโโ Integration Tests (from enhanced-config-validator-integration) โโโโโโโโโ
+
+describe('EnhancedConfigValidator - Integration Tests', () => {
+ let mockResourceService: any;
+ let mockOperationService: any;
+ let mockRepository: any;
+
+ beforeEach(() => {
+ mockRepository = {
+ getNode: vi.fn(),
+ getNodeOperations: vi.fn().mockReturnValue([]),
+ getNodeResources: vi.fn().mockReturnValue([]),
+ getOperationsForResource: vi.fn().mockReturnValue([]),
+ getDefaultOperationForResource: vi.fn().mockReturnValue(undefined),
+ getNodePropertyDefaults: vi.fn().mockReturnValue({})
+ };
+
+ mockResourceService = { findSimilarResources: vi.fn().mockReturnValue([]) };
+ mockOperationService = { findSimilarOperations: vi.fn().mockReturnValue([]) };
+
+ vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService);
+ vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService);
+
+ EnhancedConfigValidator.initializeSimilarityServices(mockRepository);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('similarity service integration', () => {
+ it('should initialize similarity services when initializeSimilarityServices is called', () => {
+ expect(ResourceSimilarityService).toHaveBeenCalled();
+ expect(OperationSimilarityService).toHaveBeenCalled();
+ });
+
+ it('should use resource similarity service for invalid resource errors', () => {
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar resource name', availableOperations: ['send', 'update'] }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource', operation: 'send' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly');
+ expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith('nodes-base.slack', 'invalidResource', expect.any(Number));
+ expect(result.suggestions.length).toBeGreaterThan(0);
+ });
+
+ it('should use operation similarity service for invalid operation errors', () => {
+ mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.9, reason: 'Very similar - likely a typo', resource: 'message' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOperation' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }] }], 'operation', 'ai-friendly');
+ expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith('nodes-base.slack', 'invalidOperation', 'message', expect.any(Number));
+ expect(result.suggestions.length).toBeGreaterThan(0);
+ });
+
+ it('should handle similarity service errors gracefully', () => {
+ mockResourceService.findSimilarResources.mockImplementation(() => { throw new Error('Service error'); });
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource', operation: 'send' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly');
+ expect(result).toBeDefined();
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should not call similarity services for valid configurations', () => {
+ mockRepository.getNodeResources.mockReturnValue([{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }]);
+ mockRepository.getNodeOperations.mockReturnValue([{ value: 'send', name: 'Send Message' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'send', channel: '#general', text: 'Test message' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly');
+ expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled();
+ expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled();
+ expect(result.valid).toBe(true);
+ });
+
+ it('should limit suggestion count when calling similarity services', () => {
+ EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly');
+ expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith('nodes-base.slack', 'invalidResource', 3);
+ });
+ });
+
+ describe('error enhancement with suggestions', () => {
+ it('should enhance resource validation errors with suggestions', () => {
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.85, reason: 'Very similar - likely a typo', availableOperations: ['send', 'update', 'delete'] }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'msgs' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }, { value: 'channel', name: 'Channel' }] }], 'operation', 'ai-friendly');
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ expect(resourceError!.suggestion).toBeDefined();
+ expect(resourceError!.suggestion).toContain('message');
+ });
+
+ it('should enhance operation validation errors with suggestions', () => {
+ mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.9, reason: 'Almost exact match - likely a typo', resource: 'message', description: 'Send Message' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'sned' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }] }], 'operation', 'ai-friendly');
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError).toBeDefined();
+ expect(operationError!.suggestion).toBeDefined();
+ expect(operationError!.suggestion).toContain('send');
+ });
+
+ it('should not enhance errors when no good suggestions are available', () => {
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.2, reason: 'Possibly related resource' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'completelyWrongValue' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly');
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ expect(resourceError!.suggestion).toBeUndefined();
+ });
+
+ it('should provide multiple operation suggestions when resource is known', () => {
+ mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.7, reason: 'Similar operation' }, { value: 'update', confidence: 0.6, reason: 'Similar operation' }, { value: 'delete', confidence: 0.5, reason: 'Similar operation' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOp' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }, { value: 'update', name: 'Update Message' }, { value: 'delete', name: 'Delete Message' }] }], 'operation', 'ai-friendly');
+ expect(result.suggestions.length).toBeGreaterThan(2);
+ expect(result.suggestions.filter(s => s.includes('send') || s.includes('update') || s.includes('delete')).length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('confidence thresholds and filtering', () => {
+ it('should only use high confidence resource suggestions', () => {
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message1', confidence: 0.9, reason: 'High confidence' }, { value: 'message2', confidence: 0.4, reason: 'Low confidence' }, { value: 'message3', confidence: 0.7, reason: 'Medium confidence' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly');
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError?.suggestion).toBeDefined();
+ expect(resourceError!.suggestion).toContain('message1');
+ });
+
+ it('should only use high confidence operation suggestions', () => {
+ mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.95, reason: 'Very high confidence' }, { value: 'post', confidence: 0.3, reason: 'Low confidence' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOperation' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'ai-friendly');
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError?.suggestion).toBeDefined();
+ expect(operationError!.suggestion).toContain('send');
+ expect(operationError!.suggestion).not.toContain('post');
+ });
+ });
+
+ describe('integration with existing validation logic', () => {
+ it('should work with minimal validation mode', () => {
+ mockRepository.getNodeResources.mockReturnValue([]);
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'minimal', 'ai-friendly');
+ expect(mockResourceService.findSimilarResources).toHaveBeenCalled();
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should work with strict validation profile', () => {
+ mockRepository.getNodeResources.mockReturnValue([{ value: 'message', name: 'Message' }]);
+ mockRepository.getOperationsForResource.mockReturnValue([]);
+ mockOperationService.findSimilarOperations.mockReturnValue([{ value: 'send', confidence: 0.8, reason: 'Similar' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'invalidOp' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }, { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send Message' }] }], 'operation', 'strict');
+ expect(mockOperationService.findSimilarOperations).toHaveBeenCalled();
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError?.suggestion).toBeDefined();
+ });
+
+ it('should preserve original error properties when enhancing', () => {
+ mockResourceService.findSimilarResources.mockReturnValue([{ value: 'message', confidence: 0.8, reason: 'Similar' }]);
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'invalidResource' }, [{ name: 'resource', type: 'options', required: true, options: [{ value: 'message', name: 'Message' }] }], 'operation', 'ai-friendly');
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError?.type).toBeDefined();
+ expect(resourceError?.property).toBe('resource');
+ expect(resourceError?.message).toBeDefined();
+ expect(resourceError?.suggestion).toBeDefined();
+ });
+ });
+});
+
+// โโโ Operation and Resource Validation (from enhanced-config-validator-operations) โโโ
+
+describe('EnhancedConfigValidator - Operation and Resource Validation', () => {
+ let repository: NodeRepository;
+ let testDb: any;
+
+ beforeEach(async () => {
+ testDb = await createTestDatabase();
+ repository = testDb.nodeRepository;
+
+ // Configure mocked similarity services to return empty arrays by default
+ vi.mocked(ResourceSimilarityService).mockImplementation(() => ({
+ findSimilarResources: vi.fn().mockReturnValue([])
+ }) as any);
+ vi.mocked(OperationSimilarityService).mockImplementation(() => ({
+ findSimilarOperations: vi.fn().mockReturnValue([])
+ }) as any);
+
+ EnhancedConfigValidator.initializeSimilarityServices(repository);
+
+ repository.saveNode({
+ nodeType: 'nodes-base.googleDrive', packageName: 'n8n-nodes-base', displayName: 'Google Drive', description: 'Access Google Drive', category: 'transform', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '1',
+ properties: [
+ { name: 'resource', type: 'options', required: true, options: [{ value: 'file', name: 'File' }, { value: 'folder', name: 'Folder' }, { value: 'fileFolder', name: 'File & Folder' }] },
+ { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['file'] } }, options: [{ value: 'copy', name: 'Copy' }, { value: 'delete', name: 'Delete' }, { value: 'download', name: 'Download' }, { value: 'list', name: 'List' }, { value: 'share', name: 'Share' }, { value: 'update', name: 'Update' }, { value: 'upload', name: 'Upload' }] },
+ { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['folder'] } }, options: [{ value: 'create', name: 'Create' }, { value: 'delete', name: 'Delete' }, { value: 'share', name: 'Share' }] },
+ { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['fileFolder'] } }, options: [{ value: 'search', name: 'Search' }] }
+ ],
+ operations: [], credentials: []
+ });
+
+ repository.saveNode({
+ nodeType: 'nodes-base.slack', packageName: 'n8n-nodes-base', displayName: 'Slack', description: 'Send messages to Slack', category: 'communication', style: 'declarative' as const, isAITool: false, isTrigger: false, isWebhook: false, isVersioned: true, version: '2',
+ properties: [
+ { name: 'resource', type: 'options', required: true, options: [{ value: 'channel', name: 'Channel' }, { value: 'message', name: 'Message' }, { value: 'user', name: 'User' }] },
+ { name: 'operation', type: 'options', required: true, displayOptions: { show: { resource: ['message'] } }, options: [{ value: 'send', name: 'Send' }, { value: 'update', name: 'Update' }, { value: 'delete', name: 'Delete' }] }
+ ],
+ operations: [], credentials: []
+ });
+ });
+
+ afterEach(async () => {
+ if (testDb) { await testDb.cleanup(); }
+ });
+
+ describe('Invalid Operations', () => {
+ it('should detect invalid operation for Google Drive fileFolder resource', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'fileFolder', operation: 'listFiles' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError).toBeDefined();
+ expect(operationError!.message).toContain('listFiles');
+ });
+
+ it('should detect typos in operations', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'file', operation: 'downlod' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError).toBeDefined();
+ });
+
+ it('should list valid operations for the resource', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'folder', operation: 'upload' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError).toBeDefined();
+ expect(operationError!.fix).toContain('Valid operations for resource "folder"');
+ expect(operationError!.fix).toContain('create');
+ expect(operationError!.fix).toContain('delete');
+ expect(operationError!.fix).toContain('share');
+ });
+ });
+
+ describe('Invalid Resources', () => {
+ it('should detect invalid plural resource "files"', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'files', operation: 'list' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ expect(resourceError!.message).toContain('files');
+ });
+
+ it('should detect typos in resources', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'flie', operation: 'download' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ });
+
+ it('should list valid resources when no match found', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'document', operation: 'create' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ expect(resourceError!.fix).toContain('Valid resources:');
+ expect(resourceError!.fix).toContain('file');
+ expect(resourceError!.fix).toContain('folder');
+ });
+ });
+
+ describe('Combined Resource and Operation Validation', () => {
+ it('should validate both resource and operation together', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'files', operation: 'listFiles' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThanOrEqual(2);
+ expect(result.errors.find(e => e.property === 'resource')).toBeDefined();
+ expect(result.errors.find(e => e.property === 'operation')).toBeDefined();
+ });
+ });
+
+ describe('Slack Node Validation', () => {
+ it('should detect invalid operation "sendMessage" for Slack', () => {
+ const node = repository.getNode('nodes-base.slack');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'sendMessage' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const operationError = result.errors.find(e => e.property === 'operation');
+ expect(operationError).toBeDefined();
+ });
+
+ it('should detect invalid plural resource "channels" for Slack', () => {
+ const node = repository.getNode('nodes-base.slack');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'channels', operation: 'create' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.valid).toBe(false);
+ const resourceError = result.errors.find(e => e.property === 'resource');
+ expect(resourceError).toBeDefined();
+ });
+ });
+
+ describe('Valid Configurations', () => {
+ it('should accept valid Google Drive configuration', () => {
+ const node = repository.getNode('nodes-base.googleDrive');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.googleDrive', { resource: 'file', operation: 'download' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.errors.find(e => e.property === 'resource')).toBeUndefined();
+ expect(result.errors.find(e => e.property === 'operation')).toBeUndefined();
+ });
+
+ it('should accept valid Slack configuration', () => {
+ const node = repository.getNode('nodes-base.slack');
+ const result = EnhancedConfigValidator.validateWithMode('nodes-base.slack', { resource: 'message', operation: 'send' }, node.properties, 'operation', 'ai-friendly');
+ expect(result.errors.find(e => e.property === 'resource')).toBeUndefined();
+ expect(result.errors.find(e => e.property === 'operation')).toBeUndefined();
+ });
+ });
});
\ No newline at end of file
diff --git a/tests/unit/services/loop-output-edge-cases.test.ts b/tests/unit/services/loop-output-edge-cases.test.ts
deleted file mode 100644
index 85916cc..0000000
--- a/tests/unit/services/loop-output-edge-cases.test.ts
+++ /dev/null
@@ -1,865 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/enhanced-config-validator');
-
-describe('Loop Output Fix - Edge Cases', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
- let mockNodeValidator: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- mockNodeRepository = {
- getNode: vi.fn((nodeType: string) => {
- // Default return
- if (nodeType === 'nodes-base.splitInBatches') {
- return {
- nodeType: 'nodes-base.splitInBatches',
- outputs: [
- { displayName: 'Done', name: 'done' },
- { displayName: 'Loop', name: 'loop' }
- ],
- outputNames: ['done', 'loop'],
- properties: []
- };
- }
- return {
- nodeType,
- properties: []
- };
- })
- };
-
- mockNodeValidator = {
- validateWithMode: vi.fn().mockReturnValue({
- errors: [],
- warnings: []
- })
- };
-
- validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator);
- });
-
- describe('Nodes without outputs', () => {
- it('should handle nodes with null outputs gracefully', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.httpRequest',
- outputs: null,
- outputNames: null,
- properties: []
- });
-
- const workflow = {
- name: 'No Outputs Workflow',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: { url: 'https://example.com' }
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request': {
- main: [
- [{ node: 'Set', type: 'main', index: 0 }]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not crash or produce output-related errors
- expect(result).toBeDefined();
- const outputErrors = result.errors.filter(e =>
- e.message?.includes('output') && !e.message?.includes('Connection')
- );
- expect(outputErrors).toHaveLength(0);
- });
-
- it('should handle nodes with undefined outputs gracefully', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.webhook',
- // outputs and outputNames are undefined
- properties: []
- });
-
- const workflow = {
- name: 'Undefined Outputs Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result).toBeDefined();
- expect(result.valid).toBeTruthy(); // Empty workflow with webhook should be valid
- });
-
- it('should handle nodes with empty outputs array', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.customNode',
- outputs: [],
- outputNames: [],
- properties: []
- });
-
- const workflow = {
- name: 'Empty Outputs Workflow',
- nodes: [
- {
- id: '1',
- name: 'Custom Node',
- type: 'n8n-nodes-base.customNode',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Custom Node': {
- main: [
- [{ node: 'Custom Node', type: 'main', index: 0 }] // Self-reference
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should warn about self-reference but not crash
- const selfRefWarnings = result.warnings.filter(w =>
- w.message?.includes('self-referencing')
- );
- expect(selfRefWarnings).toHaveLength(1);
- });
- });
-
- describe('Invalid connection indices', () => {
- it('should handle negative connection indices', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Negative Index Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'Set', type: 'main', index: -1 }] // Invalid negative index
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const negativeIndexErrors = result.errors.filter(e =>
- e.message?.includes('Invalid connection index -1')
- );
- expect(negativeIndexErrors).toHaveLength(1);
- expect(negativeIndexErrors[0].message).toContain('must be non-negative');
- });
-
- it('should handle very large connection indices', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.switch',
- outputs: [
- { displayName: 'Output 1' },
- { displayName: 'Output 2' }
- ],
- properties: []
- });
-
- const workflow = {
- name: 'Large Index Workflow',
- nodes: [
- {
- id: '1',
- name: 'Switch',
- type: 'n8n-nodes-base.switch',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Switch': {
- main: [
- [{ node: 'Set', type: 'main', index: 999 }] // Very large index
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should validate without crashing (n8n allows large indices)
- expect(result).toBeDefined();
- });
- });
-
- describe('Malformed connection structures', () => {
- it('should handle null connection objects', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Null Connections Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- null, // Null output
- [{ node: 'NonExistent', type: 'main', index: 0 }]
- ] as any
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should handle gracefully without crashing
- expect(result).toBeDefined();
- });
-
- it('should handle missing connection properties', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Malformed Connections Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [
- { node: 'Set' } as any, // Missing type and index
- { type: 'main', index: 0 } as any, // Missing node
- {} as any // Empty object
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should handle malformed connections but report errors
- expect(result).toBeDefined();
- expect(result.errors.length).toBeGreaterThan(0);
- });
- });
-
- describe('Deep loop back detection limits', () => {
- it('should respect maxDepth limit in checkForLoopBack', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- // Create a very deep chain that exceeds maxDepth (50)
- const nodes = [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- }
- ];
-
- const connections: any = {
- 'Split In Batches': {
- main: [
- [], // Done output
- [{ node: 'Node1', type: 'main', index: 0 }] // Loop output
- ]
- }
- };
-
- // Create chain of 60 nodes (exceeds maxDepth of 50)
- for (let i = 1; i <= 60; i++) {
- nodes.push({
- id: (i + 1).toString(),
- name: `Node${i}`,
- type: 'n8n-nodes-base.set',
- position: [100 + i * 50, 100],
- parameters: {}
- });
-
- if (i < 60) {
- connections[`Node${i}`] = {
- main: [[{ node: `Node${i + 1}`, type: 'main', index: 0 }]]
- };
- } else {
- // Last node connects back to Split In Batches
- connections[`Node${i}`] = {
- main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]]
- };
- }
- }
-
- const workflow = {
- name: 'Deep Chain Workflow',
- nodes,
- connections
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should warn about missing loop back because depth limit prevents detection
- const loopBackWarnings = result.warnings.filter(w =>
- w.message?.includes('doesn\'t connect back')
- );
- expect(loopBackWarnings).toHaveLength(1);
- });
-
- it('should handle circular references without infinite loops', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Circular Reference Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'NodeA',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'NodeB',
- type: 'n8n-nodes-base.function',
- position: [500, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [],
- [{ node: 'NodeA', type: 'main', index: 0 }]
- ]
- },
- 'NodeA': {
- main: [
- [{ node: 'NodeB', type: 'main', index: 0 }]
- ]
- },
- 'NodeB': {
- main: [
- [{ node: 'NodeA', type: 'main', index: 0 }] // Circular: B -> A -> B -> A ...
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should complete without hanging and warn about missing loop back
- expect(result).toBeDefined();
- const loopBackWarnings = result.warnings.filter(w =>
- w.message?.includes('doesn\'t connect back')
- );
- expect(loopBackWarnings).toHaveLength(1);
- });
-
- it('should handle self-referencing nodes in loop back detection', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Self Reference Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'SelfRef',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [],
- [{ node: 'SelfRef', type: 'main', index: 0 }]
- ]
- },
- 'SelfRef': {
- main: [
- [{ node: 'SelfRef', type: 'main', index: 0 }] // Self-reference instead of loop back
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should warn about missing loop back and self-reference
- const loopBackWarnings = result.warnings.filter(w =>
- w.message?.includes('doesn\'t connect back')
- );
- const selfRefWarnings = result.warnings.filter(w =>
- w.message?.includes('self-referencing')
- );
-
- expect(loopBackWarnings).toHaveLength(1);
- expect(selfRefWarnings).toHaveLength(1);
- });
- });
-
- describe('Complex output structures', () => {
- it('should handle nodes with many outputs', async () => {
- const manyOutputs = Array.from({ length: 20 }, (_, i) => ({
- displayName: `Output ${i + 1}`,
- name: `output${i + 1}`,
- description: `Output number ${i + 1}`
- }));
-
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.complexSwitch',
- outputs: manyOutputs,
- outputNames: manyOutputs.map(o => o.name),
- properties: []
- });
-
- const workflow = {
- name: 'Many Outputs Workflow',
- nodes: [
- {
- id: '1',
- name: 'Complex Switch',
- type: 'n8n-nodes-base.complexSwitch',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Complex Switch': {
- main: Array.from({ length: 20 }, () => [
- { node: 'Set', type: 'main', index: 0 }
- ])
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should handle without performance issues
- expect(result).toBeDefined();
- });
-
- it('should handle mixed output types (main, error, ai_tool)', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.complexNode',
- outputs: [
- { displayName: 'Main', type: 'main' },
- { displayName: 'Error', type: 'error' }
- ],
- properties: []
- });
-
- const workflow = {
- name: 'Mixed Output Types Workflow',
- nodes: [
- {
- id: '1',
- name: 'Complex Node',
- type: 'n8n-nodes-base.complexNode',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Main Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 50],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 150],
- parameters: {}
- },
- {
- id: '4',
- name: 'Tool',
- type: 'n8n-nodes-base.httpRequest',
- position: [500, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Complex Node': {
- main: [
- [{ node: 'Main Handler', type: 'main', index: 0 }]
- ],
- error: [
- [{ node: 'Error Handler', type: 'main', index: 0 }]
- ],
- ai_tool: [
- [{ node: 'Tool', type: 'main', index: 0 }]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should validate all connection types
- expect(result).toBeDefined();
- expect(result.statistics.validConnections).toBe(3);
- });
- });
-
- describe('SplitInBatches specific edge cases', () => {
- it('should handle SplitInBatches with no connections', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Isolated SplitInBatches',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not produce SplitInBatches-specific warnings for isolated node
- const splitWarnings = result.warnings.filter(w =>
- w.message?.includes('SplitInBatches') ||
- w.message?.includes('loop') ||
- w.message?.includes('done')
- );
- expect(splitWarnings).toHaveLength(0);
- });
-
- it('should handle SplitInBatches with only one output connected', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Single Output SplitInBatches',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Final Action',
- type: 'n8n-nodes-base.emailSend',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'Final Action', type: 'main', index: 0 }], // Only done output connected
- [] // Loop output empty
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should NOT warn about empty loop output (it's only a problem if loop connects to something but doesn't loop back)
- // An empty loop output is valid - it just means no looping occurs
- const loopWarnings = result.warnings.filter(w =>
- w.message?.includes('loop') && w.message?.includes('connect back')
- );
- expect(loopWarnings).toHaveLength(0);
- });
-
- it('should handle SplitInBatches with both outputs to same node', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Same Target SplitInBatches',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Multi Purpose',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'Multi Purpose', type: 'main', index: 0 }], // Done -> Multi Purpose
- [{ node: 'Multi Purpose', type: 'main', index: 0 }] // Loop -> Multi Purpose
- ]
- },
- 'Multi Purpose': {
- main: [
- [{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Both outputs go to same node which loops back - should be valid
- // No warnings about loop back since it does connect back
- const loopWarnings = result.warnings.filter(w =>
- w.message?.includes('loop') && w.message?.includes('connect back')
- );
- expect(loopWarnings).toHaveLength(0);
- });
-
- it('should detect reversed outputs with processing node on done output', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- const workflow = {
- name: 'Reversed SplitInBatches with Function Node',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Process Function',
- type: 'n8n-nodes-base.function',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'Process Function', type: 'main', index: 0 }], // Done -> Function (this is wrong)
- [] // Loop output empty
- ]
- },
- 'Process Function': {
- main: [
- [{ node: 'Split In Batches', type: 'main', index: 0 }] // Function connects back (indicates it should be on loop)
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should error about reversed outputs since function node on done output connects back
- const reversedErrors = result.errors.filter(e =>
- e.message?.includes('SplitInBatches outputs appear reversed')
- );
- expect(reversedErrors).toHaveLength(1);
- });
-
- it('should handle non-existent node type gracefully', async () => {
- // Node doesn't exist in repository
- mockNodeRepository.getNode.mockReturnValue(null);
-
- const workflow = {
- name: 'Unknown Node Type',
- nodes: [
- {
- id: '1',
- name: 'Unknown Node',
- type: 'n8n-nodes-base.unknownNode',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should report unknown node type error
- const unknownNodeErrors = result.errors.filter(e =>
- e.message?.includes('Unknown node type')
- );
- expect(unknownNodeErrors).toHaveLength(1);
- });
- });
-
- describe('Performance edge cases', () => {
- it('should handle very large workflows efficiently', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.set',
- properties: []
- });
-
- // Create workflow with 1000 nodes
- const nodes = Array.from({ length: 1000 }, (_, i) => ({
- id: `node${i}`,
- name: `Node ${i}`,
- type: 'n8n-nodes-base.set',
- position: [100 + (i % 50) * 50, 100 + Math.floor(i / 50) * 50],
- parameters: {}
- }));
-
- // Create simple linear connections
- const connections: any = {};
- for (let i = 0; i < 999; i++) {
- connections[`Node ${i}`] = {
- main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]]
- };
- }
-
- const workflow = {
- name: 'Large Workflow',
- nodes,
- connections
- };
-
- const startTime = Date.now();
- const result = await validator.validateWorkflow(workflow as any);
- const duration = Date.now() - startTime;
-
- // Should complete within reasonable time (< 5 seconds)
- expect(duration).toBeLessThan(5000);
- expect(result).toBeDefined();
- expect(result.statistics.totalNodes).toBe(1000);
- });
-
- it('should handle workflows with many SplitInBatches nodes', async () => {
- // Use default mock that includes outputs for SplitInBatches
-
- // Create 100 SplitInBatches nodes
- const nodes = Array.from({ length: 100 }, (_, i) => ({
- id: `split${i}`,
- name: `Split ${i}`,
- type: 'n8n-nodes-base.splitInBatches',
- position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100],
- parameters: {}
- }));
-
- const connections: any = {};
- // Each split connects to the next one
- for (let i = 0; i < 99; i++) {
- connections[`Split ${i}`] = {
- main: [
- [{ node: `Split ${i + 1}`, type: 'main', index: 0 }], // Done -> next split
- [] // Empty loop
- ]
- };
- }
-
- const workflow = {
- name: 'Many SplitInBatches Workflow',
- nodes,
- connections
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should validate all nodes without performance issues
- expect(result).toBeDefined();
- expect(result.statistics.totalNodes).toBe(100);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/n8n-validation-sticky-notes.test.ts b/tests/unit/services/n8n-validation-sticky-notes.test.ts
deleted file mode 100644
index e7873d5..0000000
--- a/tests/unit/services/n8n-validation-sticky-notes.test.ts
+++ /dev/null
@@ -1,532 +0,0 @@
-import { describe, test, expect } from 'vitest';
-import { validateWorkflowStructure } from '@/services/n8n-validation';
-import type { Workflow } from '@/types/n8n-api';
-
-describe('n8n-validation - Sticky Notes Bug Fix', () => {
- describe('sticky notes should be excluded from disconnected nodes validation', () => {
- test('should allow workflow with sticky notes and connected functional nodes', () => {
- const workflow: Partial = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: '2',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: 'sticky1',
- name: 'Documentation Note',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'This is a documentation note' }
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
- }
- }
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should have no errors - sticky note should be ignored
- expect(errors).toEqual([]);
- });
-
- test('should handle multiple sticky notes without errors', () => {
- const workflow: Partial = {
- name: 'Documented Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: '2',
- name: 'Process',
- type: 'n8n-nodes-base.set',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- // 10 sticky notes for documentation
- ...Array.from({ length: 10 }, (_, i) => ({
- id: `sticky${i}`,
- name: `๐ Note ${i}`,
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [100 + i * 50, 100] as [number, number],
- parameters: { content: `Documentation note ${i}` }
- }))
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Process', type: 'main', index: 0 }]]
- }
- }
- };
-
- const errors = validateWorkflowStructure(workflow);
- expect(errors).toEqual([]);
- });
-
- test('should handle all sticky note type variations', () => {
- const stickyTypes = [
- 'n8n-nodes-base.stickyNote',
- 'nodes-base.stickyNote',
- '@n8n/n8n-nodes-base.stickyNote'
- ];
-
- stickyTypes.forEach((stickyType, index) => {
- const workflow: Partial = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: `sticky${index}`,
- name: `Note ${index}`,
- type: stickyType,
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: `Note ${index}` }
- }
- ],
- connections: {}
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Sticky note should be ignored regardless of type variation
- expect(errors.every(e => !e.includes(`Note ${index}`))).toBe(true);
- });
- });
-
- test('should handle complex workflow with multiple sticky notes (real-world scenario)', () => {
- // Simulates workflow like "POST /auth/login" with 4 sticky notes
- const workflow: Partial = {
- name: 'POST /auth/login',
- nodes: [
- {
- id: 'webhook1',
- name: 'Webhook Trigger',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/auth/login', httpMethod: 'POST' }
- },
- {
- id: 'http1',
- name: 'Authenticate',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: 'respond1',
- name: 'Return Success',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [650, 250],
- parameters: {}
- },
- {
- id: 'respond2',
- name: 'Return Error',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [650, 350],
- parameters: {}
- },
- // 4 sticky notes for documentation
- {
- id: 'sticky1',
- name: '๐ Webhook Trigger',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 150],
- parameters: { content: 'Receives login request' }
- },
- {
- id: 'sticky2',
- name: '๐ Authenticate with Supabase',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [450, 150],
- parameters: { content: 'Validates credentials' }
- },
- {
- id: 'sticky3',
- name: '๐ Return Tokens',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [650, 150],
- parameters: { content: 'Returns access and refresh tokens' }
- },
- {
- id: 'sticky4',
- name: '๐ Return Error',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [650, 450],
- parameters: { content: 'Returns error message' }
- }
- ],
- connections: {
- 'Webhook Trigger': {
- main: [[{ node: 'Authenticate', type: 'main', index: 0 }]]
- },
- 'Authenticate': {
- main: [
- [{ node: 'Return Success', type: 'main', index: 0 }],
- [{ node: 'Return Error', type: 'main', index: 0 }]
- ]
- }
- }
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should have no errors - all sticky notes should be ignored
- expect(errors).toEqual([]);
- });
- });
-
- describe('validation should still detect truly disconnected functional nodes', () => {
- test('should detect disconnected HTTP node but ignore sticky note', () => {
- const workflow: Partial = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: '2',
- name: 'Disconnected HTTP',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: 'sticky1',
- name: 'Sticky Note',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'Note' }
- }
- ],
- connections: {} // No connections
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should error on HTTP node, but NOT on sticky note
- expect(errors.length).toBeGreaterThan(0);
- const disconnectedError = errors.find(e => e.includes('Disconnected'));
- expect(disconnectedError).toBeDefined();
- expect(disconnectedError).toContain('Disconnected HTTP');
- expect(disconnectedError).not.toContain('Sticky Note');
- });
-
- test('should detect multiple disconnected functional nodes but ignore sticky notes', () => {
- const workflow: Partial = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: '2',
- name: 'Disconnected HTTP',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: '3',
- name: 'Disconnected Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 3,
- position: [650, 300],
- parameters: {}
- },
- // Multiple sticky notes that should be ignored
- {
- id: 'sticky1',
- name: 'Note 1',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'Note 1' }
- },
- {
- id: 'sticky2',
- name: 'Note 2',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [450, 100],
- parameters: { content: 'Note 2' }
- }
- ],
- connections: {} // No connections
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should error because there are no connections
- // When there are NO connections, validation shows "Multi-node workflow has no connections"
- // This is the expected behavior - it suggests connecting any two executable nodes
- expect(errors.length).toBeGreaterThan(0);
- const connectionError = errors.find(e => e.includes('no connections') || e.includes('Disconnected'));
- expect(connectionError).toBeDefined();
- // Error should NOT mention sticky notes
- expect(connectionError).not.toContain('Note 1');
- expect(connectionError).not.toContain('Note 2');
- });
-
- test('should allow sticky notes but still validate functional node connections', () => {
- const workflow: Partial = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: '2',
- name: 'Connected HTTP',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: '3',
- name: 'Disconnected Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 3,
- position: [650, 300],
- parameters: {}
- },
- {
- id: 'sticky1',
- name: 'Sticky Note',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'Note' }
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Connected HTTP', type: 'main', index: 0 }]]
- }
- }
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should error only on disconnected Set node
- expect(errors.length).toBeGreaterThan(0);
- const disconnectedError = errors.find(e => e.includes('Disconnected'));
- expect(disconnectedError).toBeDefined();
- expect(disconnectedError).toContain('Disconnected Set');
- expect(disconnectedError).not.toContain('Connected HTTP');
- expect(disconnectedError).not.toContain('Sticky Note');
- });
- });
-
- describe('regression tests - ensure sticky notes work like in n8n UI', () => {
- test('single webhook with sticky notes should be valid (matches n8n UI behavior)', () => {
- const workflow: Partial = {
- name: 'Webhook Only with Notes',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/test' }
- },
- {
- id: 'sticky1',
- name: 'Usage Instructions',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'Call this webhook to trigger the workflow' }
- }
- ],
- connections: {}
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Webhook-only workflows are valid in n8n
- // Sticky notes should not affect this
- expect(errors).toEqual([]);
- });
-
- test('workflow with only sticky notes should be invalid (no executable nodes)', () => {
- const workflow: Partial = {
- name: 'Only Notes',
- nodes: [
- {
- id: 'sticky1',
- name: 'Note 1',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250, 100],
- parameters: { content: 'Note 1' }
- },
- {
- id: 'sticky2',
- name: 'Note 2',
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [450, 100],
- parameters: { content: 'Note 2' }
- }
- ],
- connections: {}
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should fail because there are no executable nodes
- expect(errors.length).toBeGreaterThan(0);
- expect(errors.some(e => e.includes('at least one executable node'))).toBe(true);
- });
-
- test('complex production workflow structure should validate correctly', () => {
- // Tests a realistic production workflow structure
- const workflow: Partial = {
- name: 'Production API Endpoint',
- nodes: [
- // Functional nodes
- {
- id: 'webhook1',
- name: 'API Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300],
- parameters: { path: '/api/endpoint' }
- },
- {
- id: 'validate1',
- name: 'Validate Input',
- type: 'n8n-nodes-base.code',
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- },
- {
- id: 'branch1',
- name: 'Check Valid',
- type: 'n8n-nodes-base.if',
- typeVersion: 2,
- position: [650, 300],
- parameters: {}
- },
- {
- id: 'process1',
- name: 'Process Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [850, 250],
- parameters: {}
- },
- {
- id: 'success1',
- name: 'Return Success',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [1050, 250],
- parameters: {}
- },
- {
- id: 'error1',
- name: 'Return Error',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [850, 350],
- parameters: {}
- },
- // Documentation sticky notes (11 notes like in real workflow)
- ...Array.from({ length: 11 }, (_, i) => ({
- id: `sticky${i}`,
- name: `๐ Documentation ${i}`,
- type: 'n8n-nodes-base.stickyNote',
- typeVersion: 1,
- position: [250 + i * 100, 100] as [number, number],
- parameters: { content: `Documentation section ${i}` }
- }))
- ],
- connections: {
- 'API Webhook': {
- main: [[{ node: 'Validate Input', type: 'main', index: 0 }]]
- },
- 'Validate Input': {
- main: [[{ node: 'Check Valid', type: 'main', index: 0 }]]
- },
- 'Check Valid': {
- main: [
- [{ node: 'Process Request', type: 'main', index: 0 }],
- [{ node: 'Return Error', type: 'main', index: 0 }]
- ]
- },
- 'Process Request': {
- main: [[{ node: 'Return Success', type: 'main', index: 0 }]]
- }
- }
- };
-
- const errors = validateWorkflowStructure(workflow);
-
- // Should be valid - all functional nodes connected, sticky notes ignored
- expect(errors).toEqual([]);
- });
- });
-});
diff --git a/tests/unit/services/n8n-validation.test.ts b/tests/unit/services/n8n-validation.test.ts
index ff16a7d..76de23f 100644
--- a/tests/unit/services/n8n-validation.test.ts
+++ b/tests/unit/services/n8n-validation.test.ts
@@ -1830,4 +1830,513 @@ describe('n8n-validation', () => {
expect(validateWorkflowStructure(forUpdate)).toEqual([]);
});
});
+
+ describe('Sticky Notes Bug Fix', () => {
+ describe('sticky notes should be excluded from disconnected nodes validation', () => {
+ it('should allow workflow with sticky notes and connected functional nodes', () => {
+ const workflow: Partial = {
+ name: 'Test Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: '2',
+ name: 'HTTP Request',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: 'sticky1',
+ name: 'Documentation Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'This is a documentation note' }
+ }
+ ],
+ connections: {
+ 'Webhook': {
+ main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors).toEqual([]);
+ });
+
+ it('should handle multiple sticky notes without errors', () => {
+ const workflow: Partial = {
+ name: 'Documented Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: '2',
+ name: 'Process',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ ...Array.from({ length: 10 }, (_, i) => ({
+ id: `sticky${i}`,
+ name: `Note ${i}`,
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [100 + i * 50, 100] as [number, number],
+ parameters: { content: `Documentation note ${i}` }
+ }))
+ ],
+ connections: {
+ 'Webhook': {
+ main: [[{ node: 'Process', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+ expect(errors).toEqual([]);
+ });
+
+ it('should handle all sticky note type variations', () => {
+ const stickyTypes = [
+ 'n8n-nodes-base.stickyNote',
+ 'nodes-base.stickyNote',
+ '@n8n/n8n-nodes-base.stickyNote'
+ ];
+
+ stickyTypes.forEach((stickyType, index) => {
+ const workflow: Partial = {
+ name: 'Test Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: `sticky${index}`,
+ name: `Note ${index}`,
+ type: stickyType,
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: `Note ${index}` }
+ }
+ ],
+ connections: {}
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors.every(e => !e.includes(`Note ${index}`))).toBe(true);
+ });
+ });
+
+ it('should handle complex workflow with multiple sticky notes (real-world scenario)', () => {
+ const workflow: Partial = {
+ name: 'POST /auth/login',
+ nodes: [
+ {
+ id: 'webhook1',
+ name: 'Webhook Trigger',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/auth/login', httpMethod: 'POST' }
+ },
+ {
+ id: 'http1',
+ name: 'Authenticate',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: 'respond1',
+ name: 'Return Success',
+ type: 'n8n-nodes-base.respondToWebhook',
+ typeVersion: 1,
+ position: [650, 250],
+ parameters: {}
+ },
+ {
+ id: 'respond2',
+ name: 'Return Error',
+ type: 'n8n-nodes-base.respondToWebhook',
+ typeVersion: 1,
+ position: [650, 350],
+ parameters: {}
+ },
+ {
+ id: 'sticky1',
+ name: 'Webhook Trigger Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 150],
+ parameters: { content: 'Receives login request' }
+ },
+ {
+ id: 'sticky2',
+ name: 'Authenticate with Supabase Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [450, 150],
+ parameters: { content: 'Validates credentials' }
+ },
+ {
+ id: 'sticky3',
+ name: 'Return Tokens Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [650, 150],
+ parameters: { content: 'Returns access and refresh tokens' }
+ },
+ {
+ id: 'sticky4',
+ name: 'Return Error Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [650, 450],
+ parameters: { content: 'Returns error message' }
+ }
+ ],
+ connections: {
+ 'Webhook Trigger': {
+ main: [[{ node: 'Authenticate', type: 'main', index: 0 }]]
+ },
+ 'Authenticate': {
+ main: [
+ [{ node: 'Return Success', type: 'main', index: 0 }],
+ [{ node: 'Return Error', type: 'main', index: 0 }]
+ ]
+ }
+ }
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors).toEqual([]);
+ });
+ });
+
+ describe('validation should still detect truly disconnected functional nodes', () => {
+ it('should detect disconnected HTTP node but ignore sticky note', () => {
+ const workflow: Partial = {
+ name: 'Test Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: '2',
+ name: 'Disconnected HTTP',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: 'sticky1',
+ name: 'Sticky Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'Note' }
+ }
+ ],
+ connections: {}
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors.length).toBeGreaterThan(0);
+ const disconnectedError = errors.find(e => e.includes('Disconnected'));
+ expect(disconnectedError).toBeDefined();
+ expect(disconnectedError).toContain('Disconnected HTTP');
+ expect(disconnectedError).not.toContain('Sticky Note');
+ });
+
+ it('should detect multiple disconnected functional nodes but ignore sticky notes', () => {
+ const workflow: Partial = {
+ name: 'Test Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: '2',
+ name: 'Disconnected HTTP',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: '3',
+ name: 'Disconnected Set',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 3,
+ position: [650, 300],
+ parameters: {}
+ },
+ {
+ id: 'sticky1',
+ name: 'Note 1',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'Note 1' }
+ },
+ {
+ id: 'sticky2',
+ name: 'Note 2',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [450, 100],
+ parameters: { content: 'Note 2' }
+ }
+ ],
+ connections: {}
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors.length).toBeGreaterThan(0);
+ const connectionError = errors.find(e => e.includes('no connections') || e.includes('Disconnected'));
+ expect(connectionError).toBeDefined();
+ expect(connectionError).not.toContain('Note 1');
+ expect(connectionError).not.toContain('Note 2');
+ });
+
+ it('should allow sticky notes but still validate functional node connections', () => {
+ const workflow: Partial = {
+ name: 'Test Workflow',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: '2',
+ name: 'Connected HTTP',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: '3',
+ name: 'Disconnected Set',
+ type: 'n8n-nodes-base.set',
+ typeVersion: 3,
+ position: [650, 300],
+ parameters: {}
+ },
+ {
+ id: 'sticky1',
+ name: 'Sticky Note',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'Note' }
+ }
+ ],
+ connections: {
+ 'Webhook': {
+ main: [[{ node: 'Connected HTTP', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors.length).toBeGreaterThan(0);
+ const disconnectedError = errors.find(e => e.includes('Disconnected'));
+ expect(disconnectedError).toBeDefined();
+ expect(disconnectedError).toContain('Disconnected Set');
+ expect(disconnectedError).not.toContain('Connected HTTP');
+ expect(disconnectedError).not.toContain('Sticky Note');
+ });
+ });
+
+ describe('regression tests - ensure sticky notes work like in n8n UI', () => {
+ it('single webhook with sticky notes should be valid (matches n8n UI behavior)', () => {
+ const workflow: Partial = {
+ name: 'Webhook Only with Notes',
+ nodes: [
+ {
+ id: '1',
+ name: 'Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/test' }
+ },
+ {
+ id: 'sticky1',
+ name: 'Usage Instructions',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'Call this webhook to trigger the workflow' }
+ }
+ ],
+ connections: {}
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors).toEqual([]);
+ });
+
+ it('workflow with only sticky notes should be invalid (no executable nodes)', () => {
+ const workflow: Partial = {
+ name: 'Only Notes',
+ nodes: [
+ {
+ id: 'sticky1',
+ name: 'Note 1',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250, 100],
+ parameters: { content: 'Note 1' }
+ },
+ {
+ id: 'sticky2',
+ name: 'Note 2',
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [450, 100],
+ parameters: { content: 'Note 2' }
+ }
+ ],
+ connections: {}
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors.some(e => e.includes('at least one executable node'))).toBe(true);
+ });
+
+ it('complex production workflow structure should validate correctly', () => {
+ const workflow: Partial = {
+ name: 'Production API Endpoint',
+ nodes: [
+ {
+ id: 'webhook1',
+ name: 'API Webhook',
+ type: 'n8n-nodes-base.webhook',
+ typeVersion: 1,
+ position: [250, 300],
+ parameters: { path: '/api/endpoint' }
+ },
+ {
+ id: 'validate1',
+ name: 'Validate Input',
+ type: 'n8n-nodes-base.code',
+ typeVersion: 2,
+ position: [450, 300],
+ parameters: {}
+ },
+ {
+ id: 'branch1',
+ name: 'Check Valid',
+ type: 'n8n-nodes-base.if',
+ typeVersion: 2,
+ position: [650, 300],
+ parameters: {}
+ },
+ {
+ id: 'process1',
+ name: 'Process Request',
+ type: 'n8n-nodes-base.httpRequest',
+ typeVersion: 3,
+ position: [850, 250],
+ parameters: {}
+ },
+ {
+ id: 'success1',
+ name: 'Return Success',
+ type: 'n8n-nodes-base.respondToWebhook',
+ typeVersion: 1,
+ position: [1050, 250],
+ parameters: {}
+ },
+ {
+ id: 'error1',
+ name: 'Return Error',
+ type: 'n8n-nodes-base.respondToWebhook',
+ typeVersion: 1,
+ position: [850, 350],
+ parameters: {}
+ },
+ ...Array.from({ length: 11 }, (_, i) => ({
+ id: `sticky${i}`,
+ name: `Documentation ${i}`,
+ type: 'n8n-nodes-base.stickyNote',
+ typeVersion: 1,
+ position: [250 + i * 100, 100] as [number, number],
+ parameters: { content: `Documentation section ${i}` }
+ }))
+ ],
+ connections: {
+ 'API Webhook': {
+ main: [[{ node: 'Validate Input', type: 'main', index: 0 }]]
+ },
+ 'Validate Input': {
+ main: [[{ node: 'Check Valid', type: 'main', index: 0 }]]
+ },
+ 'Check Valid': {
+ main: [
+ [{ node: 'Process Request', type: 'main', index: 0 }],
+ [{ node: 'Return Error', type: 'main', index: 0 }]
+ ]
+ },
+ 'Process Request': {
+ main: [[{ node: 'Return Success', type: 'main', index: 0 }]]
+ }
+ }
+ };
+
+ const errors = validateWorkflowStructure(workflow);
+
+ expect(errors).toEqual([]);
+ });
+ });
+ });
});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-ai-subnode.test.ts b/tests/unit/services/workflow-validator-ai-subnode.test.ts
deleted file mode 100644
index 75547ac..0000000
--- a/tests/unit/services/workflow-validator-ai-subnode.test.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/enhanced-config-validator');
-vi.mock('@/services/expression-validator');
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - AI Sub-Node Main Connection Detection', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: NodeRepository;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- mockNodeRepository = new NodeRepository({} as any) as any;
-
- if (!mockNodeRepository.getAllNodes) {
- mockNodeRepository.getAllNodes = vi.fn();
- }
- if (!mockNodeRepository.getNode) {
- mockNodeRepository.getNode = vi.fn();
- }
-
- const nodeTypes: Record = {
- 'nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- package: 'n8n-nodes-base',
- isTrigger: true,
- outputs: ['main'],
- properties: [],
- },
- 'nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- package: 'n8n-nodes-base',
- outputs: ['main'],
- properties: [],
- },
- 'nodes-langchain.lmChatGoogleGemini': {
- type: 'nodes-langchain.lmChatGoogleGemini',
- displayName: 'Google Gemini Chat Model',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['ai_languageModel'],
- properties: [],
- },
- 'nodes-langchain.memoryBufferWindow': {
- type: 'nodes-langchain.memoryBufferWindow',
- displayName: 'Window Buffer Memory',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['ai_memory'],
- properties: [],
- },
- 'nodes-langchain.embeddingsOpenAi': {
- type: 'nodes-langchain.embeddingsOpenAi',
- displayName: 'Embeddings OpenAI',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['ai_embedding'],
- properties: [],
- },
- 'nodes-langchain.agent': {
- type: 'nodes-langchain.agent',
- displayName: 'AI Agent',
- package: '@n8n/n8n-nodes-langchain',
- isAITool: true,
- outputs: ['main'],
- properties: [],
- },
- 'nodes-langchain.openAi': {
- type: 'nodes-langchain.openAi',
- displayName: 'OpenAI',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['main'],
- properties: [],
- },
- 'nodes-langchain.textClassifier': {
- type: 'nodes-langchain.textClassifier',
- displayName: 'Text Classifier',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['={{}}'], // Dynamic expression-based outputs
- properties: [],
- },
- 'nodes-langchain.vectorStoreInMemory': {
- type: 'nodes-langchain.vectorStoreInMemory',
- displayName: 'In-Memory Vector Store',
- package: '@n8n/n8n-nodes-langchain',
- outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'],
- properties: [],
- },
- };
-
- vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
- return nodeTypes[nodeType] || null;
- });
- vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
-
- validator = new WorkflowValidator(
- mockNodeRepository,
- EnhancedConfigValidator as any
- );
- });
-
- function makeWorkflow(sourceType: string, sourceName: string, connectionKey: string = 'main') {
- return {
- nodes: [
- { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
- { id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} },
- { id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
- ],
- connections: {
- 'Manual Trigger': {
- main: [[{ node: sourceName, type: 'main', index: 0 }]]
- },
- [sourceName]: {
- [connectionKey]: [[{ node: 'Set', type: connectionKey, index: 0 }]]
- }
- }
- };
- }
-
- it('should flag LLM node (lmChatGoogleGemini) connected via main', async () => {
- const workflow = makeWorkflow(
- 'n8n-nodes-langchain.lmChatGoogleGemini',
- 'Google Gemini'
- );
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
- expect(error).toBeDefined();
- expect(error!.message).toContain('ai_languageModel');
- expect(error!.message).toContain('AI sub-node');
- expect(error!.nodeName).toBe('Google Gemini');
- });
-
- it('should flag memory node (memoryBufferWindow) connected via main', async () => {
- const workflow = makeWorkflow(
- 'n8n-nodes-langchain.memoryBufferWindow',
- 'Window Buffer Memory'
- );
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
- expect(error).toBeDefined();
- expect(error!.message).toContain('ai_memory');
- });
-
- it('should flag embeddings node connected via main', async () => {
- const workflow = makeWorkflow(
- 'n8n-nodes-langchain.embeddingsOpenAi',
- 'Embeddings OpenAI'
- );
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
- expect(error).toBeDefined();
- expect(error!.message).toContain('ai_embedding');
- });
-
- it('should NOT flag regular langchain nodes (agent, openAi) connected via main', async () => {
- const workflow1 = makeWorkflow('n8n-nodes-langchain.agent', 'AI Agent');
- const workflow2 = makeWorkflow('n8n-nodes-langchain.openAi', 'OpenAI');
-
- const result1 = await validator.validateWorkflow(workflow1 as any);
- const result2 = await validator.validateWorkflow(workflow2 as any);
-
- expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- });
-
- it('should NOT flag dynamic-output nodes (expression-based outputs)', async () => {
- const workflow1 = makeWorkflow('n8n-nodes-langchain.textClassifier', 'Text Classifier');
- const workflow2 = makeWorkflow('n8n-nodes-langchain.vectorStoreInMemory', 'Vector Store');
-
- const result1 = await validator.validateWorkflow(workflow1 as any);
- const result2 = await validator.validateWorkflow(workflow2 as any);
-
- expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- });
-
- it('should NOT flag AI sub-node connected via correct AI type', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
- { id: '2', name: 'AI Agent', type: 'n8n-nodes-langchain.agent', position: [200, 0], parameters: {} },
- { id: '3', name: 'Google Gemini', type: 'n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} },
- ],
- connections: {
- 'Manual Trigger': {
- main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
- },
- 'Google Gemini': {
- ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- });
-
- it('should NOT flag unknown/community nodes not in database', async () => {
- const workflow = makeWorkflow('n8n-nodes-community.someNode', 'Community Node');
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
- });
-});
diff --git a/tests/unit/services/workflow-validator-comprehensive.test.ts b/tests/unit/services/workflow-validator-comprehensive.test.ts
deleted file mode 100644
index 3010e1f..0000000
--- a/tests/unit/services/workflow-validator-comprehensive.test.ts
+++ /dev/null
@@ -1,2079 +0,0 @@
-import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-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 type { WorkflowNode, Workflow } from '@/types/n8n-api';
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/enhanced-config-validator');
-vi.mock('@/services/expression-validator');
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - Comprehensive Tests', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: NodeRepository;
- let mockEnhancedConfigValidator: typeof EnhancedConfigValidator;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create mock instances
- mockNodeRepository = new NodeRepository({} as any) as any;
- mockEnhancedConfigValidator = EnhancedConfigValidator as any;
-
- // Ensure the mock repository has all necessary methods
- if (!mockNodeRepository.getAllNodes) {
- mockNodeRepository.getAllNodes = vi.fn();
- }
- if (!mockNodeRepository.getNode) {
- mockNodeRepository.getNode = vi.fn();
- }
-
- // Mock common node types data
- const nodeTypes: Record = {
- 'nodes-base.webhook': {
- type: 'nodes-base.webhook',
- displayName: 'Webhook',
- package: 'n8n-nodes-base',
- version: 2,
- isVersioned: true,
- properties: [],
- category: 'trigger'
- },
- 'nodes-base.httpRequest': {
- type: 'nodes-base.httpRequest',
- displayName: 'HTTP Request',
- package: 'n8n-nodes-base',
- version: 4,
- isVersioned: true,
- properties: [],
- category: 'network'
- },
- 'nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- package: 'n8n-nodes-base',
- version: 3,
- isVersioned: true,
- properties: [],
- category: 'data'
- },
- 'nodes-base.code': {
- type: 'nodes-base.code',
- displayName: 'Code',
- package: 'n8n-nodes-base',
- version: 2,
- isVersioned: true,
- properties: [],
- category: 'code'
- },
- 'nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- package: 'n8n-nodes-base',
- version: 1,
- isVersioned: true,
- properties: [],
- category: 'trigger'
- },
- 'nodes-base.if': {
- type: 'nodes-base.if',
- displayName: 'IF',
- package: 'n8n-nodes-base',
- version: 2,
- isVersioned: true,
- properties: [],
- category: 'logic'
- },
- 'nodes-base.slack': {
- type: 'nodes-base.slack',
- displayName: 'Slack',
- package: 'n8n-nodes-base',
- version: 2,
- isVersioned: true,
- properties: [],
- category: 'communication'
- },
- 'nodes-base.googleSheets': {
- type: 'nodes-base.googleSheets',
- displayName: 'Google Sheets',
- package: 'n8n-nodes-base',
- version: 4,
- isVersioned: true,
- properties: [],
- category: 'data'
- },
- 'nodes-langchain.agent': {
- type: 'nodes-langchain.agent',
- displayName: 'AI Agent',
- package: '@n8n/n8n-nodes-langchain',
- version: 1,
- isVersioned: true,
- properties: [],
- isAITool: true,
- category: 'ai'
- },
- 'nodes-base.postgres': {
- type: 'nodes-base.postgres',
- displayName: 'Postgres',
- package: 'n8n-nodes-base',
- version: 2,
- isVersioned: true,
- properties: [],
- category: 'database'
- },
- 'community.customNode': {
- type: 'community.customNode',
- displayName: 'Custom Node',
- package: 'n8n-nodes-custom',
- version: 1,
- isVersioned: false,
- properties: [],
- isAITool: false,
- category: 'custom'
- }
- };
-
- // Set up default mock behaviors
- vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
- // Handle normalization for custom nodes
- if (nodeType === 'n8n-nodes-custom.customNode') {
- return {
- type: 'n8n-nodes-custom.customNode',
- displayName: 'Custom Node',
- package: 'n8n-nodes-custom',
- version: 1,
- isVersioned: false,
- properties: [],
- isAITool: false
- };
- }
-
- return nodeTypes[nodeType] || null;
- });
-
- // Mock getAllNodes for NodeSimilarityService
- vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
-
- vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
- errors: [],
- warnings: [],
- suggestions: [],
- mode: 'operation' as const,
- valid: true,
- visibleProperties: [],
- hiddenProperties: []
- } as any);
-
- vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
- valid: true,
- errors: [],
- warnings: [],
- usedVariables: new Set(),
- usedNodes: new Set()
- });
-
- // Create validator instance
- validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator);
- });
-
- describe('validateWorkflow', () => {
- it('should validate a minimal valid workflow', async () => {
- const workflow = createWorkflow('Test Workflow')
- .addWebhookNode({ name: 'Webhook' })
- .build();
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.statistics.totalNodes).toBe(1);
- expect(result.statistics.enabledNodes).toBe(1);
- expect(result.statistics.triggerNodes).toBe(1);
- });
-
- it('should validate a workflow with all options disabled', async () => {
- const workflow = createWorkflow('Test Workflow')
- .addWebhookNode({ name: 'Webhook' })
- .build();
-
- const result = await validator.validateWorkflow(workflow as any, {
- validateNodes: false,
- validateConnections: false,
- validateExpressions: false
- });
-
- expect(result.valid).toBe(true);
- expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
- expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
- });
-
- it('should handle validation errors gracefully', async () => {
- const workflow = createWorkflow('Test Workflow')
- .addWebhookNode({ name: 'Webhook' })
- .build();
-
- // Make the validation throw an error
- vi.mocked(mockNodeRepository.getNode).mockImplementation(() => {
- throw new Error('Database error');
- });
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true);
- });
-
- it('should use different validation profiles', async () => {
- const workflow = createWorkflow('Test Workflow')
- .addWebhookNode({ name: 'Webhook' })
- .build();
-
- const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const;
-
- for (const profile of profiles) {
- const result = await validator.validateWorkflow(workflow as any, { profile });
- expect(result).toBeDefined();
- expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith(
- expect.any(String),
- expect.any(Object),
- expect.any(Array),
- 'operation',
- profile
- );
- }
- });
- });
-
- describe('validateWorkflowStructure', () => {
- it('should error when nodes array is missing', async () => {
- const workflow = { connections: {} } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
- });
-
- it('should error when connections object is missing', async () => {
- const workflow = { nodes: [] } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
- });
-
- it('should warn when workflow has no nodes', async () => {
- const workflow = { nodes: [], connections: {} } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(true); // Empty workflows are valid but get a warning
- expect(result.warnings).toHaveLength(1);
- expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined');
- });
-
- it('should error for single non-webhook node workflow', async () => {
- const workflow = {
- nodes: [{
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- }],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
- });
-
- it('should warn for webhook without connections', async () => {
- const workflow = {
- nodes: [{
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- typeVersion: 2
- }],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(true);
- expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
- });
-
- it('should error for multi-node workflow without connections', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
- });
-
- it('should detect duplicate node names', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
- });
-
- it('should detect duplicate node IDs', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook1',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '1',
- name: 'Webhook2',
- type: 'n8n-nodes-base.webhook',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
- });
-
- it('should count trigger nodes correctly', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Schedule',
- type: 'n8n-nodes-base.scheduleTrigger',
- position: [100, 300],
- parameters: {}
- },
- {
- id: '3',
- name: 'Manual',
- type: 'n8n-nodes-base.manualTrigger',
- position: [100, 500],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.triggerNodes).toBe(3);
- });
-
- it('should warn when no trigger nodes exist', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Code',
- type: 'n8n-nodes-base.code',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Set': {
- main: [[{ node: 'Code', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
- });
-
- it('should not count disabled nodes in enabledNodes count', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- disabled: true
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.totalNodes).toBe(2);
- expect(result.statistics.enabledNodes).toBe(1);
- });
- });
-
- describe('validateAllNodes', () => {
- it('should skip disabled nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- disabled: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
- });
-
- it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => {
- // This test verifies the fix for false positives - both prefixes are valid
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'nodes-base.webhook', // This is now valid (normalized internally)
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- // Mock the normalized node lookup
- (mockNodeRepository.getNode as any) = vi.fn((type: string) => {
- if (type === 'nodes-base.webhook') {
- return {
- nodeType: 'nodes-base.webhook',
- displayName: 'Webhook',
- properties: [],
- isVersioned: false
- };
- }
- return null;
- });
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should NOT error for nodes-base prefix - it's valid!
- expect(result.valid).toBe(true);
- expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false);
- });
-
- it.skip('should handle unknown node types with suggestions', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'httpRequest', // Missing package prefix
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true);
- });
-
- it('should try normalized types for n8n-nodes-base', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
- });
-
- it('should validate typeVersion but skip parameter validation for langchain nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1,
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation
- // This prevents invalid typeVersion values from bypassing validation
- // But they skip parameter validation (handled by dedicated AI validators)
- expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
-
- // Should not have typeVersion validation errors (other AI-specific errors may exist)
- const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion'));
- expect(typeVersionErrors).toEqual([]);
- });
-
- it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 99999, // Invalid - exceeds maximum
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Critical: Before v2.17.4, this would pass validation but fail at runtime
- // After v2.17.4: Invalid typeVersion is caught during validation
- expect(result.valid).toBe(false);
- expect(result.errors.some(e =>
- e.message.includes('typeVersion 99999 exceeds maximum')
- )).toBe(true);
- });
-
- it('should validate typeVersion for versioned nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- // Missing typeVersion
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
- });
-
- it('should error for invalid typeVersion', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- typeVersion: 'invalid' as any
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
- });
-
- it('should warn for outdated typeVersion', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- typeVersion: 1 // Current version is 2
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
- });
-
- it('should error for typeVersion exceeding maximum', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- typeVersion: 10 // Max is 2
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
- });
-
- it('should add node validation errors and warnings', async () => {
- vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
- errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }],
- warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }],
- suggestions: [],
- mode: 'operation' as const,
- valid: false,
- visibleProperties: [],
- hiddenProperties: []
- } as any);
-
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- typeVersion: 4
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
- expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
- });
-
- it('should handle node validation failures gracefully', async () => {
- vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => {
- throw new Error('Validation error');
- });
-
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- typeVersion: 4
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
- });
- });
-
- describe('validateConnections', () => {
- it('should validate valid connections', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.validConnections).toBe(1);
- expect(result.statistics.invalidConnections).toBe(0);
- });
-
- it('should error for connection from non-existent node', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'NonExistent': {
- main: [[{ node: 'Webhook', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
- expect(result.statistics.invalidConnections).toBe(1);
- });
-
- it('should error when using node ID instead of name in source', async () => {
- const workflow = {
- nodes: [
- {
- id: 'webhook-id',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: 'set-id',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'webhook-id': { // Using ID instead of name
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true);
- });
-
- it('should error for connection to non-existent node', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
- expect(result.statistics.invalidConnections).toBe(1);
- });
-
- it('should error when using node ID instead of name in target', async () => {
- const workflow = {
- nodes: [
- {
- id: 'webhook-id',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: 'set-id',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true);
- });
-
- it('should warn for connection to disabled node', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {},
- disabled: true
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
- });
-
- it('should validate error outputs', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP': {
- error: [[{ node: 'Error Handler', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.validConnections).toBe(1);
- });
-
- it('should validate AI tool connections', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Tool',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Agent': {
- ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.validConnections).toBe(1);
- });
-
- it('should warn for community nodes used as AI tools', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [100, 100],
- parameters: {},
- typeVersion: 1
- },
- {
- id: '2',
- name: 'CustomTool',
- type: 'n8n-nodes-custom.customNode',
- position: [300, 100],
- parameters: {},
- typeVersion: 1
- }
- ],
- connections: {
- 'Agent': {
- ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true);
- });
-
- it('should warn for orphaned nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Orphaned',
- type: 'n8n-nodes-base.code',
- position: [500, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true);
- });
-
- it('should detect cycles in workflow', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Node1',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Node2',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Node3',
- type: 'n8n-nodes-base.set',
- position: [500, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'Node2', type: 'main', index: 0 }]]
- },
- 'Node2': {
- main: [[{ node: 'Node3', type: 'main', index: 0 }]]
- },
- 'Node3': {
- main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
- });
-
- it('should handle null connections properly', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'IF',
- type: 'n8n-nodes-base.if',
- position: [100, 100],
- parameters: {},
- typeVersion: 2
- },
- {
- id: '2',
- name: 'True Branch',
- type: 'n8n-nodes-base.set',
- position: [300, 50],
- parameters: {},
- typeVersion: 3
- }
- ],
- connections: {
- 'IF': {
- main: [
- [{ node: 'True Branch', type: 'main', index: 0 }],
- null // False branch not connected
- ]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.statistics.validConnections).toBe(1);
- expect(result.valid).toBe(true);
- });
- });
-
- describe('validateExpressions', () => {
- it('should validate expressions in node parameters', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {
- values: {
- string: [
- {
- name: 'field',
- value: '={{ $json.data }}'
- }
- ]
- }
- }
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(
- expect.objectContaining({ values: expect.any(Object) }),
- expect.objectContaining({
- availableNodes: expect.arrayContaining(['Webhook']),
- currentNodeName: 'Set',
- hasInputData: true
- })
- );
- });
-
- it('should add expression errors to result', async () => {
- vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
- valid: false,
- errors: ['Invalid expression syntax'],
- warnings: ['Deprecated variable usage'],
- usedVariables: new Set(['$json']),
- usedNodes: new Set()
- });
-
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {
- value: '={{ invalid }}'
- }
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
- expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
- expect(result.statistics.expressionsValidated).toBe(1);
- });
-
- it('should skip expression validation for disabled nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {
- value: '={{ $json.data }}'
- },
- disabled: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
- });
- });
-
- describe('checkWorkflowPatterns', () => {
- it('should suggest error handling for large workflows', async () => {
- const builder = createWorkflow('Large Workflow');
-
- // Add more than 3 nodes
- for (let i = 0; i < 5; i++) {
- builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` });
- }
-
- const workflow = builder.build() as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
- });
-
- it('should warn about long linear chains', async () => {
- const builder = createWorkflow('Linear Workflow');
-
- // Create a chain of 12 nodes
- const nodeNames: string[] = [];
- for (let i = 0; i < 12; i++) {
- const nodeName = `Node${i}`;
- builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName });
- nodeNames.push(nodeName);
- }
-
- // Connect them sequentially
- builder.connectSequentially(nodeNames);
-
- const workflow = builder.build() as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
- });
-
- it('should warn about missing credentials', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Slack',
- type: 'n8n-nodes-base.slack',
- position: [100, 100],
- parameters: {},
- credentials: {
- slackApi: {} // Missing id
- }
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true);
- });
-
- it('should warn about AI agents without tools', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
- });
-
- it('should NOT warn about AI agents WITH tools properly connected', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Calculator Tool',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Calculator Tool': {
- ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should NOT have warning about missing tools
- expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false);
- });
-
- it('should suggest community package setting for AI tools', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Tool',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Agent': {
- ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true);
- });
- });
-
- describe('checkNodeErrorHandling', () => {
- it('should error when node-level properties are inside parameters', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- typeVersion: 4,
- parameters: {
- url: 'https://api.example.com',
- onError: 'continueRegularOutput', // Wrong location!
- retryOnFail: true, // Wrong location!
- credentials: {} // Wrong location!
- }
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
- expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true);
- });
-
- it('should validate onError property values', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- onError: 'invalidValue' as any
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
- });
-
- it('should warn about deprecated continueOnFail', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- continueOnFail: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
- });
-
- it('should error for conflicting error handling properties', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- continueOnFail: true,
- onError: 'continueRegularOutput'
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
- });
-
- it('should validate retry configuration', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- retryOnFail: true,
- maxTries: 'invalid' as any,
- waitBetweenTries: -1000
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
- });
-
- it('should warn about excessive retry values', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- retryOnFail: true,
- maxTries: 15,
- waitBetweenTries: 400000
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true);
- expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true);
- });
-
- it('should warn about retryOnFail without maxTries', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- retryOnFail: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true);
- });
-
- it('should validate other node-level properties', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {},
- typeVersion: 3,
- alwaysOutputData: 'invalid' as any,
- executeOnce: 'invalid' as any,
- disabled: 'invalid' as any,
- notesInFlow: 'invalid' as any,
- notes: 123 as any
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
-
- expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true);
- });
-
- it('should warn about executeOnce', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {},
- executeOnce: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true);
- });
-
- it('should warn error-prone nodes without error handling', async () => {
- const errorProneNodes = [
- { type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 },
- { type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 },
- { type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 },
- { type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 }
- ];
-
- for (const nodeInfo of errorProneNodes) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Node',
- type: nodeInfo.type,
- position: [100, 100],
- parameters: {},
- typeVersion: nodeInfo.version
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true);
- }
- });
-
- it('should warn about conflicting error handling', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- continueOnFail: true,
- retryOnFail: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true);
- });
-
- it('should suggest alwaysOutputData for debugging', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- retryOnFail: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true);
- });
-
- it('should provide general error handling suggestions', async () => {
- const builder = createWorkflow('No Error Handling');
-
- // Add 6 nodes without error handling
- for (let i = 0; i < 6; i++) {
- builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` });
- }
-
- const workflow = builder.build() as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true);
- });
-
- it('should suggest replacing deprecated error handling', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- continueOnFail: true
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true);
- });
- });
-
- describe('generateSuggestions', () => {
- it('should suggest adding trigger for workflows without triggers', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
- });
-
- it('should provide connection examples for connection errors', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {} // Missing connections
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
- expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true);
- });
-
- it('should suggest error handling when missing', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true);
- });
-
- it('should suggest breaking up large workflows', async () => {
- const builder = createWorkflow('Large Workflow');
-
- // Add 25 nodes
- for (let i = 0; i < 25; i++) {
- builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` });
- }
-
- const workflow = builder.build() as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true);
- });
-
- it('should suggest Code node for complex expressions', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Complex',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {
- field1: '={{ $json.a }}',
- field2: '={{ $json.b }}',
- field3: '={{ $json.c }}',
- field4: '={{ $json.d }}',
- field5: '={{ $json.e }}',
- field6: '={{ $json.f }}'
- }
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true);
- });
-
- it('should suggest minimal workflow structure', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true);
- });
- });
-
- describe('findSimilarNodeTypes', () => {
- it.skip('should find similar node types for common mistakes', async () => {
- // Test that webhook without prefix gets suggestions
- const webhookWorkflow = {
- nodes: [
- {
- id: '1',
- name: 'Node',
- type: 'webhook',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const webhookResult = await validator.validateWorkflow(webhookWorkflow);
-
- // Check that we get an unknown node error with suggestions
- const unknownNodeError = webhookResult.errors.find(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownNodeError).toBeDefined();
-
- // For webhook, it should definitely suggest nodes-base.webhook
- expect(unknownNodeError?.message).toContain('nodes-base.webhook');
-
- // Test that slack without prefix gets suggestions
- const slackWorkflow = {
- nodes: [
- {
- id: '1',
- name: 'Node',
- type: 'slack',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- } as any;
-
- const slackResult = await validator.validateWorkflow(slackWorkflow);
- const slackError = slackResult.errors.find(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(slackError).toBeDefined();
- expect(slackError?.message).toContain('nodes-base.slack');
- });
- });
-
- describe('Integration Tests', () => {
- it('should validate a complex workflow with multiple issues', async () => {
- const workflow = {
- nodes: [
- // Valid trigger
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {},
- typeVersion: 2
- },
- // Node with valid alternative prefix (no longer an error)
- {
- id: '2',
- name: 'HTTP1',
- type: 'nodes-base.httpRequest', // Valid prefix (normalized internally)
- position: [300, 100],
- parameters: {}
- },
- // Node with missing typeVersion
- {
- id: '3',
- name: 'Slack',
- type: 'n8n-nodes-base.slack',
- position: [500, 100],
- parameters: {}
- },
- // Disabled node
- {
- id: '4',
- name: 'Disabled',
- type: 'n8n-nodes-base.set',
- position: [700, 100],
- parameters: {},
- disabled: true
- },
- // Node with error handling in wrong place
- {
- id: '5',
- name: 'HTTP2',
- type: 'n8n-nodes-base.httpRequest',
- position: [900, 100],
- parameters: {
- onError: 'continueRegularOutput'
- },
- typeVersion: 4
- },
- // Orphaned node
- {
- id: '6',
- name: 'Orphaned',
- type: 'n8n-nodes-base.code',
- position: [1100, 100],
- parameters: {},
- typeVersion: 2
- },
- // AI Agent without tools
- {
- id: '7',
- name: 'Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- position: [100, 300],
- parameters: {},
- typeVersion: 1
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'HTTP1', type: 'main', index: 0 }]]
- },
- 'HTTP1': {
- main: [[{ node: 'Slack', type: 'main', index: 0 }]]
- },
- 'Slack': {
- main: [[{ node: 'Disabled', type: 'main', index: 0 }]]
- },
- // Using ID instead of name
- '5': {
- main: [[{ node: 'Agent', type: 'main', index: 0 }]]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should have multiple errors (but not for the nodes-base prefix)
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid
-
- // Specific errors (removed the invalid node type error as it's no longer invalid)
- expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
- expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true);
- expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true);
-
- // Warnings
- expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true);
- expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true);
- expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
-
- // Statistics
- expect(result.statistics.totalNodes).toBe(7);
- expect(result.statistics.enabledNodes).toBe(6);
- expect(result.statistics.triggerNodes).toBe(1);
- expect(result.statistics.invalidConnections).toBeGreaterThan(0);
-
- // Suggestions
- expect(result.suggestions.length).toBeGreaterThan(0);
- });
-
- it('should validate a perfect workflow', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Manual Trigger',
- type: 'n8n-nodes-base.manualTrigger',
- position: [250, 300],
- parameters: {},
- typeVersion: 1
- },
- {
- id: '2',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [450, 300],
- parameters: {
- url: 'https://api.example.com/data',
- method: 'GET'
- },
- typeVersion: 4,
- onError: 'continueErrorOutput',
- retryOnFail: true,
- maxTries: 3,
- waitBetweenTries: 1000
- },
- {
- id: '3',
- name: 'Process Data',
- type: 'n8n-nodes-base.code',
- position: [650, 300],
- parameters: {
- jsCode: 'return items;'
- },
- typeVersion: 2
- },
- {
- id: '4',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [650, 500],
- parameters: {
- values: {
- string: [
- {
- name: 'error',
- value: 'An error occurred'
- }
- ]
- }
- },
- typeVersion: 3
- }
- ],
- connections: {
- 'Manual Trigger': {
- main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
- },
- 'HTTP Request': {
- main: [
- [{ node: 'Process Data', type: 'main', index: 0 }],
- [{ node: 'Error Handler', type: 'main', index: 0 }]
- ]
- }
- }
- } as any;
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.warnings).toHaveLength(0);
- expect(result.statistics.validConnections).toBe(3);
- expect(result.statistics.invalidConnections).toBe(0);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-connections.test.ts b/tests/unit/services/workflow-validator-connections.test.ts
index 411e55e..7d44b56 100644
--- a/tests/unit/services/workflow-validator-connections.test.ts
+++ b/tests/unit/services/workflow-validator-connections.test.ts
@@ -915,4 +915,269 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
expect(warning!.message).toContain('"unmatched" branch has no effect');
});
});
+
+ // โโโ Error Output Validation (absorbed from workflow-validator-error-outputs) โโ
+
+ describe('Error Output Configuration', () => {
+ it('should detect incorrect configuration - multiple nodes in same array', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {} },
+ { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} },
+ { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} },
+ ],
+ connections: {
+ 'Validate Input': {
+ main: [[
+ { node: 'Filter URLs', type: 'main', index: 0 },
+ { node: 'Error Response1', type: 'main', index: 0 },
+ ]],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e =>
+ e.message.includes('Incorrect error output configuration') &&
+ e.message.includes('Error Response1') &&
+ e.message.includes('appear to be error handlers but are in main[0]'),
+ )).toBe(true);
+ const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration'));
+ expect(errorMsg?.message).toContain('INCORRECT (current)');
+ expect(errorMsg?.message).toContain('CORRECT (should be)');
+ });
+
+ it('should validate correct configuration - separate arrays', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Validate Input', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [-400, 64], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '2', name: 'Filter URLs', type: 'n8n-nodes-base.filter', typeVersion: 2.2, position: [-176, 64], parameters: {} },
+ { id: '3', name: 'Error Response1', type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.5, position: [-160, 240], parameters: {} },
+ ],
+ connections: {
+ 'Validate Input': {
+ main: [
+ [{ node: 'Filter URLs', type: 'main', index: 0 }],
+ [{ node: 'Error Response1', type: 'main', index: 0 }],
+ ],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ });
+
+ it('should detect onError without error connections', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: {
+ 'HTTP Request': { main: [[{ node: 'Process Data', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e =>
+ e.nodeName === 'HTTP Request' &&
+ e.message.includes("has onError: 'continueErrorOutput' but no error output connections"),
+ )).toBe(true);
+ });
+
+ it('should warn about error connections without onError', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', typeVersion: 4, position: [100, 100], parameters: {} },
+ { id: '2', name: 'Process Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 300], parameters: {} },
+ ],
+ connections: {
+ 'HTTP Request': {
+ main: [
+ [{ node: 'Process Data', type: 'main', index: 0 }],
+ [{ node: 'Error Handler', type: 'main', index: 0 }],
+ ],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.warnings.some(w =>
+ w.nodeName === 'HTTP Request' &&
+ w.message.includes('error output connections in main[1] but missing onError'),
+ )).toBe(true);
+ });
+ });
+
+ describe('Error Handler Detection', () => {
+ it('should detect error handler nodes by name', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'API Call', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Process Success', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Handle Error', type: 'n8n-nodes-base.set', position: [300, 300], parameters: {} },
+ ],
+ connections: {
+ 'API Call': { main: [[{ node: 'Process Success', type: 'main', index: 0 }, { node: 'Handle Error', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Handle Error') && e.message.includes('appear to be error handlers'))).toBe(true);
+ });
+
+ it('should detect error handler nodes by type', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Respond', type: 'n8n-nodes-base.respondToWebhook', position: [300, 300], parameters: {} },
+ ],
+ connections: {
+ 'Webhook': { main: [[{ node: 'Process', type: 'main', index: 0 }, { node: 'Respond', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Respond') && e.message.includes('appear to be error handlers'))).toBe(true);
+ });
+
+ it('should not flag non-error nodes in main[0]', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', position: [100, 100], parameters: {} },
+ { id: '2', name: 'First Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Second Process', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} },
+ ],
+ connections: {
+ 'Start': { main: [[{ node: 'First Process', type: 'main', index: 0 }, { node: 'Second Process', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ });
+ });
+
+ describe('Complex Error Patterns', () => {
+ it('should handle multiple error handlers correctly in main[1]', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '2', name: 'Process', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Log Error', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} },
+ { id: '4', name: 'Send Error Email', type: 'n8n-nodes-base.emailSend', position: [300, 300], parameters: {} },
+ ],
+ connections: {
+ 'HTTP Request': {
+ main: [
+ [{ node: 'Process', type: 'main', index: 0 }],
+ [{ node: 'Log Error', type: 'main', index: 0 }, { node: 'Send Error Email', type: 'main', index: 0 }],
+ ],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ });
+
+ it('should detect mixed success and error handlers in main[0]', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'API Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Transform Data', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Store Data', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} },
+ { id: '4', name: 'Error Notification', type: 'n8n-nodes-base.emailSend', position: [300, 300], parameters: {} },
+ ],
+ connections: {
+ 'API Request': {
+ main: [[
+ { node: 'Transform Data', type: 'main', index: 0 },
+ { node: 'Store Data', type: 'main', index: 0 },
+ { node: 'Error Notification', type: 'main', index: 0 },
+ ]],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e =>
+ e.message.includes('Error Notification') && e.message.includes('appear to be error handlers but are in main[0]'),
+ )).toBe(true);
+ });
+
+ it('should handle nested error handling (error handlers with their own errors)', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Primary API', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '2', name: 'Success Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Error Logger', type: 'n8n-nodes-base.httpRequest', position: [300, 200], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '4', name: 'Fallback Error', type: 'n8n-nodes-base.set', position: [500, 250], parameters: {} },
+ ],
+ connections: {
+ 'Primary API': { main: [[{ node: 'Success Handler', type: 'main', index: 0 }], [{ node: 'Error Logger', type: 'main', index: 0 }]] },
+ 'Error Logger': { main: [[], [{ node: 'Fallback Error', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ });
+
+ it('should handle workflows with only error outputs (no success path)', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Risky Operation', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'continueErrorOutput' },
+ { id: '2', name: 'Error Handler Only', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} },
+ ],
+ connections: {
+ 'Risky Operation': { main: [[], [{ node: 'Error Handler Only', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ expect(result.errors.some(e => e.message.includes("has onError: 'continueErrorOutput' but no error output connections"))).toBe(false);
+ });
+
+ it('should not flag legitimate parallel processing nodes', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Data Source', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Process A', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {} },
+ { id: '3', name: 'Process B', type: 'n8n-nodes-base.set', position: [300, 150], parameters: {} },
+ { id: '4', name: 'Transform Data', type: 'n8n-nodes-base.set', position: [300, 250], parameters: {} },
+ ],
+ connections: {
+ 'Data Source': { main: [[{ node: 'Process A', type: 'main', index: 0 }, { node: 'Process B', type: 'main', index: 0 }, { node: 'Transform Data', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
+ });
+
+ it('should detect all variations of error-related node names', async () => {
+ const workflow = {
+ nodes: [
+ { id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Handle Failure', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ { id: '3', name: 'Catch Exception', type: 'n8n-nodes-base.set', position: [300, 200], parameters: {} },
+ { id: '4', name: 'Success Path', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} },
+ ],
+ connections: {
+ 'Source': { main: [[{ node: 'Handle Failure', type: 'main', index: 0 }, { node: 'Catch Exception', type: 'main', index: 0 }, { node: 'Success Path', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.errors.some(e =>
+ e.message.includes('Handle Failure') && e.message.includes('Catch Exception') && e.message.includes('appear to be error handlers but are in main[0]'),
+ )).toBe(true);
+ });
+ });
});
diff --git a/tests/unit/services/workflow-validator-edge-cases.test.ts b/tests/unit/services/workflow-validator-edge-cases.test.ts
deleted file mode 100644
index c897344..0000000
--- a/tests/unit/services/workflow-validator-edge-cases.test.ts
+++ /dev/null
@@ -1,576 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-import type { WorkflowValidationResult } from '@/services/workflow-validator';
-
-// NOTE: Mocking EnhancedConfigValidator is challenging because:
-// 1. WorkflowValidator expects the class itself, not an instance
-// 2. The class has static methods that are called directly
-// 3. vi.mock() hoisting makes it difficult to mock properly
-//
-// For properly mocked tests, see workflow-validator-with-mocks.test.ts
-// These tests use a partially mocked approach that may still access the database
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/expression-validator');
-vi.mock('@/utils/logger');
-
-// Mock EnhancedConfigValidator with static methods
-vi.mock('@/services/enhanced-config-validator', () => ({
- EnhancedConfigValidator: {
- validate: vi.fn().mockReturnValue({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: [],
- visibleProperties: [],
- hiddenProperties: []
- }),
- validateWithMode: vi.fn().mockReturnValue({
- valid: true,
- errors: [],
- warnings: [],
- fixedConfig: null
- })
- }
-}));
-
-describe('WorkflowValidator - Edge Cases', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
- let mockEnhancedConfigValidator: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create mock repository that returns node info for test nodes and common n8n nodes
- mockNodeRepository = {
- getNode: vi.fn().mockImplementation((type: string) => {
- if (type === 'test.node' || type === 'test.agent' || type === 'test.tool') {
- return {
- name: 'Test Node',
- type: type,
- typeVersion: 1,
- properties: [],
- package: 'test-package',
- version: 1,
- displayName: 'Test Node',
- isVersioned: false
- };
- }
- // Handle common n8n node types
- if (type.startsWith('n8n-nodes-base.') || type.startsWith('nodes-base.')) {
- const nodeName = type.split('.')[1];
- return {
- name: nodeName,
- type: type,
- typeVersion: 1,
- properties: [],
- package: 'n8n-nodes-base',
- version: 1,
- displayName: nodeName.charAt(0).toUpperCase() + nodeName.slice(1),
- isVersioned: ['set', 'httpRequest'].includes(nodeName)
- };
- }
- return null;
- }),
- findByType: vi.fn().mockReturnValue({
- name: 'Test Node',
- type: 'test.node',
- typeVersion: 1,
- properties: []
- }),
- searchNodes: vi.fn().mockReturnValue([])
- };
-
- // Ensure EnhancedConfigValidator.validate always returns a valid result
- vi.mocked(EnhancedConfigValidator.validate).mockReturnValue({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: [],
- visibleProperties: [],
- hiddenProperties: []
- });
-
- // Create validator instance with mocked dependencies
- validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
- });
-
- describe('Null and Undefined Handling', () => {
- it('should handle null workflow gracefully', async () => {
- const result = await validator.validateWorkflow(null as any);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
- });
-
- it('should handle undefined workflow gracefully', async () => {
- const result = await validator.validateWorkflow(undefined as any);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
- });
-
- it('should handle workflow with null nodes array', async () => {
- const workflow = {
- nodes: null,
- connections: {}
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true);
- });
-
- it('should handle workflow with null connections', async () => {
- const workflow = {
- nodes: [],
- connections: null
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true);
- });
-
- it('should handle nodes with null/undefined properties', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: null,
- type: 'test.node',
- position: [0, 0],
- parameters: undefined
- }
- ],
- connections: {}
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
- });
-
- describe('Boundary Value Testing', () => {
- it('should handle empty workflow', async () => {
- const workflow = {
- nodes: [],
- connections: {}
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(true);
- expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true);
- });
-
- it('should handle very large workflows', async () => {
- const nodes = Array(1000).fill(null).map((_, i) => ({
- id: `node${i}`,
- name: `Node ${i}`,
- type: 'test.node',
- position: [i * 100, 0] as [number, number],
- parameters: {}
- }));
-
- const connections: any = {};
- for (let i = 0; i < 999; i++) {
- connections[`Node ${i}`] = {
- main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]]
- };
- }
-
- const workflow = { nodes, connections };
-
- const start = Date.now();
- const result = await validator.validateWorkflow(workflow as any);
- const duration = Date.now() - start;
-
- expect(result).toBeDefined();
- // Use longer timeout for CI environments
- const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
- const timeout = isCI ? 10000 : 5000; // 10 seconds for CI, 5 seconds for local
- expect(duration).toBeLessThan(timeout);
- });
-
- it('should handle deeply nested connections', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Start', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Middle', type: 'test.node', position: [100, 0] as [number, number], parameters: {} },
- { id: '3', name: 'End', type: 'test.node', position: [200, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Start': {
- main: [[{ node: 'Middle', type: 'main', index: 0 }]],
- error: [[{ node: 'End', type: 'main', index: 0 }]],
- ai_tool: [[{ node: 'Middle', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.statistics.invalidConnections).toBe(0);
- });
-
- it.skip('should handle nodes at extreme positions - FIXME: mock issues', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'FarLeft', type: 'n8n-nodes-base.set', position: [-999999, -999999] as [number, number], parameters: {} },
- { id: '2', name: 'FarRight', type: 'n8n-nodes-base.set', position: [999999, 999999] as [number, number], parameters: {} },
- { id: '3', name: 'Zero', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'FarLeft': {
- main: [[{ node: 'FarRight', type: 'main', index: 0 }]]
- },
- 'FarRight': {
- main: [[{ node: 'Zero', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(true);
- });
- });
-
- describe('Invalid Data Type Handling', () => {
- it('should handle non-array nodes', async () => {
- const workflow = {
- nodes: 'not-an-array',
- connections: {}
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(false);
- expect(result.errors[0].message).toContain('nodes must be an array');
- });
-
- it('should handle non-object connections', async () => {
- const workflow = {
- nodes: [],
- connections: []
- };
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(false);
- expect(result.errors[0].message).toContain('connections must be an object');
- });
-
- it('should handle invalid position values', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'InvalidPos', type: 'test.node', position: 'invalid' as any, parameters: {} },
- { id: '2', name: 'NaNPos', type: 'test.node', position: [NaN, NaN] as [number, number], parameters: {} },
- { id: '3', name: 'InfinityPos', type: 'test.node', position: [Infinity, -Infinity] as [number, number], parameters: {} }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('should handle circular references in workflow object', async () => {
- const workflow: any = {
- nodes: [],
- connections: {}
- };
- workflow.circular = workflow;
-
- await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined();
- });
- });
-
- describe('Connection Validation Edge Cases', () => {
- it('should detect self-referencing nodes', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'SelfLoop', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'SelfLoop': {
- main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true);
- });
-
- it('should handle non-existent node references', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true);
- });
-
- it('should handle invalid connection formats', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: 'invalid-format' as any
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('should handle missing connection properties', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'Node2' }]] // Missing type and index
- }
- } as any
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- // Should still work as type and index can have defaults
- expect(result.statistics.validConnections).toBeGreaterThan(0);
- });
-
- it('should handle negative output indices', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'Node2', type: 'main', index: -1 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true);
- });
- });
-
- describe('Special Characters and Unicode', () => {
- // Note: These tests are skipped because WorkflowValidator also needs special character
- // normalization (similar to WorkflowDiffEngine fix in #270). Will be addressed in a future PR.
- it.skip('should handle apostrophes in node names - TODO: needs WorkflowValidator normalization', async () => {
- // Test default n8n Manual Trigger node name with apostrophes
- const workflow = {
- nodes: [
- { id: '1', name: "When clicking 'Execute workflow'", type: 'n8n-nodes-base.manualTrigger', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- "When clicking 'Execute workflow'": {
- main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it.skip('should handle special characters in node names - TODO: needs WorkflowValidator normalization', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Node ไธญๆ', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} },
- { id: '3', name: 'Node๐', type: 'n8n-nodes-base.set', position: [200, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node@#$%': {
- main: [[{ node: 'Node ไธญๆ', type: 'main', index: 0 }]]
- },
- 'Node ไธญๆ': {
- main: [[{ node: 'Node๐', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it('should handle very long node names', async () => {
- const longName = 'A'.repeat(1000);
- const workflow = {
- nodes: [
- { id: '1', name: longName, type: 'test.node', position: [0, 0] as [number, number], parameters: {} }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true);
- });
- });
-
- describe('Batch Validation', () => {
- it.skip('should handle batch validation with mixed valid/invalid workflows - FIXME: mock issues', async () => {
- const workflows = [
- {
- nodes: [
- { id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'Node2', type: 'main', index: 0 }]]
- }
- }
- },
- null as any,
- {
- nodes: 'invalid' as any,
- connections: {}
- }
- ];
-
- const promises = workflows.map(w => validator.validateWorkflow(w));
- const results = await Promise.all(promises);
-
- expect(results[0].valid).toBe(true);
- expect(results[1].valid).toBe(false);
- expect(results[2].valid).toBe(false);
- });
-
- it.skip('should handle concurrent validation requests - FIXME: mock issues', async () => {
- const workflow = {
- nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.webhook', position: [0, 0] as [number, number], parameters: {} }],
- connections: {}
- };
-
- const promises = Array(10).fill(null).map(() => validator.validateWorkflow(workflow));
- const results = await Promise.all(promises);
-
- expect(results.every(r => r.valid)).toBe(true);
- });
- });
-
- describe('Expression Validation Edge Cases', () => {
- it('should skip expression validation when option is false', async () => {
- const workflow = {
- nodes: [{
- id: '1',
- name: 'Node1',
- type: 'test.node',
- position: [0, 0] as [number, number],
- parameters: {
- value: '{{ $json.invalid.expression }}'
- }
- }],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow, {
- validateExpressions: false
- });
-
- expect(result.statistics.expressionsValidated).toBe(0);
- });
- });
-
- describe('Connection Type Validation', () => {
- it('should validate different connection types', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Agent', type: 'test.agent', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Tool', type: 'test.tool', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Tool': {
- ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.statistics.validConnections).toBeGreaterThan(0);
- });
- });
-
- describe('Error Recovery', () => {
- it('should continue validation after encountering errors', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: null as any, type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Valid', type: 'test.node', position: [100, 0] as [number, number], parameters: {} },
- { id: '3', name: 'AlsoValid', type: 'test.node', position: [200, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Valid': {
- main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
- expect(result.errors.length).toBeGreaterThan(0);
- expect(result.statistics.validConnections).toBeGreaterThan(0);
- });
- });
-
- describe('Static Method Alternatives', () => {
- it('should validate workflow connections only', async () => {
- const workflow = {
- nodes: [
- { id: '1', name: 'Node1', type: 'test.node', position: [0, 0] as [number, number], parameters: {} },
- { id: '2', name: 'Node2', type: 'test.node', position: [100, 0] as [number, number], parameters: {} }
- ],
- connections: {
- 'Node1': {
- main: [[{ node: 'Node2', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow, {
- validateNodes: false,
- validateExpressions: false,
- validateConnections: true
- });
-
- expect(result.statistics.validConnections).toBe(1);
- });
-
- it('should validate workflow expressions only', async () => {
- const workflow = {
- nodes: [{
- id: '1',
- name: 'Node1',
- type: 'test.node',
- position: [0, 0] as [number, number],
- parameters: {
- value: '{{ $json.data }}'
- }
- }],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow, {
- validateNodes: false,
- validateExpressions: true,
- validateConnections: false
- });
-
- expect(result.statistics.expressionsValidated).toBeGreaterThan(0);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-error-outputs.test.ts b/tests/unit/services/workflow-validator-error-outputs.test.ts
deleted file mode 100644
index 335db60..0000000
--- a/tests/unit/services/workflow-validator-error-outputs.test.ts
+++ /dev/null
@@ -1,793 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - Error Output Validation', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create mock repository
- mockNodeRepository = {
- getNode: vi.fn((type: string) => {
- // Return mock node info for common node types
- if (type.includes('httpRequest') || type.includes('webhook') || type.includes('set')) {
- return {
- node_type: type,
- display_name: 'Mock Node',
- isVersioned: true,
- version: 1
- };
- }
- return null;
- })
- };
-
- validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
- });
-
- describe('Error Output Configuration', () => {
- it('should detect incorrect configuration - multiple nodes in same array', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Validate Input',
- type: 'n8n-nodes-base.set',
- typeVersion: 3.4,
- position: [-400, 64],
- parameters: {}
- },
- {
- id: '2',
- name: 'Filter URLs',
- type: 'n8n-nodes-base.filter',
- typeVersion: 2.2,
- position: [-176, 64],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Response1',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1.5,
- position: [-160, 240],
- parameters: {}
- }
- ],
- connections: {
- 'Validate Input': {
- main: [
- [
- { node: 'Filter URLs', type: 'main', index: 0 },
- { node: 'Error Response1', type: 'main', index: 0 } // WRONG! Both in main[0]
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.valid).toBe(false);
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration') &&
- e.message.includes('Error Response1') &&
- e.message.includes('appear to be error handlers but are in main[0]')
- )).toBe(true);
-
- // Check that the error message includes the fix
- const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration'));
- expect(errorMsg?.message).toContain('INCORRECT (current)');
- expect(errorMsg?.message).toContain('CORRECT (should be)');
- expect(errorMsg?.message).toContain('main[1] = error output');
- });
-
- it('should validate correct configuration - separate arrays', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Validate Input',
- type: 'n8n-nodes-base.set',
- typeVersion: 3.4,
- position: [-400, 64],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '2',
- name: 'Filter URLs',
- type: 'n8n-nodes-base.filter',
- typeVersion: 2.2,
- position: [-176, 64],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Response1',
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1.5,
- position: [-160, 240],
- parameters: {}
- }
- ],
- connections: {
- 'Validate Input': {
- main: [
- [
- { node: 'Filter URLs', type: 'main', index: 0 }
- ],
- [
- { node: 'Error Response1', type: 'main', index: 0 } // Correctly in main[1]
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not have the specific error about incorrect configuration
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
-
- it('should detect onError without error connections', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 4,
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput' // Has onError
- },
- {
- id: '2',
- name: 'Process Data',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request': {
- main: [
- [
- { node: 'Process Data', type: 'main', index: 0 }
- ]
- // No main[1] for error output
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e =>
- e.nodeName === 'HTTP Request' &&
- e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
- )).toBe(true);
- });
-
- it('should warn about error connections without onError', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 4,
- position: [100, 100],
- parameters: {}
- // Missing onError property
- },
- {
- id: '2',
- name: 'Process Data',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 300],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request': {
- main: [
- [
- { node: 'Process Data', type: 'main', index: 0 }
- ],
- [
- { node: 'Error Handler', type: 'main', index: 0 } // Has error connection
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.warnings.some(w =>
- w.nodeName === 'HTTP Request' &&
- w.message.includes('error output connections in main[1] but missing onError')
- )).toBe(true);
- });
- });
-
- describe('Error Handler Detection', () => {
- it('should detect error handler nodes by name', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'API Call',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Process Success',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Handle Error', // Contains 'error'
- type: 'n8n-nodes-base.set',
- position: [300, 300],
- parameters: {}
- }
- ],
- connections: {
- 'API Call': {
- main: [
- [
- { node: 'Process Success', type: 'main', index: 0 },
- { node: 'Handle Error', type: 'main', index: 0 } // Wrong placement
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e =>
- e.message.includes('Handle Error') &&
- e.message.includes('appear to be error handlers')
- )).toBe(true);
- });
-
- it('should detect error handler nodes by type', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Process',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Respond',
- type: 'n8n-nodes-base.respondToWebhook', // Common error handler type
- position: [300, 300],
- parameters: {}
- }
- ],
- connections: {
- 'Webhook': {
- main: [
- [
- { node: 'Process', type: 'main', index: 0 },
- { node: 'Respond', type: 'main', index: 0 } // Wrong placement
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e =>
- e.message.includes('Respond') &&
- e.message.includes('appear to be error handlers')
- )).toBe(true);
- });
-
- it('should not flag non-error nodes in main[0]', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Start',
- type: 'n8n-nodes-base.manualTrigger',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'First Process',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Second Process',
- type: 'n8n-nodes-base.set',
- position: [300, 200],
- parameters: {}
- }
- ],
- connections: {
- 'Start': {
- main: [
- [
- { node: 'First Process', type: 'main', index: 0 },
- { node: 'Second Process', type: 'main', index: 0 } // Both are valid success paths
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not have error about incorrect error configuration
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
- });
-
- describe('Complex Error Patterns', () => {
- it('should handle multiple error handlers correctly', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '2',
- name: 'Process',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Log Error',
- type: 'n8n-nodes-base.set',
- position: [300, 200],
- parameters: {}
- },
- {
- id: '4',
- name: 'Send Error Email',
- type: 'n8n-nodes-base.emailSend',
- position: [300, 300],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request': {
- main: [
- [
- { node: 'Process', type: 'main', index: 0 }
- ],
- [
- { node: 'Log Error', type: 'main', index: 0 },
- { node: 'Send Error Email', type: 'main', index: 0 } // Multiple error handlers OK in main[1]
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not have errors about the configuration
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
-
- it('should detect mixed success and error handlers in main[0]', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'API Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Transform Data',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Store Data',
- type: 'n8n-nodes-base.set',
- position: [500, 100],
- parameters: {}
- },
- {
- id: '4',
- name: 'Error Notification',
- type: 'n8n-nodes-base.emailSend',
- position: [300, 300],
- parameters: {}
- }
- ],
- connections: {
- 'API Request': {
- main: [
- [
- { node: 'Transform Data', type: 'main', index: 0 },
- { node: 'Store Data', type: 'main', index: 0 },
- { node: 'Error Notification', type: 'main', index: 0 } // Error handler mixed with success nodes
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- expect(result.errors.some(e =>
- e.message.includes('Error Notification') &&
- e.message.includes('appear to be error handlers but are in main[0]')
- )).toBe(true);
- });
-
- it('should handle nested error handling (error handlers with their own errors)', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Primary API',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '2',
- name: 'Success Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Logger',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 200],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '4',
- name: 'Fallback Error',
- type: 'n8n-nodes-base.set',
- position: [500, 250],
- parameters: {}
- }
- ],
- connections: {
- 'Primary API': {
- main: [
- [
- { node: 'Success Handler', type: 'main', index: 0 }
- ],
- [
- { node: 'Error Logger', type: 'main', index: 0 }
- ]
- ]
- },
- 'Error Logger': {
- main: [
- [],
- [
- { node: 'Fallback Error', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not have errors about incorrect configuration
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
- });
-
- describe('Edge Cases', () => {
- it('should handle workflows with no connections at all', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Isolated Node',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should have warning about orphaned node but not error about connections
- expect(result.warnings.some(w =>
- w.nodeName === 'Isolated Node' &&
- w.message.includes('not connected to any other nodes')
- )).toBe(true);
-
- // Should not have error about error output configuration
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
-
- it('should handle nodes with empty main arrays', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '2',
- name: 'Target Node',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source Node': {
- main: [
- [], // Empty success array
- [] // Empty error array
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should detect that onError is set but no error connections exist
- expect(result.errors.some(e =>
- e.nodeName === 'Source Node' &&
- e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
- )).toBe(true);
- });
-
- it('should handle workflows with only error outputs (no success path)', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Risky Operation',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- },
- {
- id: '2',
- name: 'Error Handler Only',
- type: 'n8n-nodes-base.set',
- position: [300, 200],
- parameters: {}
- }
- ],
- connections: {
- 'Risky Operation': {
- main: [
- [], // No success connections
- [
- { node: 'Error Handler Only', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not have errors about incorrect configuration - this is valid
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
-
- // Should not have errors about missing error connections
- expect(result.errors.some(e =>
- e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
- )).toBe(false);
- });
-
- it('should handle undefined or null connection arrays gracefully', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source Node': {
- main: [
- null, // Null array
- undefined // Undefined array
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not crash and should not have configuration errors
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
-
- it('should detect all variations of error-related node names', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Handle Failure',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- },
- {
- id: '3',
- name: 'Catch Exception',
- type: 'n8n-nodes-base.set',
- position: [300, 200],
- parameters: {}
- },
- {
- id: '4',
- name: 'Success Path',
- type: 'n8n-nodes-base.set',
- position: [500, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'Handle Failure', type: 'main', index: 0 },
- { node: 'Catch Exception', type: 'main', index: 0 },
- { node: 'Success Path', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should detect both 'Handle Failure' and 'Catch Exception' as error handlers
- expect(result.errors.some(e =>
- e.message.includes('Handle Failure') &&
- e.message.includes('Catch Exception') &&
- e.message.includes('appear to be error handlers but are in main[0]')
- )).toBe(true);
- });
-
- it('should not flag legitimate parallel processing nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Data Source',
- type: 'n8n-nodes-base.webhook',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Process A',
- type: 'n8n-nodes-base.set',
- position: [300, 50],
- parameters: {}
- },
- {
- id: '3',
- name: 'Process B',
- type: 'n8n-nodes-base.set',
- position: [300, 150],
- parameters: {}
- },
- {
- id: '4',
- name: 'Transform Data',
- type: 'n8n-nodes-base.set',
- position: [300, 250],
- parameters: {}
- }
- ],
- connections: {
- 'Data Source': {
- main: [
- [
- { node: 'Process A', type: 'main', index: 0 },
- { node: 'Process B', type: 'main', index: 0 },
- { node: 'Transform Data', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not flag these as error configuration issues
- expect(result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- )).toBe(false);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-expression-format.test.ts b/tests/unit/services/workflow-validator-expression-format.test.ts
deleted file mode 100644
index 38328df..0000000
--- a/tests/unit/services/workflow-validator-expression-format.test.ts
+++ /dev/null
@@ -1,488 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '../../../src/services/workflow-validator';
-import { NodeRepository } from '../../../src/database/node-repository';
-import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
-
-// Mock the database
-vi.mock('../../../src/database/node-repository');
-
-describe('WorkflowValidator - Expression Format Validation', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
-
- beforeEach(() => {
- // Create mock repository
- mockNodeRepository = {
- findNodeByType: vi.fn().mockImplementation((type: string) => {
- // Return mock nodes for common types
- if (type === 'n8n-nodes-base.emailSend') {
- return {
- node_type: 'n8n-nodes-base.emailSend',
- display_name: 'Email Send',
- properties: {},
- version: 2.1
- };
- }
- if (type === 'n8n-nodes-base.github') {
- return {
- node_type: 'n8n-nodes-base.github',
- display_name: 'GitHub',
- properties: {},
- version: 1.1
- };
- }
- if (type === 'n8n-nodes-base.webhook') {
- return {
- node_type: 'n8n-nodes-base.webhook',
- display_name: 'Webhook',
- properties: {},
- version: 1
- };
- }
- if (type === 'n8n-nodes-base.httpRequest') {
- return {
- node_type: 'n8n-nodes-base.httpRequest',
- display_name: 'HTTP Request',
- properties: {},
- version: 4
- };
- }
- return null;
- }),
- searchNodes: vi.fn().mockReturnValue([]),
- getAllNodes: vi.fn().mockReturnValue([]),
- close: vi.fn()
- };
-
- validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
- });
-
- describe('Expression Format Detection', () => {
- it('should detect missing = prefix in simple expressions', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Send Email',
- type: 'n8n-nodes-base.emailSend',
- position: [0, 0] as [number, number],
- parameters: {
- fromEmail: '{{ $env.SENDER_EMAIL }}',
- toEmail: 'user@example.com',
- subject: 'Test Email'
- },
- typeVersion: 2.1
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- expect(result.valid).toBe(false);
-
- // Find expression format errors
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format error'));
- expect(formatErrors).toHaveLength(1);
-
- const error = formatErrors[0];
- expect(error.message).toContain('Expression format error');
- expect(error.message).toContain('fromEmail');
- expect(error.message).toContain('{{ $env.SENDER_EMAIL }}');
- expect(error.message).toContain('={{ $env.SENDER_EMAIL }}');
- });
-
- it('should detect missing resource locator format for GitHub fields', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'GitHub',
- type: 'n8n-nodes-base.github',
- position: [0, 0] as [number, number],
- parameters: {
- operation: 'createComment',
- owner: '{{ $vars.GITHUB_OWNER }}',
- repository: '{{ $vars.GITHUB_REPO }}',
- issueNumber: 123,
- body: 'Test comment'
- },
- typeVersion: 1.1
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- expect(result.valid).toBe(false);
- // Should have errors for both owner and repository
- const ownerError = result.errors.find(e => e.message.includes('owner'));
- const repoError = result.errors.find(e => e.message.includes('repository'));
-
- expect(ownerError).toBeTruthy();
- expect(repoError).toBeTruthy();
- expect(ownerError?.message).toContain('resource locator format');
- expect(ownerError?.message).toContain('__rl');
- });
-
- it('should detect mixed content without prefix', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0] as [number, number],
- parameters: {
- url: 'https://api.example.com/{{ $json.endpoint }}',
- headers: {
- Authorization: 'Bearer {{ $env.API_TOKEN }}'
- }
- },
- typeVersion: 4
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- expect(result.valid).toBe(false);
- const errors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(errors.length).toBeGreaterThan(0);
-
- // Check for URL error
- const urlError = errors.find(e => e.message.includes('url'));
- expect(urlError).toBeTruthy();
- expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}');
- });
-
- it('should accept properly formatted expressions', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Send Email',
- type: 'n8n-nodes-base.emailSend',
- position: [0, 0] as [number, number],
- parameters: {
- fromEmail: '={{ $env.SENDER_EMAIL }}',
- toEmail: 'user@example.com',
- subject: '=Test {{ $json.type }}'
- },
- typeVersion: 2.1
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have no expression format errors
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(formatErrors).toHaveLength(0);
- });
-
- it('should accept resource locator format', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'GitHub',
- type: 'n8n-nodes-base.github',
- position: [0, 0] as [number, number],
- parameters: {
- operation: 'createComment',
- owner: {
- __rl: true,
- value: '={{ $vars.GITHUB_OWNER }}',
- mode: 'expression'
- },
- repository: {
- __rl: true,
- value: '={{ $vars.GITHUB_REPO }}',
- mode: 'expression'
- },
- issueNumber: 123,
- body: '=Test comment from {{ $json.author }}'
- },
- typeVersion: 1.1
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have no expression format errors
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(formatErrors).toHaveLength(0);
- });
-
- it('should validate nested expressions in complex parameters', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0] as [number, number],
- parameters: {
- method: 'POST',
- url: 'https://api.example.com',
- sendBody: true,
- bodyParameters: {
- parameters: [
- {
- name: 'userId',
- value: '{{ $json.id }}'
- },
- {
- name: 'timestamp',
- value: '={{ $now }}'
- }
- ]
- }
- },
- typeVersion: 4
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should detect the missing prefix in nested parameter
- const errors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(errors.length).toBeGreaterThan(0);
-
- const nestedError = errors.find(e => e.message.includes('bodyParameters'));
- expect(nestedError).toBeTruthy();
- });
-
- it('should warn about RL format even with prefix', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'GitHub',
- type: 'n8n-nodes-base.github',
- position: [0, 0] as [number, number],
- parameters: {
- operation: 'createComment',
- owner: '={{ $vars.GITHUB_OWNER }}',
- repository: '={{ $vars.GITHUB_REPO }}',
- issueNumber: 123,
- body: 'Test'
- },
- typeVersion: 1.1
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have warnings about using RL format
- const warnings = result.warnings.filter(w => w.message.includes('resource locator format'));
- expect(warnings.length).toBeGreaterThan(0);
- });
- });
-
- describe('Real-world workflow examples', () => {
- it.skip('should validate Email workflow with expression issues', async () => {
- const workflow = {
- name: 'Error Notification Workflow',
- nodes: [
- {
- id: 'webhook-1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [250, 300] as [number, number],
- parameters: {
- path: 'error-handler',
- httpMethod: 'POST'
- },
- typeVersion: 1
- },
- {
- id: 'email-1',
- name: 'Error Handler',
- type: 'n8n-nodes-base.emailSend',
- position: [450, 300] as [number, number],
- parameters: {
- fromEmail: '{{ $env.ADMIN_EMAIL }}',
- toEmail: 'admin@company.com',
- subject: 'Error in {{ $json.workflow }}',
- message: 'An error occurred: {{ $json.error }}',
- options: {
- replyTo: '={{ $env.SUPPORT_EMAIL }}'
- }
- },
- typeVersion: 2.1
- }
- ],
- connections: {
- 'Webhook': {
- main: [[{ node: 'Error Handler', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have multiple expression format errors
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(formatErrors.length).toBeGreaterThanOrEqual(3); // fromEmail, subject, message
-
- // Check specific errors
- const fromEmailError = formatErrors.find(e => e.message.includes('fromEmail'));
- expect(fromEmailError).toBeTruthy();
- expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}');
- });
-
- it.skip('should validate GitHub workflow with resource locator issues', async () => {
- const workflow = {
- name: 'GitHub Issue Handler',
- nodes: [
- {
- id: 'webhook-1',
- name: 'Issue Webhook',
- type: 'n8n-nodes-base.webhook',
- position: [250, 300] as [number, number],
- parameters: {
- path: 'github-issue',
- httpMethod: 'POST'
- },
- typeVersion: 1
- },
- {
- id: 'github-1',
- name: 'Create Comment',
- type: 'n8n-nodes-base.github',
- position: [450, 300] as [number, number],
- parameters: {
- operation: 'createComment',
- owner: '{{ $vars.GITHUB_OWNER }}',
- repository: '{{ $vars.GITHUB_REPO }}',
- issueNumber: '={{ $json.body.issue.number }}',
- body: 'Thanks for the issue @{{ $json.body.issue.user.login }}!'
- },
- typeVersion: 1.1
- }
- ],
- connections: {
- 'Issue Webhook': {
- main: [[{ node: 'Create Comment', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have errors for owner, repository, and body
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(formatErrors.length).toBeGreaterThanOrEqual(3);
-
- // Check for resource locator suggestions
- const ownerError = formatErrors.find(e => e.message.includes('owner'));
- expect(ownerError?.message).toContain('__rl');
- expect(ownerError?.message).toContain('resource locator format');
- });
-
- it('should provide clear fix examples in error messages', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Process Data',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0] as [number, number],
- parameters: {
- url: 'https://api.example.com/users/{{ $json.userId }}'
- },
- typeVersion: 4
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const error = result.errors.find(e => e.message.includes('Expression format'));
- expect(error).toBeTruthy();
-
- // Error message should contain both incorrect and correct examples
- expect(error?.message).toContain('Current (incorrect):');
- expect(error?.message).toContain('"url": "https://api.example.com/users/{{ $json.userId }}"');
- expect(error?.message).toContain('Fixed (correct):');
- expect(error?.message).toContain('"url": "=https://api.example.com/users/{{ $json.userId }}"');
- });
- });
-
- describe('Integration with other validations', () => {
- it('should validate expression format alongside syntax', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Test Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0] as [number, number],
- parameters: {
- url: '{{ $json.url', // Syntax error: unclosed expression
- headers: {
- 'X-Token': '{{ $env.TOKEN }}' // Format error: missing prefix
- }
- },
- typeVersion: 4
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have both syntax and format errors
- const syntaxErrors = result.errors.filter(e => e.message.includes('Unmatched expression brackets'));
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
-
- expect(syntaxErrors.length).toBeGreaterThan(0);
- expect(formatErrors.length).toBeGreaterThan(0);
- });
-
- it('should not interfere with node validation', async () => {
- // Test that expression format validation works alongside other validations
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0] as [number, number],
- parameters: {
- url: '{{ $json.endpoint }}', // Expression format error
- headers: {
- Authorization: '={{ $env.TOKEN }}' // Correct format
- }
- },
- typeVersion: 4
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have expression format error for url field
- const formatErrors = result.errors.filter(e => e.message.includes('Expression format'));
- expect(formatErrors).toHaveLength(1);
- expect(formatErrors[0].message).toContain('url');
-
- // The workflow should still have structure validation (no trigger warning, etc)
- // This proves that expression validation doesn't interfere with other checks
- expect(result.warnings.some(w => w.message.includes('trigger'))).toBe(true);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-loops-simple.test.ts b/tests/unit/services/workflow-validator-loops-simple.test.ts
deleted file mode 100644
index a9c4339..0000000
--- a/tests/unit/services/workflow-validator-loops-simple.test.ts
+++ /dev/null
@@ -1,434 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/enhanced-config-validator');
-
-describe('WorkflowValidator - SplitInBatches Validation (Simplified)', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
- let mockNodeValidator: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- mockNodeRepository = {
- getNode: vi.fn()
- };
-
- mockNodeValidator = {
- validateWithMode: vi.fn().mockReturnValue({
- errors: [],
- warnings: []
- })
- };
-
- validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator);
- });
-
- describe('SplitInBatches node detection', () => {
- it('should identify SplitInBatches nodes in workflow', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const workflow = {
- name: 'SplitInBatches Workflow',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: { batchSize: 10 }
- },
- {
- id: '2',
- name: 'Process Item',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [], // Done output (0)
- [{ node: 'Process Item', type: 'main', index: 0 }] // Loop output (1)
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should complete validation without crashing
- expect(result).toBeDefined();
- expect(result.valid).toBeDefined();
- });
-
- it('should handle SplitInBatches with processing node name patterns', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const processingNames = [
- 'Process Item',
- 'Transform Data',
- 'Handle Each',
- 'Function Node',
- 'Code Block'
- ];
-
- for (const nodeName of processingNames) {
- const workflow = {
- name: 'Processing Pattern Test',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: nodeName,
- type: 'n8n-nodes-base.function',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: nodeName, type: 'main', index: 0 }], // Processing node on Done output
- []
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should identify potential processing nodes
- expect(result).toBeDefined();
- }
- });
-
- it('should handle final processing node patterns', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const finalNames = [
- 'Final Summary',
- 'Send Email',
- 'Complete Notification',
- 'Final Report'
- ];
-
- for (const nodeName of finalNames) {
- const workflow = {
- name: 'Final Pattern Test',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: nodeName,
- type: 'n8n-nodes-base.emailSend',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: nodeName, type: 'main', index: 0 }], // Final node on Done output (correct)
- []
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not warn about final nodes on done output
- expect(result).toBeDefined();
- }
- });
- });
-
- describe('Connection validation', () => {
- it('should validate connection indices', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const workflow = {
- name: 'Connection Index Test',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'Target',
- type: 'n8n-nodes-base.set',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'Target', type: 'main', index: -1 }] // Invalid negative index
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const negativeIndexErrors = result.errors.filter(e =>
- e.message?.includes('Invalid connection index -1')
- );
- expect(negativeIndexErrors.length).toBeGreaterThan(0);
- });
-
- it('should handle non-existent target nodes', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const workflow = {
- name: 'Missing Target Test',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [{ node: 'NonExistentNode', type: 'main', index: 0 }]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const missingNodeErrors = result.errors.filter(e =>
- e.message?.includes('non-existent node')
- );
- expect(missingNodeErrors.length).toBeGreaterThan(0);
- });
- });
-
- describe('Self-referencing connections', () => {
- it('should allow self-referencing for SplitInBatches nodes', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.splitInBatches',
- properties: []
- });
-
- const workflow = {
- name: 'Self Reference Test',
- nodes: [
- {
- id: '1',
- name: 'Split In Batches',
- type: 'n8n-nodes-base.splitInBatches',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Split In Batches': {
- main: [
- [],
- [{ node: 'Split In Batches', type: 'main', index: 0 }] // Self-reference on loop output
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should not warn about self-reference for SplitInBatches
- const selfRefWarnings = result.warnings.filter(w =>
- w.message?.includes('self-referencing')
- );
- expect(selfRefWarnings).toHaveLength(0);
- });
-
- it('should warn about self-referencing for non-loop nodes', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.set',
- properties: []
- });
-
- const workflow = {
- name: 'Non-Loop Self Reference Test',
- nodes: [
- {
- id: '1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Set': {
- main: [
- [{ node: 'Set', type: 'main', index: 0 }] // Self-reference on regular node
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should warn about self-reference for non-loop nodes
- const selfRefWarnings = result.warnings.filter(w =>
- w.message?.includes('self-referencing')
- );
- expect(selfRefWarnings.length).toBeGreaterThan(0);
- });
- });
-
- describe('Output connection validation', () => {
- it('should validate output connections for nodes with outputs', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.if',
- outputs: [
- { displayName: 'True', description: 'Items that match condition' },
- { displayName: 'False', description: 'Items that do not match condition' }
- ],
- outputNames: ['true', 'false'],
- properties: []
- });
-
- const workflow = {
- name: 'IF Node Test',
- nodes: [
- {
- id: '1',
- name: 'IF',
- type: 'n8n-nodes-base.if',
- position: [100, 100],
- parameters: {}
- },
- {
- id: '2',
- name: 'True Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 50],
- parameters: {}
- },
- {
- id: '3',
- name: 'False Handler',
- type: 'n8n-nodes-base.set',
- position: [300, 150],
- parameters: {}
- }
- ],
- connections: {
- 'IF': {
- main: [
- [{ node: 'True Handler', type: 'main', index: 0 }], // True output (0)
- [{ node: 'False Handler', type: 'main', index: 0 }] // False output (1)
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should validate without major errors
- expect(result).toBeDefined();
- expect(result.statistics.validConnections).toBe(2);
- });
- });
-
- describe('Error handling', () => {
- it('should handle nodes without outputs gracefully', async () => {
- mockNodeRepository.getNode.mockReturnValue({
- nodeType: 'nodes-base.httpRequest',
- outputs: null,
- outputNames: null,
- properties: []
- });
-
- const workflow = {
- name: 'No Outputs Test',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should handle gracefully without crashing
- expect(result).toBeDefined();
- });
-
- it('should handle unknown node types gracefully', async () => {
- mockNodeRepository.getNode.mockReturnValue(null);
-
- const workflow = {
- name: 'Unknown Node Test',
- nodes: [
- {
- id: '1',
- name: 'Unknown',
- type: 'n8n-nodes-base.unknown',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should report unknown node error
- const unknownErrors = result.errors.filter(e =>
- e.message?.includes('Unknown node type')
- );
- expect(unknownErrors.length).toBeGreaterThan(0);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-loops.test.ts b/tests/unit/services/workflow-validator-loops.test.ts
index fdeb18c..9a69151 100644
--- a/tests/unit/services/workflow-validator-loops.test.ts
+++ b/tests/unit/services/workflow-validator-loops.test.ts
@@ -702,4 +702,244 @@ describe('WorkflowValidator - Loop Node Validation', () => {
expect(result).toBeDefined();
});
});
+
+ // โโโ Loop Output Edge Cases (absorbed from loop-output-edge-cases) โโ
+
+ describe('Nodes without outputs', () => {
+ it('should handle nodes with null outputs gracefully', async () => {
+ mockNodeRepository.getNode.mockReturnValue({
+ nodeType: 'nodes-base.httpRequest', outputs: null, outputNames: null, properties: [],
+ });
+
+ const workflow = {
+ name: 'No Outputs',
+ nodes: [
+ { id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: { url: 'https://example.com' } },
+ { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: { 'HTTP Request': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ const outputErrors = result.errors.filter(e => e.message?.includes('output') && !e.message?.includes('Connection'));
+ expect(outputErrors).toHaveLength(0);
+ });
+
+ it('should handle nodes with empty outputs array', async () => {
+ mockNodeRepository.getNode.mockReturnValue({
+ nodeType: 'nodes-base.customNode', outputs: [], outputNames: [], properties: [],
+ });
+
+ const workflow = {
+ name: 'Empty Outputs',
+ nodes: [{ id: '1', name: 'Custom Node', type: 'n8n-nodes-base.customNode', position: [100, 100], parameters: {} }],
+ connections: { 'Custom Node': { main: [[{ node: 'Custom Node', type: 'main', index: 0 }]] } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ const selfRefWarnings = result.warnings.filter(w => w.message?.includes('self-referencing'));
+ expect(selfRefWarnings).toHaveLength(1);
+ });
+ });
+
+ describe('Invalid connection indices', () => {
+ it('should handle very large connection indices', async () => {
+ mockNodeRepository.getNode.mockReturnValue({
+ nodeType: 'nodes-base.switch', outputs: [{ displayName: 'Output 1' }, { displayName: 'Output 2' }], properties: [],
+ });
+
+ const workflow = {
+ name: 'Large Index',
+ nodes: [
+ { id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: { 'Switch': { main: [[{ node: 'Set', type: 'main', index: 999 }]] } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ });
+ });
+
+ describe('Malformed connection structures', () => {
+ it('should handle null connection objects', async () => {
+ const workflow = {
+ name: 'Null Connections',
+ nodes: [{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }],
+ connections: { 'Split In Batches': { main: [null, [{ node: 'NonExistent', type: 'main', index: 0 }]] as any } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ });
+
+ it('should handle missing connection properties', async () => {
+ const workflow = {
+ name: 'Malformed Connections',
+ nodes: [
+ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: {
+ 'Split In Batches': { main: [[{ node: 'Set' } as any, { type: 'main', index: 0 } as any, {} as any]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Complex output structures', () => {
+ it('should handle nodes with many outputs', async () => {
+ const manyOutputs = Array.from({ length: 20 }, (_, i) => ({
+ displayName: `Output ${i + 1}`, name: `output${i + 1}`,
+ }));
+
+ mockNodeRepository.getNode.mockReturnValue({
+ nodeType: 'nodes-base.complexSwitch', outputs: manyOutputs, outputNames: manyOutputs.map(o => o.name), properties: [],
+ });
+
+ const workflow = {
+ name: 'Many Outputs',
+ nodes: [
+ { id: '1', name: 'Complex Switch', type: 'n8n-nodes-base.complexSwitch', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: { 'Complex Switch': { main: Array.from({ length: 20 }, () => [{ node: 'Set', type: 'main', index: 0 }]) } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ });
+
+ it('should handle mixed output types (main, error, ai_tool)', async () => {
+ mockNodeRepository.getNode.mockReturnValue({
+ nodeType: 'nodes-base.complexNode', outputs: [{ displayName: 'Main', type: 'main' }, { displayName: 'Error', type: 'error' }], properties: [],
+ });
+
+ const workflow = {
+ name: 'Mixed Output Types',
+ nodes: [
+ { id: '1', name: 'Complex Node', type: 'n8n-nodes-base.complexNode', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Main Handler', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {} },
+ { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 150], parameters: {} },
+ { id: '4', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [500, 100], parameters: {} },
+ ],
+ connections: {
+ 'Complex Node': {
+ main: [[{ node: 'Main Handler', type: 'main', index: 0 }]],
+ error: [[{ node: 'Error Handler', type: 'main', index: 0 }]],
+ ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]],
+ },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result).toBeDefined();
+ expect(result.statistics.validConnections).toBe(3);
+ });
+ });
+
+ describe('SplitInBatches specific edge cases', () => {
+ it('should handle SplitInBatches with no connections', async () => {
+ const workflow = {
+ name: 'Isolated SplitInBatches',
+ nodes: [{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} }],
+ connections: {},
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ const splitWarnings = result.warnings.filter(w => w.message?.includes('SplitInBatches') || w.message?.includes('loop') || w.message?.includes('done'));
+ expect(splitWarnings).toHaveLength(0);
+ });
+
+ it('should handle SplitInBatches with only done output connected', async () => {
+ const workflow = {
+ name: 'Single Output SplitInBatches',
+ nodes: [
+ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Final Action', type: 'n8n-nodes-base.emailSend', position: [300, 100], parameters: {} },
+ ],
+ connections: { 'Split In Batches': { main: [[{ node: 'Final Action', type: 'main', index: 0 }], []] } },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ const loopWarnings = result.warnings.filter(w => w.message?.includes('loop') && w.message?.includes('connect back'));
+ expect(loopWarnings).toHaveLength(0);
+ });
+
+ it('should handle SplitInBatches with both outputs to same node', async () => {
+ const workflow = {
+ name: 'Same Target SplitInBatches',
+ nodes: [
+ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Multi Purpose', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: {
+ 'Split In Batches': { main: [[{ node: 'Multi Purpose', type: 'main', index: 0 }], [{ node: 'Multi Purpose', type: 'main', index: 0 }]] },
+ 'Multi Purpose': { main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ const loopWarnings = result.warnings.filter(w => w.message?.includes('loop') && w.message?.includes('connect back'));
+ expect(loopWarnings).toHaveLength(0);
+ });
+
+ it('should detect reversed outputs with processing node on done output', async () => {
+ const workflow = {
+ name: 'Reversed SplitInBatches with Function Node',
+ nodes: [
+ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} },
+ { id: '2', name: 'Process Function', type: 'n8n-nodes-base.function', position: [300, 100], parameters: {} },
+ ],
+ connections: {
+ 'Split In Batches': { main: [[{ node: 'Process Function', type: 'main', index: 0 }], []] },
+ 'Process Function': { main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ const reversedErrors = result.errors.filter(e => e.message?.includes('SplitInBatches outputs appear reversed'));
+ expect(reversedErrors).toHaveLength(1);
+ });
+
+ it('should handle self-referencing nodes in loop back detection', async () => {
+ const workflow = {
+ name: 'Self Reference in Loop Back',
+ nodes: [
+ { id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [100, 100], parameters: {} },
+ { id: '2', name: 'SelfRef', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} },
+ ],
+ connections: {
+ 'Split In Batches': { main: [[], [{ node: 'SelfRef', type: 'main', index: 0 }]] },
+ 'SelfRef': { main: [[{ node: 'SelfRef', type: 'main', index: 0 }]] },
+ },
+ };
+
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.warnings.filter(w => w.message?.includes("doesn't connect back"))).toHaveLength(1);
+ expect(result.warnings.filter(w => w.message?.includes('self-referencing'))).toHaveLength(1);
+ });
+
+ it('should handle many SplitInBatches nodes', async () => {
+ const nodes = Array.from({ length: 100 }, (_, i) => ({
+ id: `split${i}`, name: `Split ${i}`, type: 'n8n-nodes-base.splitInBatches',
+ position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100], parameters: {},
+ }));
+
+ const connections: any = {};
+ for (let i = 0; i < 99; i++) {
+ connections[`Split ${i}`] = { main: [[{ node: `Split ${i + 1}`, type: 'main', index: 0 }], []] };
+ }
+
+ const result = await validator.validateWorkflow({ name: 'Many SplitInBatches', nodes, connections } as any);
+ expect(result).toBeDefined();
+ expect(result.statistics.totalNodes).toBe(100);
+ });
+ });
});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-mocks.test.ts b/tests/unit/services/workflow-validator-mocks.test.ts
deleted file mode 100644
index 88c71af..0000000
--- a/tests/unit/services/workflow-validator-mocks.test.ts
+++ /dev/null
@@ -1,721 +0,0 @@
-import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - Mock-based Unit Tests', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
- let mockGetNode: Mock;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create detailed mock repository with spy functions
- mockGetNode = vi.fn();
- mockNodeRepository = {
- getNode: mockGetNode
- };
-
- validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
-
- // Default mock responses
- mockGetNode.mockImplementation((type: string) => {
- if (type.includes('httpRequest')) {
- return {
- node_type: type,
- display_name: 'HTTP Request',
- isVersioned: true,
- version: 4
- };
- } else if (type.includes('set')) {
- return {
- node_type: type,
- display_name: 'Set',
- isVersioned: true,
- version: 3
- };
- } else if (type.includes('respondToWebhook')) {
- return {
- node_type: type,
- display_name: 'Respond to Webhook',
- isVersioned: true,
- version: 1
- };
- }
- return null;
- });
- });
-
- describe('Error Handler Detection Logic', () => {
- it('should correctly identify error handlers by node name patterns', async () => {
- const errorNodeNames = [
- 'Error Handler',
- 'Handle Error',
- 'Catch Exception',
- 'Failure Response',
- 'Error Notification',
- 'Fail Safe',
- 'Exception Handler',
- 'Error Callback'
- ];
-
- const successNodeNames = [
- 'Process Data',
- 'Transform',
- 'Success Handler',
- 'Continue Process',
- 'Normal Flow'
- ];
-
- for (const errorName of errorNodeNames) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Success Path',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: errorName,
- type: 'n8n-nodes-base.set',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'Success Path', type: 'main', index: 0 },
- { node: errorName, type: 'main', index: 0 } // Should be detected as error handler
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should detect this as an incorrect error configuration
- const hasError = result.errors.some(e =>
- e.message.includes('Incorrect error output configuration') &&
- e.message.includes(errorName)
- );
- expect(hasError).toBe(true);
- }
-
- // Test that success node names are NOT flagged
- for (const successName of successNodeNames) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'First Process',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: successName,
- type: 'n8n-nodes-base.set',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'First Process', type: 'main', index: 0 },
- { node: successName, type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should NOT detect this as an error configuration
- const hasError = result.errors.some(e =>
- e.message.includes('Incorrect error output configuration')
- );
- expect(hasError).toBe(false);
- }
- });
-
- it('should correctly identify error handlers by node type patterns', async () => {
- const errorNodeTypes = [
- 'n8n-nodes-base.respondToWebhook',
- 'n8n-nodes-base.emailSend'
- // Note: slack and webhook are not in the current detection logic
- ];
-
- // Update mock to return appropriate node info for these types
- mockGetNode.mockImplementation((type: string) => {
- return {
- node_type: type,
- display_name: type.split('.').pop() || 'Unknown',
- isVersioned: true,
- version: 1
- };
- });
-
- for (const nodeType of errorNodeTypes) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Success Path',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: 'Response Node',
- type: nodeType,
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'Success Path', type: 'main', index: 0 },
- { node: 'Response Node', type: 'main', index: 0 } // Should be detected
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should detect this as an incorrect error configuration
- const hasError = result.errors.some(e =>
- e.message.includes('Incorrect error output configuration') &&
- e.message.includes('Response Node')
- );
- expect(hasError).toBe(true);
- }
- });
-
- it('should handle cases where node repository returns null', async () => {
- // Mock repository to return null for unknown nodes
- mockGetNode.mockImplementation((type: string) => {
- if (type === 'n8n-nodes-base.unknownNode') {
- return null;
- }
- return {
- node_type: type,
- display_name: 'Known Node',
- isVersioned: true,
- version: 1
- };
- });
-
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Unknown Node',
- type: 'n8n-nodes-base.unknownNode',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'Unknown Node', type: 'main', index: 0 },
- { node: 'Error Handler', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should still detect the error configuration based on node name
- const hasError = result.errors.some(e =>
- e.message.includes('Incorrect error output configuration') &&
- e.message.includes('Error Handler')
- );
- expect(hasError).toBe(true);
-
- // Should not crash due to null node info
- expect(result).toHaveProperty('valid');
- expect(Array.isArray(result.errors)).toBe(true);
- });
- });
-
- describe('onError Property Validation Logic', () => {
- it('should validate onError property combinations correctly', async () => {
- const testCases = [
- {
- name: 'onError set but no error connections',
- onError: 'continueErrorOutput',
- hasErrorConnections: false,
- expectedErrorType: 'error',
- expectedMessage: "has onError: 'continueErrorOutput' but no error output connections"
- },
- {
- name: 'error connections but no onError',
- onError: undefined,
- hasErrorConnections: true,
- expectedErrorType: 'warning',
- expectedMessage: 'error output connections in main[1] but missing onError'
- },
- {
- name: 'onError set with error connections',
- onError: 'continueErrorOutput',
- hasErrorConnections: true,
- expectedErrorType: null,
- expectedMessage: null
- },
- {
- name: 'no onError and no error connections',
- onError: undefined,
- hasErrorConnections: false,
- expectedErrorType: null,
- expectedMessage: null
- }
- ];
-
- for (const testCase of testCases) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Test Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {},
- ...(testCase.onError ? { onError: testCase.onError } : {})
- },
- {
- id: '2',
- name: 'Success Handler',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Handler',
- type: 'n8n-nodes-base.set',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Test Node': {
- main: [
- [
- { node: 'Success Handler', type: 'main', index: 0 }
- ],
- ...(testCase.hasErrorConnections ? [
- [
- { node: 'Error Handler', type: 'main', index: 0 }
- ]
- ] : [])
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- if (testCase.expectedErrorType === 'error') {
- const hasExpectedError = result.errors.some(e =>
- e.nodeName === 'Test Node' &&
- e.message.includes(testCase.expectedMessage!)
- );
- expect(hasExpectedError).toBe(true);
- } else if (testCase.expectedErrorType === 'warning') {
- const hasExpectedWarning = result.warnings.some(w =>
- w.nodeName === 'Test Node' &&
- w.message.includes(testCase.expectedMessage!)
- );
- expect(hasExpectedWarning).toBe(true);
- } else {
- // Should not have related errors or warnings about onError/error output mismatches
- const hasRelatedError = result.errors.some(e =>
- e.nodeName === 'Test Node' &&
- (e.message.includes("has onError: 'continueErrorOutput' but no error output connections") ||
- e.message.includes('Incorrect error output configuration'))
- );
- const hasRelatedWarning = result.warnings.some(w =>
- w.nodeName === 'Test Node' &&
- w.message.includes('error output connections in main[1] but missing onError')
- );
- expect(hasRelatedError).toBe(false);
- expect(hasRelatedWarning).toBe(false);
- }
- }
- });
-
- it('should handle different onError values correctly', async () => {
- const onErrorValues = [
- 'continueErrorOutput',
- 'continueRegularOutput',
- 'stopWorkflow'
- ];
-
- for (const onErrorValue of onErrorValues) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Test Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {},
- onError: onErrorValue
- },
- {
- id: '2',
- name: 'Next Node',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- }
- ],
- connections: {
- 'Test Node': {
- main: [
- [
- { node: 'Next Node', type: 'main', index: 0 }
- ]
- // No error connections
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- if (onErrorValue === 'continueErrorOutput') {
- // Should have error about missing error connections
- const hasError = result.errors.some(e =>
- e.nodeName === 'Test Node' &&
- e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
- );
- expect(hasError).toBe(true);
- } else {
- // Should not have error about missing error connections
- const hasError = result.errors.some(e =>
- e.nodeName === 'Test Node' &&
- e.message.includes('but no error output connections')
- );
- expect(hasError).toBe(false);
- }
- }
- });
- });
-
- describe('JSON Format Generation', () => {
- it('should generate valid JSON in error messages', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'API Call',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Success Process',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: 'Error Handler',
- type: 'n8n-nodes-base.respondToWebhook',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'API Call': {
- main: [
- [
- { node: 'Success Process', type: 'main', index: 0 },
- { node: 'Error Handler', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const errorConfigError = result.errors.find(e =>
- e.message.includes('Incorrect error output configuration')
- );
-
- expect(errorConfigError).toBeDefined();
-
- // Extract JSON sections from error message
- const incorrectMatch = errorConfigError!.message.match(/INCORRECT \(current\):\n([\s\S]*?)\n\nCORRECT/);
- const correctMatch = errorConfigError!.message.match(/CORRECT \(should be\):\n([\s\S]*?)\n\nAlso add/);
-
- expect(incorrectMatch).toBeDefined();
- expect(correctMatch).toBeDefined();
-
- // Extract just the JSON part (remove comments)
- const incorrectJsonStr = incorrectMatch![1];
- const correctJsonStr = correctMatch![1];
-
- // Remove comments and clean up for JSON parsing
- const cleanIncorrectJson = incorrectJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, '');
- const cleanCorrectJson = correctJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, '');
-
- const incorrectJson = `{${cleanIncorrectJson}}`;
- const correctJson = `{${cleanCorrectJson}}`;
-
- expect(() => JSON.parse(incorrectJson)).not.toThrow();
- expect(() => JSON.parse(correctJson)).not.toThrow();
-
- const parsedIncorrect = JSON.parse(incorrectJson);
- const parsedCorrect = JSON.parse(correctJson);
-
- // Validate structure
- expect(parsedIncorrect).toHaveProperty('API Call');
- expect(parsedCorrect).toHaveProperty('API Call');
- expect(parsedIncorrect['API Call']).toHaveProperty('main');
- expect(parsedCorrect['API Call']).toHaveProperty('main');
-
- // Incorrect should have both nodes in main[0]
- expect(Array.isArray(parsedIncorrect['API Call'].main)).toBe(true);
- expect(parsedIncorrect['API Call'].main).toHaveLength(1);
- expect(parsedIncorrect['API Call'].main[0]).toHaveLength(2);
-
- // Correct should have separate arrays
- expect(Array.isArray(parsedCorrect['API Call'].main)).toBe(true);
- expect(parsedCorrect['API Call'].main).toHaveLength(2);
- expect(parsedCorrect['API Call'].main[0]).toHaveLength(1); // Success only
- expect(parsedCorrect['API Call'].main[1]).toHaveLength(1); // Error only
- });
-
- it('should handle special characters in node names in JSON', async () => {
- // Test simpler special characters that are easier to handle in JSON
- const specialNodeNames = [
- 'Node with spaces',
- 'Node-with-dashes',
- 'Node_with_underscores'
- ];
-
- for (const specialName of specialNodeNames) {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Source',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Success',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: specialName,
- type: 'n8n-nodes-base.respondToWebhook',
- position: [200, 100],
- parameters: {}
- }
- ],
- connections: {
- 'Source': {
- main: [
- [
- { node: 'Success', type: 'main', index: 0 },
- { node: specialName, type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow as any);
-
- const errorConfigError = result.errors.find(e =>
- e.message.includes('Incorrect error output configuration')
- );
-
- expect(errorConfigError).toBeDefined();
-
- // Verify the error message contains the special node name
- expect(errorConfigError!.message).toContain(specialName);
-
- // Verify JSON structure is present (but don't parse due to comments)
- expect(errorConfigError!.message).toContain('INCORRECT (current):');
- expect(errorConfigError!.message).toContain('CORRECT (should be):');
- expect(errorConfigError!.message).toContain('main[0]');
- expect(errorConfigError!.message).toContain('main[1]');
- }
- });
- });
-
- describe('Repository Interaction Patterns', () => {
- it('should call repository getNode with correct parameters', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set Node',
- type: 'n8n-nodes-base.set',
- position: [200, 0],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Node': {
- main: [
- [
- { node: 'Set Node', type: 'main', index: 0 }
- ]
- ]
- }
- }
- };
-
- await validator.validateWorkflow(workflow as any);
-
- // Should have called getNode for each node type (normalized to short form)
- // Called during node validation + output/input index bounds checking
- expect(mockGetNode).toHaveBeenCalledWith('nodes-base.httpRequest');
- expect(mockGetNode).toHaveBeenCalledWith('nodes-base.set');
- expect(mockGetNode.mock.calls.length).toBeGreaterThanOrEqual(2);
- });
-
- it('should handle repository errors gracefully', async () => {
- // Mock repository to throw error
- mockGetNode.mockImplementation(() => {
- throw new Error('Database connection failed');
- });
-
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'Test Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Should not throw error
- const result = await validator.validateWorkflow(workflow as any);
-
- // Should still return a valid result
- expect(result).toHaveProperty('valid');
- expect(Array.isArray(result.errors)).toBe(true);
- expect(Array.isArray(result.warnings)).toBe(true);
- });
-
- it('should optimize repository calls for duplicate node types', async () => {
- const workflow = {
- nodes: [
- {
- id: '1',
- name: 'HTTP 1',
- type: 'n8n-nodes-base.httpRequest',
- position: [0, 0],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP 2',
- type: 'n8n-nodes-base.httpRequest',
- position: [200, 0],
- parameters: {}
- },
- {
- id: '3',
- name: 'HTTP 3',
- type: 'n8n-nodes-base.httpRequest',
- position: [400, 0],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- await validator.validateWorkflow(workflow as any);
-
- // Should call getNode for the same type multiple times (current implementation)
- // Note: This test documents current behavior. Could be optimized in the future.
- const httpRequestCalls = mockGetNode.mock.calls.filter(
- call => call[0] === 'nodes-base.httpRequest'
- );
- expect(httpRequestCalls.length).toBeGreaterThan(0);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-performance.test.ts b/tests/unit/services/workflow-validator-performance.test.ts
deleted file mode 100644
index 4c64c5f..0000000
--- a/tests/unit/services/workflow-validator-performance.test.ts
+++ /dev/null
@@ -1,528 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - Performance Tests', () => {
- let validator: WorkflowValidator;
- let mockNodeRepository: any;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create mock repository with performance optimizations
- mockNodeRepository = {
- getNode: vi.fn((type: string) => {
- // Return mock node info for any node type to avoid database calls
- return {
- node_type: type,
- display_name: 'Mock Node',
- isVersioned: true,
- version: 1
- };
- })
- };
-
- validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
- });
-
- describe('Large Workflow Performance', () => {
- it('should validate large workflows with many error paths efficiently', async () => {
- // Generate a large workflow with 500 nodes
- const nodeCount = 500;
- const nodes = [];
- const connections: any = {};
-
- // Create nodes with various error handling patterns
- for (let i = 1; i <= nodeCount; i++) {
- nodes.push({
- id: i.toString(),
- name: `Node${i}`,
- type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 10, (i % 10) * 100],
- parameters: {},
- ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {})
- });
- }
-
- // Create connections with multiple error handling scenarios
- for (let i = 1; i < nodeCount; i++) {
- const hasErrorHandling = i % 3 === 0;
- const hasMultipleConnections = i % 7 === 0;
-
- if (hasErrorHandling && hasMultipleConnections) {
- // Mix correct and incorrect error handling patterns
- const isIncorrect = i % 14 === 0;
-
- if (isIncorrect) {
- // Incorrect: error handlers mixed with success nodes in main[0]
- connections[`Node${i}`] = {
- main: [
- [
- { node: `Node${i + 1}`, type: 'main', index: 0 },
- { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong!
- ]
- ]
- };
- } else {
- // Correct: separate success and error outputs
- connections[`Node${i}`] = {
- main: [
- [
- { node: `Node${i + 1}`, type: 'main', index: 0 }
- ],
- [
- { node: `Error Handler ${i}`, type: 'main', index: 0 }
- ]
- ]
- };
- }
-
- // Add error handler node
- nodes.push({
- id: `error-${i}`,
- name: `Error Handler ${i}`,
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [(i + nodeCount) * 10, 500],
- parameters: {}
- });
- } else {
- // Simple connection
- connections[`Node${i}`] = {
- main: [
- [
- { node: `Node${i + 1}`, type: 'main', index: 0 }
- ]
- ]
- };
- }
- }
-
- const workflow = { nodes, connections };
-
- const startTime = performance.now();
- const result = await validator.validateWorkflow(workflow as any);
- const endTime = performance.now();
-
- const executionTime = endTime - startTime;
-
- // Validation should complete within reasonable time
- expect(executionTime).toBeLessThan(10000); // Less than 10 seconds
-
- // Should still catch validation errors
- expect(Array.isArray(result.errors)).toBe(true);
- expect(Array.isArray(result.warnings)).toBe(true);
-
- // Should detect incorrect error configurations
- const incorrectConfigErrors = result.errors.filter(e =>
- e.message.includes('Incorrect error output configuration')
- );
- expect(incorrectConfigErrors.length).toBeGreaterThan(0);
-
- console.log(`Validated ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`);
- console.log(`Found ${result.errors.length} errors and ${result.warnings.length} warnings`);
- });
-
- it('should handle deeply nested error handling chains efficiently', async () => {
- // Create a chain of error handlers, each with their own error handling
- const chainLength = 100;
- const nodes = [];
- const connections: any = {};
-
- for (let i = 1; i <= chainLength; i++) {
- // Main processing node
- nodes.push({
- id: `main-${i}`,
- name: `Main ${i}`,
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [i * 150, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- });
-
- // Error handler node
- nodes.push({
- id: `error-${i}`,
- name: `Error Handler ${i}`,
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [i * 150, 300],
- parameters: {},
- onError: 'continueErrorOutput'
- });
-
- // Fallback error node
- nodes.push({
- id: `fallback-${i}`,
- name: `Fallback ${i}`,
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 150, 500],
- parameters: {}
- });
-
- // Connections
- connections[`Main ${i}`] = {
- main: [
- // Success path
- i < chainLength ? [{ node: `Main ${i + 1}`, type: 'main', index: 0 }] : [],
- // Error path
- [{ node: `Error Handler ${i}`, type: 'main', index: 0 }]
- ]
- };
-
- connections[`Error Handler ${i}`] = {
- main: [
- // Success path (continue to next error handler or end)
- [],
- // Error path (go to fallback)
- [{ node: `Fallback ${i}`, type: 'main', index: 0 }]
- ]
- };
- }
-
- const workflow = { nodes, connections };
-
- const startTime = performance.now();
- const result = await validator.validateWorkflow(workflow as any);
- const endTime = performance.now();
-
- const executionTime = endTime - startTime;
-
- // Should complete quickly even with complex nested error handling
- expect(executionTime).toBeLessThan(5000); // Less than 5 seconds
-
- // Should not have errors about incorrect configuration (this is correct)
- const incorrectConfigErrors = result.errors.filter(e =>
- e.message.includes('Incorrect error output configuration')
- );
- expect(incorrectConfigErrors.length).toBe(0);
-
- console.log(`Validated ${nodes.length} nodes with nested error handling in ${executionTime.toFixed(2)}ms`);
- });
-
- it('should efficiently validate workflows with many parallel error paths', async () => {
- // Create a workflow with one source node that fans out to many parallel paths,
- // each with their own error handling
- const parallelPathCount = 200;
- const nodes = [
- {
- id: 'source',
- name: 'Source',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [0, 0],
- parameters: {}
- }
- ];
- const connections: any = {
- 'Source': {
- main: [[]]
- }
- };
-
- // Create parallel paths
- for (let i = 1; i <= parallelPathCount; i++) {
- // Processing node
- nodes.push({
- id: `process-${i}`,
- name: `Process ${i}`,
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [200, i * 20],
- parameters: {},
- onError: 'continueErrorOutput'
- } as any);
-
- // Success handler
- nodes.push({
- id: `success-${i}`,
- name: `Success ${i}`,
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [400, i * 20],
- parameters: {}
- });
-
- // Error handler
- nodes.push({
- id: `error-${i}`,
- name: `Error Handler ${i}`,
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [400, i * 20 + 10],
- parameters: {}
- });
-
- // Connect source to processing node
- connections['Source'].main[0].push({
- node: `Process ${i}`,
- type: 'main',
- index: 0
- });
-
- // Connect processing node to success and error handlers
- connections[`Process ${i}`] = {
- main: [
- [{ node: `Success ${i}`, type: 'main', index: 0 }],
- [{ node: `Error Handler ${i}`, type: 'main', index: 0 }]
- ]
- };
- }
-
- const workflow = { nodes, connections };
-
- const startTime = performance.now();
- const result = await validator.validateWorkflow(workflow as any);
- const endTime = performance.now();
-
- const executionTime = endTime - startTime;
-
- // Should validate efficiently despite many parallel paths
- expect(executionTime).toBeLessThan(8000); // Less than 8 seconds
-
- // Should not have errors about incorrect configuration
- const incorrectConfigErrors = result.errors.filter(e =>
- e.message.includes('Incorrect error output configuration')
- );
- expect(incorrectConfigErrors.length).toBe(0);
-
- console.log(`Validated ${nodes.length} nodes with ${parallelPathCount} parallel error paths in ${executionTime.toFixed(2)}ms`);
- });
-
- it('should handle worst-case scenario with many incorrect configurations efficiently', async () => {
- // Create a workflow where many nodes have the incorrect error configuration
- // This tests the performance of the error detection algorithm
- const nodeCount = 300;
- const nodes = [];
- const connections: any = {};
-
- for (let i = 1; i <= nodeCount; i++) {
- // Main node
- nodes.push({
- id: `main-${i}`,
- name: `Main ${i}`,
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [i * 20, 100],
- parameters: {}
- });
-
- // Success handler
- nodes.push({
- id: `success-${i}`,
- name: `Success ${i}`,
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 20, 200],
- parameters: {}
- });
-
- // Error handler (with error-indicating name)
- nodes.push({
- id: `error-${i}`,
- name: `Error Handler ${i}`,
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [i * 20, 300],
- parameters: {}
- });
-
- // INCORRECT configuration: both success and error handlers in main[0]
- connections[`Main ${i}`] = {
- main: [
- [
- { node: `Success ${i}`, type: 'main', index: 0 },
- { node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong!
- ]
- ]
- };
- }
-
- const workflow = { nodes, connections };
-
- const startTime = performance.now();
- const result = await validator.validateWorkflow(workflow as any);
- const endTime = performance.now();
-
- const executionTime = endTime - startTime;
-
- // Should complete within reasonable time even when generating many errors
- expect(executionTime).toBeLessThan(15000); // Less than 15 seconds
-
- // Should detect ALL incorrect configurations
- const incorrectConfigErrors = result.errors.filter(e =>
- e.message.includes('Incorrect error output configuration')
- );
- expect(incorrectConfigErrors.length).toBe(nodeCount); // One error per node
-
- console.log(`Detected ${incorrectConfigErrors.length} incorrect configurations in ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`);
- });
- });
-
- describe('Memory Usage and Optimization', () => {
- it('should not leak memory during large workflow validation', async () => {
- // Get initial memory usage
- const initialMemory = process.memoryUsage().heapUsed;
-
- // Validate multiple large workflows
- for (let run = 0; run < 5; run++) {
- const nodeCount = 200;
- const nodes = [];
- const connections: any = {};
-
- for (let i = 1; i <= nodeCount; i++) {
- nodes.push({
- id: i.toString(),
- name: `Node${i}`,
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [i * 10, 100],
- parameters: {},
- onError: 'continueErrorOutput'
- });
-
- if (i > 1) {
- connections[`Node${i - 1}`] = {
- main: [
- [{ node: `Node${i}`, type: 'main', index: 0 }],
- [{ node: `Error${i}`, type: 'main', index: 0 }]
- ]
- };
-
- nodes.push({
- id: `error-${i}`,
- name: `Error${i}`,
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 10, 200],
- parameters: {}
- });
- }
- }
-
- const workflow = { nodes, connections };
- await validator.validateWorkflow(workflow as any);
-
- // Force garbage collection if available
- if (global.gc) {
- global.gc();
- }
- }
-
- const finalMemory = process.memoryUsage().heapUsed;
- const memoryIncrease = finalMemory - initialMemory;
- const memoryIncreaseMB = memoryIncrease / (1024 * 1024);
-
- // Memory increase should be reasonable (less than 50MB)
- expect(memoryIncreaseMB).toBeLessThan(50);
-
- console.log(`Memory increase after 5 large workflow validations: ${memoryIncreaseMB.toFixed(2)}MB`);
- });
-
- it('should handle concurrent validation requests efficiently', async () => {
- // Create multiple validation requests that run concurrently
- const concurrentRequests = 10;
- const workflows = [];
-
- // Prepare workflows
- for (let r = 0; r < concurrentRequests; r++) {
- const nodeCount = 50;
- const nodes = [];
- const connections: any = {};
-
- for (let i = 1; i <= nodeCount; i++) {
- nodes.push({
- id: `${r}-${i}`,
- name: `R${r}Node${i}`,
- type: i % 2 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 20, r * 100],
- parameters: {},
- ...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {})
- });
-
- if (i > 1) {
- const hasError = i % 3 === 0;
- const isIncorrect = i % 6 === 0;
-
- if (hasError && isIncorrect) {
- // Incorrect configuration
- connections[`R${r}Node${i - 1}`] = {
- main: [
- [
- { node: `R${r}Node${i}`, type: 'main', index: 0 },
- { node: `R${r}Error${i}`, type: 'main', index: 0 } // Wrong!
- ]
- ]
- };
-
- nodes.push({
- id: `${r}-error-${i}`,
- name: `R${r}Error${i}`,
- type: 'n8n-nodes-base.respondToWebhook',
- typeVersion: 1,
- position: [i * 20, r * 100 + 50],
- parameters: {}
- });
- } else if (hasError) {
- // Correct configuration
- connections[`R${r}Node${i - 1}`] = {
- main: [
- [{ node: `R${r}Node${i}`, type: 'main', index: 0 }],
- [{ node: `R${r}Error${i}`, type: 'main', index: 0 }]
- ]
- };
-
- nodes.push({
- id: `${r}-error-${i}`,
- name: `R${r}Error${i}`,
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [i * 20, r * 100 + 50],
- parameters: {}
- });
- } else {
- // Normal connection
- connections[`R${r}Node${i - 1}`] = {
- main: [
- [{ node: `R${r}Node${i}`, type: 'main', index: 0 }]
- ]
- };
- }
- }
- }
-
- workflows.push({ nodes, connections });
- }
-
- // Run concurrent validations
- const startTime = performance.now();
- const results = await Promise.all(
- workflows.map(workflow => validator.validateWorkflow(workflow as any))
- );
- const endTime = performance.now();
-
- const totalTime = endTime - startTime;
-
- // All validations should complete
- expect(results).toHaveLength(concurrentRequests);
-
- // Each result should be valid
- results.forEach(result => {
- expect(Array.isArray(result.errors)).toBe(true);
- expect(Array.isArray(result.warnings)).toBe(true);
- });
-
- // Concurrent execution should be efficient
- expect(totalTime).toBeLessThan(20000); // Less than 20 seconds total
-
- console.log(`Completed ${concurrentRequests} concurrent validations in ${totalTime.toFixed(2)}ms`);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator-tool-variants.test.ts b/tests/unit/services/workflow-validator-tool-variants.test.ts
deleted file mode 100644
index a7a14d6..0000000
--- a/tests/unit/services/workflow-validator-tool-variants.test.ts
+++ /dev/null
@@ -1,892 +0,0 @@
-/**
- * Tests for WorkflowValidator - Tool Variant Validation
- *
- * Tests the validateAIToolSource() method which ensures that base nodes
- * with ai_tool connections use the correct Tool variant node type.
- *
- * Coverage:
- * - Langchain tool nodes pass validation
- * - Tool variant nodes pass validation
- * - Base nodes with Tool variants fail with WRONG_NODE_TYPE_FOR_AI_TOOL
- * - Error includes fix suggestion with tool-variant-correction type
- * - Unknown nodes don't cause errors
- */
-
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { NodeRepository } from '@/database/node-repository';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-// Mock dependencies
-vi.mock('@/database/node-repository');
-vi.mock('@/services/enhanced-config-validator');
-vi.mock('@/utils/logger');
-
-describe('WorkflowValidator - Tool Variant Validation', () => {
- let validator: WorkflowValidator;
- let mockRepository: NodeRepository;
- let mockValidator: typeof EnhancedConfigValidator;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Create mock repository
- mockRepository = {
- getNode: vi.fn((nodeType: string) => {
- // Mock base node with Tool variant available
- if (nodeType === 'nodes-base.supabase') {
- return {
- nodeType: 'nodes-base.supabase',
- displayName: 'Supabase',
- isAITool: true,
- hasToolVariant: true,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- // Mock Tool variant node
- if (nodeType === 'nodes-base.supabaseTool') {
- return {
- nodeType: 'nodes-base.supabaseTool',
- displayName: 'Supabase Tool',
- isAITool: true,
- hasToolVariant: false,
- isToolVariant: true,
- toolVariantOf: 'nodes-base.supabase',
- isTrigger: false,
- properties: []
- };
- }
-
- // Mock langchain node (Calculator tool)
- if (nodeType === 'nodes-langchain.toolCalculator') {
- return {
- nodeType: 'nodes-langchain.toolCalculator',
- displayName: 'Calculator',
- isAITool: true,
- hasToolVariant: false,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- // Mock HTTP Request Tool node
- if (nodeType === 'nodes-langchain.toolHttpRequest') {
- return {
- nodeType: 'nodes-langchain.toolHttpRequest',
- displayName: 'HTTP Request Tool',
- isAITool: true,
- hasToolVariant: false,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- // Mock base node without Tool variant
- if (nodeType === 'nodes-base.httpRequest') {
- return {
- nodeType: 'nodes-base.httpRequest',
- displayName: 'HTTP Request',
- isAITool: false,
- hasToolVariant: false,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- return null; // Unknown node
- })
- } as any;
-
- mockValidator = EnhancedConfigValidator;
-
- validator = new WorkflowValidator(mockRepository, mockValidator);
- });
-
- describe('validateAIToolSource - Langchain tool nodes', () => {
- it('should pass validation for Calculator tool node', async () => {
- const workflow = {
- nodes: [
- {
- id: 'calculator-1',
- name: 'Calculator',
- type: 'n8n-nodes-langchain.toolCalculator',
- typeVersion: 1.2,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Calculator: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should not have errors about wrong node type for AI tool
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
- });
-
- it('should pass validation for HTTP Request Tool node', async () => {
- const workflow = {
- nodes: [
- {
- id: 'http-tool-1',
- name: 'HTTP Request Tool',
- type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
- typeVersion: 1.2,
- position: [250, 300] as [number, number],
- parameters: {
- url: 'https://api.example.com',
- toolDescription: 'Fetch data from API'
- }
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
- });
- });
-
- describe('validateAIToolSource - Tool variant nodes', () => {
- it('should pass validation for Tool variant node (supabaseTool)', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-tool-1',
- name: 'Supabase Tool',
- type: 'n8n-nodes-base.supabaseTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {
- toolDescription: 'Query Supabase database'
- }
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Supabase Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
- });
-
- it('should verify Tool variant is marked correctly in database', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-tool-1',
- name: 'Supabase Tool',
- type: 'n8n-nodes-base.supabaseTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Supabase Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- await validator.validateWorkflow(workflow);
-
- // Verify repository was called to check if it's a Tool variant
- expect(mockRepository.getNode).toHaveBeenCalledWith('nodes-base.supabaseTool');
- });
- });
-
- describe('validateAIToolSource - Base nodes with Tool variants', () => {
- it('should fail when base node is used instead of Tool variant', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-1',
- name: 'Supabase',
- type: 'n8n-nodes-base.supabase',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Supabase: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have error with WRONG_NODE_TYPE_FOR_AI_TOOL code
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(1);
- });
-
- it('should include fix suggestion in error', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-1',
- name: 'Supabase',
- type: 'n8n-nodes-base.supabase',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Supabase: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const toolVariantError = result.errors.find(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- ) as any;
-
- expect(toolVariantError).toBeDefined();
- expect(toolVariantError.fix).toBeDefined();
- expect(toolVariantError.fix.type).toBe('tool-variant-correction');
- expect(toolVariantError.fix.currentType).toBe('n8n-nodes-base.supabase');
- expect(toolVariantError.fix.suggestedType).toBe('n8n-nodes-base.supabaseTool');
- expect(toolVariantError.fix.description).toContain('n8n-nodes-base.supabase');
- expect(toolVariantError.fix.description).toContain('n8n-nodes-base.supabaseTool');
- });
-
- it('should provide clear error message', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-1',
- name: 'Supabase',
- type: 'n8n-nodes-base.supabase',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Supabase: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const toolVariantError = result.errors.find(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
-
- expect(toolVariantError).toBeDefined();
- expect(toolVariantError!.message).toContain('cannot output ai_tool connections');
- expect(toolVariantError!.message).toContain('Tool variant');
- expect(toolVariantError!.message).toContain('n8n-nodes-base.supabaseTool');
- });
-
- it('should handle multiple base nodes incorrectly used as tools', async () => {
- mockRepository.getNode = vi.fn((nodeType: string) => {
- if (nodeType === 'nodes-base.postgres') {
- return {
- nodeType: 'nodes-base.postgres',
- displayName: 'Postgres',
- isAITool: true,
- hasToolVariant: true,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
- if (nodeType === 'nodes-base.supabase') {
- return {
- nodeType: 'nodes-base.supabase',
- displayName: 'Supabase',
- isAITool: true,
- hasToolVariant: true,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
- return null;
- }) as any;
-
- const workflow = {
- nodes: [
- {
- id: 'postgres-1',
- name: 'Postgres',
- type: 'n8n-nodes-base.postgres',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'supabase-1',
- name: 'Supabase',
- type: 'n8n-nodes-base.supabase',
- typeVersion: 1,
- position: [250, 400] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Postgres: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- },
- Supabase: {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(2);
- });
- });
-
- describe('validateAIToolSource - Unknown nodes', () => {
- it('should not error for unknown node types', async () => {
- const workflow = {
- nodes: [
- {
- id: 'unknown-1',
- name: 'Unknown Tool',
- type: 'custom-package.unknownTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Unknown Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Unknown nodes should not cause tool variant errors
- // Let other validation handle unknown node types
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
-
- // But there might be an "Unknown node type" error from different validation
- const unknownNodeErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownNodeErrors.length).toBeGreaterThan(0);
- });
-
- it('should not error for community nodes', async () => {
- const workflow = {
- nodes: [
- {
- id: 'community-1',
- name: 'Community Tool',
- type: 'community-package.customTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Community Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Community nodes should not cause tool variant errors
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
- });
- });
-
- describe('validateAIToolSource - Edge cases', () => {
- it('should not error for base nodes without ai_tool connections', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-1',
- name: 'Supabase',
- type: 'n8n-nodes-base.supabase',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'set-1',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 1,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- Supabase: {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // No ai_tool connections, so no tool variant validation errors
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
- });
-
- it('should not error when base node without Tool variant uses ai_tool', async () => {
- const workflow = {
- nodes: [
- {
- id: 'http-1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'HTTP Request': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // httpRequest has no Tool variant, so this should produce a different error
- const toolVariantErrors = result.errors.filter(e =>
- e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL'
- );
- expect(toolVariantErrors).toHaveLength(0);
-
- // Should have INVALID_AI_TOOL_SOURCE error instead
- const invalidToolErrors = result.errors.filter(e =>
- e.code === 'INVALID_AI_TOOL_SOURCE'
- );
- expect(invalidToolErrors.length).toBeGreaterThan(0);
- });
- });
-
- describe('validateAllNodes - Inferred Tool Variants (Issue #522)', () => {
- /**
- * Tests for dynamic AI Tool nodes that are created at runtime by n8n
- * when ANY node is used in an AI Agent's tool slot.
- *
- * These nodes (e.g., googleDriveTool, googleSheetsTool) don't exist in npm packages
- * but are valid when the base node exists.
- */
-
- beforeEach(() => {
- // Update mock repository to include Google nodes
- mockRepository.getNode = vi.fn((nodeType: string) => {
- // Base node with Tool variant
- if (nodeType === 'nodes-base.supabase') {
- return {
- nodeType: 'nodes-base.supabase',
- displayName: 'Supabase',
- isAITool: true,
- hasToolVariant: true,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- // Tool variant in database
- if (nodeType === 'nodes-base.supabaseTool') {
- return {
- nodeType: 'nodes-base.supabaseTool',
- displayName: 'Supabase Tool',
- isAITool: true,
- hasToolVariant: false,
- isToolVariant: true,
- toolVariantOf: 'nodes-base.supabase',
- isTrigger: false,
- properties: []
- };
- }
-
- // Google Drive base node (exists, but no Tool variant in DB)
- if (nodeType === 'nodes-base.googleDrive') {
- return {
- nodeType: 'nodes-base.googleDrive',
- displayName: 'Google Drive',
- isAITool: false, // Not marked as AI tool in npm package
- hasToolVariant: false, // No Tool variant in database
- isToolVariant: false,
- isTrigger: false,
- properties: [],
- category: 'files'
- };
- }
-
- // Google Sheets base node (exists, but no Tool variant in DB)
- if (nodeType === 'nodes-base.googleSheets') {
- return {
- nodeType: 'nodes-base.googleSheets',
- displayName: 'Google Sheets',
- isAITool: false,
- hasToolVariant: false,
- isToolVariant: false,
- isTrigger: false,
- properties: [],
- category: 'productivity'
- };
- }
-
- // AI Agent node
- if (nodeType === 'nodes-langchain.agent') {
- return {
- nodeType: 'nodes-langchain.agent',
- displayName: 'AI Agent',
- isAITool: false,
- hasToolVariant: false,
- isToolVariant: false,
- isTrigger: false,
- properties: []
- };
- }
-
- return null; // Unknown node
- }) as any;
- });
-
- it('should pass validation for googleDriveTool when googleDrive exists', async () => {
- const workflow = {
- nodes: [
- {
- id: 'drive-tool-1',
- name: 'Google Drive Tool',
- type: 'n8n-nodes-base.googleDriveTool',
- typeVersion: 3,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should NOT have "Unknown node type" error
- const unknownErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownErrors).toHaveLength(0);
-
- // Should have INFERRED_TOOL_VARIANT warning
- const inferredWarnings = result.warnings.filter(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
- expect(inferredWarnings).toHaveLength(1);
- expect(inferredWarnings[0].message).toContain('googleDriveTool');
- expect(inferredWarnings[0].message).toContain('Google Drive');
- });
-
- it('should pass validation for googleSheetsTool when googleSheets exists', async () => {
- const workflow = {
- nodes: [
- {
- id: 'sheets-tool-1',
- name: 'Google Sheets Tool',
- type: 'n8n-nodes-base.googleSheetsTool',
- typeVersion: 4,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should NOT have "Unknown node type" error
- const unknownErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownErrors).toHaveLength(0);
-
- // Should have INFERRED_TOOL_VARIANT warning
- const inferredWarnings = result.warnings.filter(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
- expect(inferredWarnings).toHaveLength(1);
- expect(inferredWarnings[0].message).toContain('googleSheetsTool');
- expect(inferredWarnings[0].message).toContain('Google Sheets');
- });
-
- it('should report error for unknownNodeTool when base node does not exist', async () => {
- const workflow = {
- nodes: [
- {
- id: 'unknown-tool-1',
- name: 'Unknown Tool',
- type: 'n8n-nodes-base.nonExistentNodeTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should have "Unknown node type" error
- const unknownErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownErrors).toHaveLength(1);
-
- // Should NOT have INFERRED_TOOL_VARIANT warning
- const inferredWarnings = result.warnings.filter(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
- expect(inferredWarnings).toHaveLength(0);
- });
-
- it('should handle multiple inferred tool variants in same workflow', async () => {
- const workflow = {
- nodes: [
- {
- id: 'drive-tool-1',
- name: 'Google Drive Tool',
- type: 'n8n-nodes-base.googleDriveTool',
- typeVersion: 3,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: 'sheets-tool-1',
- name: 'Google Sheets Tool',
- type: 'n8n-nodes-base.googleSheetsTool',
- typeVersion: 4,
- position: [250, 400] as [number, number],
- parameters: {}
- },
- {
- id: 'agent-1',
- name: 'AI Agent',
- type: '@n8n/n8n-nodes-langchain.agent',
- typeVersion: 1.7,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Google Drive Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- },
- 'Google Sheets Tool': {
- ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
- }
- }
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should NOT have "Unknown node type" errors
- const unknownErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownErrors).toHaveLength(0);
-
- // Should have 2 INFERRED_TOOL_VARIANT warnings
- const inferredWarnings = result.warnings.filter(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
- expect(inferredWarnings).toHaveLength(2);
- });
-
- it('should prefer database record over inference for supabaseTool', async () => {
- const workflow = {
- nodes: [
- {
- id: 'supabase-tool-1',
- name: 'Supabase Tool',
- type: 'n8n-nodes-base.supabaseTool',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- // Should NOT have "Unknown node type" error
- const unknownErrors = result.errors.filter(e =>
- e.message && e.message.includes('Unknown node type')
- );
- expect(unknownErrors).toHaveLength(0);
-
- // Should NOT have INFERRED_TOOL_VARIANT warning (it's in database)
- const inferredWarnings = result.warnings.filter(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
- expect(inferredWarnings).toHaveLength(0);
- });
-
- it('should include helpful message in warning', async () => {
- const workflow = {
- nodes: [
- {
- id: 'drive-tool-1',
- name: 'Google Drive Tool',
- type: 'n8n-nodes-base.googleDriveTool',
- typeVersion: 3,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const result = await validator.validateWorkflow(workflow);
-
- const inferredWarning = result.warnings.find(e =>
- (e as any).code === 'INFERRED_TOOL_VARIANT'
- );
-
- expect(inferredWarning).toBeDefined();
- expect(inferredWarning!.message).toContain('inferred as a dynamic AI Tool variant');
- expect(inferredWarning!.message).toContain('nodes-base.googleDrive');
- expect(inferredWarning!.message).toContain('Google Drive');
- expect(inferredWarning!.message).toContain('AI Agent');
- });
- });
-});
diff --git a/tests/unit/services/workflow-validator-with-mocks.test.ts b/tests/unit/services/workflow-validator-with-mocks.test.ts
deleted file mode 100644
index eae6bb1..0000000
--- a/tests/unit/services/workflow-validator-with-mocks.test.ts
+++ /dev/null
@@ -1,513 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { WorkflowValidator } from '@/services/workflow-validator';
-import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
-
-// Mock logger to prevent console output
-vi.mock('@/utils/logger', () => ({
- Logger: vi.fn().mockImplementation(() => ({
- error: vi.fn(),
- warn: vi.fn(),
- info: vi.fn()
- }))
-}));
-
-describe('WorkflowValidator - Simple Unit Tests', () => {
- let validator: WorkflowValidator;
-
- // Create a simple mock repository
- const createMockRepository = (nodeData: Record) => ({
- getNode: vi.fn((type: string) => nodeData[type] || null),
- findSimilarNodes: vi.fn().mockReturnValue([])
- });
-
- // Create a simple mock validator class
- const createMockValidatorClass = (validationResult: any) => ({
- validateWithMode: vi.fn().mockReturnValue(validationResult)
- });
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- describe('Basic validation scenarios', () => {
- it('should pass validation for a webhook workflow with single node', async () => {
- // Arrange
- const nodeData = {
- 'n8n-nodes-base.webhook': {
- type: 'nodes-base.webhook',
- displayName: 'Webhook',
- name: 'webhook',
- version: 1,
- isVersioned: true,
- properties: []
- },
- 'nodes-base.webhook': {
- type: 'nodes-base.webhook',
- displayName: 'Webhook',
- name: 'webhook',
- version: 1,
- isVersioned: true,
- properties: []
- }
- };
-
- const mockRepository = createMockRepository(nodeData);
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Webhook Workflow',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook',
- typeVersion: 1,
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- // Single webhook node should just have a warning about no connections
- expect(result.warnings.some(w => w.message.includes('no connections'))).toBe(true);
- });
-
- it('should fail validation for unknown node types', async () => {
- // Arrange
- const mockRepository = createMockRepository({}); // Empty node data
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Unknown',
- type: 'n8n-nodes-base.unknownNode',
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(false);
- // Check for either the error message or valid being false
- const hasUnknownNodeError = result.errors.some(e =>
- e.message && (e.message.includes('Unknown node type') || e.message.includes('unknown-node-type'))
- );
- expect(result.errors.length > 0 || hasUnknownNodeError).toBe(true);
- });
-
- it('should detect duplicate node names', async () => {
- // Arrange
- const mockRepository = createMockRepository({});
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Duplicate Names',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP Request', // Duplicate name
- type: 'n8n-nodes-base.httpRequest',
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Duplicate node name'))).toBe(true);
- });
-
- it('should validate connections properly', async () => {
- // Arrange
- const nodeData = {
- 'n8n-nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- isVersioned: false,
- properties: []
- },
- 'nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- isVersioned: false,
- properties: []
- },
- 'n8n-nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- version: 2,
- isVersioned: true,
- properties: []
- },
- 'nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- version: 2,
- isVersioned: true,
- properties: []
- }
- };
-
- const mockRepository = createMockRepository(nodeData);
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Connected Workflow',
- nodes: [
- {
- id: '1',
- name: 'Manual Trigger',
- type: 'n8n-nodes-base.manualTrigger',
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Manual Trigger': {
- main: [[{ node: 'Set', type: 'main', index: 0 }]]
- }
- }
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(true);
- expect(result.statistics.validConnections).toBe(1);
- expect(result.statistics.invalidConnections).toBe(0);
- });
-
- it('should detect workflow cycles', async () => {
- // Arrange
- const nodeData = {
- 'n8n-nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- isVersioned: true,
- version: 2,
- properties: []
- },
- 'nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- isVersioned: true,
- version: 2,
- properties: []
- }
- };
-
- const mockRepository = createMockRepository(nodeData);
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Cyclic Workflow',
- nodes: [
- {
- id: '1',
- name: 'Node A',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: '2',
- name: 'Node B',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {
- 'Node A': {
- main: [[{ node: 'Node B', type: 'main', index: 0 }]]
- },
- 'Node B': {
- main: [[{ node: 'Node A', type: 'main', index: 0 }]] // Creates a cycle
- }
- }
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('cycle'))).toBe(true);
- });
-
- it('should handle null workflow gracefully', async () => {
- // Arrange
- const mockRepository = createMockRepository({});
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- // Act
- const result = await validator.validateWorkflow(null as any);
-
- // Assert
- expect(result.valid).toBe(false);
- expect(result.errors[0].message).toContain('workflow is null or undefined');
- });
-
- it('should require connections for multi-node workflows', async () => {
- // Arrange
- const nodeData = {
- 'n8n-nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- properties: []
- },
- 'nodes-base.manualTrigger': {
- type: 'nodes-base.manualTrigger',
- displayName: 'Manual Trigger',
- properties: []
- },
- 'n8n-nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- version: 2,
- isVersioned: true,
- properties: []
- },
- 'nodes-base.set': {
- type: 'nodes-base.set',
- displayName: 'Set',
- version: 2,
- isVersioned: true,
- properties: []
- }
- };
-
- const mockRepository = createMockRepository(nodeData);
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'No Connections',
- nodes: [
- {
- id: '1',
- name: 'Manual Trigger',
- type: 'n8n-nodes-base.manualTrigger',
- position: [250, 300] as [number, number],
- parameters: {}
- },
- {
- id: '2',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {} // No connections between nodes
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.valid).toBe(false);
- expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
- });
-
- it('should validate typeVersion for versioned nodes', async () => {
- // Arrange
- const nodeData = {
- 'n8n-nodes-base.httpRequest': {
- type: 'nodes-base.httpRequest',
- displayName: 'HTTP Request',
- isVersioned: true,
- version: 3, // Latest version is 3
- properties: []
- },
- 'nodes-base.httpRequest': {
- type: 'nodes-base.httpRequest',
- displayName: 'HTTP Request',
- isVersioned: true,
- version: 3,
- properties: []
- }
- };
-
- const mockRepository = createMockRepository(nodeData);
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Version Test',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 2, // Outdated version
- position: [250, 300] as [number, number],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert
- expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true);
- });
-
- it('should normalize and validate nodes-base prefix to find the node', async () => {
- // Arrange - Test that full-form types are normalized to short form to find the node
- // The repository only has the node under the SHORT normalized key (database format)
- const nodeData = {
- 'nodes-base.webhook': { // Repository has it under SHORT form (database format)
- type: 'nodes-base.webhook',
- displayName: 'Webhook',
- isVersioned: true,
- version: 2,
- properties: []
- }
- };
-
- // Mock repository that simulates the normalization behavior
- // After our changes, getNode is called with the already-normalized type (short form)
- const mockRepository = {
- getNode: vi.fn((type: string) => {
- // The validator now normalizes to short form before calling getNode
- // So getNode receives 'nodes-base.webhook'
- if (type === 'nodes-base.webhook') {
- return nodeData['nodes-base.webhook'];
- }
- return null;
- }),
- findSimilarNodes: vi.fn().mockReturnValue([])
- };
-
- const mockValidatorClass = createMockValidatorClass({
- valid: true,
- errors: [],
- warnings: [],
- suggestions: []
- });
-
- validator = new WorkflowValidator(mockRepository as any, mockValidatorClass as any);
-
- const workflow = {
- name: 'Valid Alternative Prefix',
- nodes: [
- {
- id: '1',
- name: 'Webhook',
- type: 'n8n-nodes-base.webhook', // Using the full-form prefix (will be normalized to short)
- position: [250, 300] as [number, number],
- parameters: {},
- typeVersion: 2
- }
- ],
- connections: {}
- };
-
- // Act
- const result = await validator.validateWorkflow(workflow as any);
-
- // Assert - The node should be found through normalization
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
-
- // Verify the repository was called (once with original, once with normalized)
- expect(mockRepository.getNode).toHaveBeenCalled();
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/services/workflow-validator.test.ts b/tests/unit/services/workflow-validator.test.ts
index 923b2f0..2bab631 100644
--- a/tests/unit/services/workflow-validator.test.ts
+++ b/tests/unit/services/workflow-validator.test.ts
@@ -1,574 +1,746 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowValidator } from '@/services/workflow-validator';
+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';
-// Note: The WorkflowValidator has complex dependencies that are difficult to mock
-// with vi.mock() because:
-// 1. It expects NodeRepository instance but EnhancedConfigValidator class
-// 2. The dependencies are imported at module level before mocks can be applied
-//
-// For proper unit testing with mocks, see workflow-validator-simple.test.ts
-// which uses manual mocking approach. This file tests the validator logic
-// without mocks to ensure the implementation works correctly.
-
+// Mock dependencies
+vi.mock('@/database/node-repository');
+vi.mock('@/services/enhanced-config-validator');
+vi.mock('@/services/expression-validator');
vi.mock('@/utils/logger');
describe('WorkflowValidator', () => {
let validator: WorkflowValidator;
+ let mockNodeRepository: NodeRepository;
+ let mockEnhancedConfigValidator: typeof EnhancedConfigValidator;
+
+ const nodeTypes: Record = {
+ 'nodes-base.webhook': { type: 'nodes-base.webhook', displayName: 'Webhook', package: 'n8n-nodes-base', isTrigger: true, version: 2, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.manualTrigger': { type: 'nodes-base.manualTrigger', displayName: 'Manual Trigger', package: 'n8n-nodes-base', isTrigger: true, version: 1, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.set': { type: 'nodes-base.set', displayName: 'Set', package: 'n8n-nodes-base', version: 3, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.code': { type: 'nodes-base.code', displayName: 'Code', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.httpRequest': { type: 'nodes-base.httpRequest', displayName: 'HTTP Request', package: 'n8n-nodes-base', version: 4, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.if': { type: 'nodes-base.if', displayName: 'IF', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main', 'main'], properties: [] },
+ 'nodes-base.filter': { type: 'nodes-base.filter', displayName: 'Filter', package: 'n8n-nodes-base', outputs: ['main', 'main'], properties: [] },
+ 'nodes-base.switch': { type: 'nodes-base.switch', displayName: 'Switch', package: 'n8n-nodes-base', outputs: ['main', 'main', 'main', 'main'], properties: [] },
+ 'nodes-base.slack': { type: 'nodes-base.slack', displayName: 'Slack', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.googleSheets': { type: 'nodes-base.googleSheets', displayName: 'Google Sheets', package: 'n8n-nodes-base', version: 4, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-base.merge': { type: 'nodes-base.merge', displayName: 'Merge', package: 'n8n-nodes-base', outputs: ['main'], properties: [] },
+ 'nodes-base.postgres': { type: 'nodes-base.postgres', displayName: 'Postgres', package: 'n8n-nodes-base', version: 2, isVersioned: true, outputs: ['main'], properties: [] },
+ 'nodes-langchain.agent': { type: 'nodes-langchain.agent', displayName: 'AI Agent', package: '@n8n/n8n-nodes-langchain', version: 1, isVersioned: true, isAITool: true, outputs: ['main'], properties: [] },
+ 'nodes-langchain.lmChatGoogleGemini': { type: 'nodes-langchain.lmChatGoogleGemini', displayName: 'Google Gemini Chat Model', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_languageModel'], properties: [] },
+ 'nodes-langchain.memoryBufferWindow': { type: 'nodes-langchain.memoryBufferWindow', displayName: 'Window Buffer Memory', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_memory'], properties: [] },
+ 'nodes-langchain.embeddingsOpenAi': { type: 'nodes-langchain.embeddingsOpenAi', displayName: 'Embeddings OpenAI', package: '@n8n/n8n-nodes-langchain', outputs: ['ai_embedding'], properties: [] },
+ 'nodes-langchain.openAi': { type: 'nodes-langchain.openAi', displayName: 'OpenAI', package: '@n8n/n8n-nodes-langchain', outputs: ['main'], properties: [] },
+ 'nodes-langchain.textClassifier': { type: 'nodes-langchain.textClassifier', displayName: 'Text Classifier', package: '@n8n/n8n-nodes-langchain', outputs: ['={{}}'], properties: [] },
+ 'nodes-langchain.vectorStoreInMemory': { type: 'nodes-langchain.vectorStoreInMemory', displayName: 'In-Memory Vector Store', package: '@n8n/n8n-nodes-langchain', outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'], properties: [] },
+ 'community.customNode': { type: 'community.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false },
+ };
beforeEach(() => {
vi.clearAllMocks();
- // These tests focus on testing the validation logic without mocking dependencies
- // For tests with mocked dependencies, see workflow-validator-simple.test.ts
- });
+ mockNodeRepository = new NodeRepository({} as any) as any;
+ mockEnhancedConfigValidator = EnhancedConfigValidator as any;
+ if (!mockNodeRepository.getAllNodes) { mockNodeRepository.getAllNodes = vi.fn(); }
+ if (!mockNodeRepository.getNode) { mockNodeRepository.getNode = vi.fn(); }
- describe('constructor', () => {
- it('should instantiate when required dependencies are provided', () => {
- const mockNodeRepository = {} as any;
- const mockEnhancedConfigValidator = {} as any;
-
- const instance = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator);
- expect(instance).toBeDefined();
- });
- });
-
- describe('workflow structure validation', () => {
- it('should validate structure when workflow has basic fields', () => {
- // This is a unit test focused on the structure
- const workflow = {
- name: 'Test Workflow',
- nodes: [
- {
- id: '1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- typeVersion: 1,
- position: [250, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- expect(workflow.nodes).toHaveLength(1);
- expect(workflow.nodes[0].name).toBe('Start');
- });
-
- it('should detect when workflow has no nodes', () => {
- const workflow = {
- nodes: [],
- connections: {}
- };
-
- expect(workflow.nodes).toHaveLength(0);
- });
-
- it('should return error when workflow has duplicate node names', () => {
- // Arrange
- const workflow = {
- name: 'Test Workflow with Duplicates',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP Request', // Duplicate name
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: '3',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [650, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Act - simulate validation logic
- const nodeNames = new Set();
- const duplicates: string[] = [];
-
- for (const node of workflow.nodes) {
- if (nodeNames.has(node.name)) {
- duplicates.push(node.name);
- }
- nodeNames.add(node.name);
+ vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
+ if (nodeType === 'n8n-nodes-custom.customNode') {
+ return { type: 'n8n-nodes-custom.customNode', displayName: 'Custom Node', package: 'n8n-nodes-custom', version: 1, isVersioned: false, properties: [], isAITool: false };
}
+ return nodeTypes[nodeType] || null;
+ });
+ vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
- // Assert
- expect(duplicates).toHaveLength(1);
- expect(duplicates[0]).toBe('HTTP Request');
+ vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
+ errors: [], warnings: [], suggestions: [], mode: 'operation' as const, valid: true, visibleProperties: [], hiddenProperties: [],
+ } as any);
+
+ vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
+ valid: true, errors: [], warnings: [], usedVariables: new Set(), usedNodes: new Set(),
});
- it('should pass when workflow has unique node names', () => {
- // Arrange
- const workflow = {
- name: 'Test Workflow with Unique Names',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request 1',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: '2',
- name: 'HTTP Request 2',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- },
- {
- id: '3',
- name: 'Set',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [650, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
+ validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator);
+ });
- // Act
- const nodeNames = new Set();
- const duplicates: string[] = [];
-
- for (const node of workflow.nodes) {
- if (nodeNames.has(node.name)) {
- duplicates.push(node.name);
- }
- nodeNames.add(node.name);
+ // โโโ Workflow Structure Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('validateWorkflow', () => {
+ it('should validate a minimal valid workflow', async () => {
+ const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build();
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ expect(result.statistics.totalNodes).toBe(1);
+ expect(result.statistics.enabledNodes).toBe(1);
+ expect(result.statistics.triggerNodes).toBe(1);
+ });
+
+ it('should validate a workflow with all options disabled', async () => {
+ const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build();
+ const result = await validator.validateWorkflow(workflow as any, { validateNodes: false, validateConnections: false, validateExpressions: false });
+ expect(result.valid).toBe(true);
+ expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
+ expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
+ });
+
+ it('should handle validation errors gracefully', async () => {
+ const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build();
+ vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { throw new Error('Database error'); });
+ const result = await validator.validateWorkflow(workflow as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true);
+ });
+
+ it('should use different validation profiles', async () => {
+ const workflow = createWorkflow('Test Workflow').addWebhookNode({ name: 'Webhook' }).build();
+ for (const profile of ['minimal', 'runtime', 'ai-friendly', 'strict'] as const) {
+ const result = await validator.validateWorkflow(workflow as any, { profile });
+ expect(result).toBeDefined();
+ expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith(expect.any(String), expect.any(Object), expect.any(Array), 'operation', profile);
}
-
- // Assert
- expect(duplicates).toHaveLength(0);
- expect(nodeNames.size).toBe(3);
});
- it('should handle edge case when node names differ only by case', () => {
- // Arrange
- const workflow = {
- name: 'Test Workflow with Case Variations',
- nodes: [
- {
- id: '1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: '2',
- name: 'http request', // Different case - should be allowed
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [450, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
+ it('should handle null workflow gracefully', async () => {
+ const result = await validator.validateWorkflow(null as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
+ });
- // Act
- const nodeNames = new Set();
- const duplicates: string[] = [];
-
- for (const node of workflow.nodes) {
- if (nodeNames.has(node.name)) {
- duplicates.push(node.name);
- }
- nodeNames.add(node.name);
+ it('should handle undefined workflow gracefully', async () => {
+ const result = await validator.validateWorkflow(undefined as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('Invalid workflow structure'))).toBe(true);
+ });
+
+ it('should handle workflow with null nodes array', async () => {
+ const result = await validator.validateWorkflow({ nodes: null, connections: {} } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('nodes must be an array'))).toBe(true);
+ });
+
+ it('should handle workflow with null connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [], connections: null } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('connections must be an object'))).toBe(true);
+ });
+
+ it('should handle non-array nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: 'not-an-array', connections: {} } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].message).toContain('nodes must be an array');
+ });
+
+ it('should handle non-object connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [], connections: [] } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].message).toContain('connections must be an object');
+ });
+
+ it('should handle nodes with null/undefined properties', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: null, type: 'n8n-nodes-base.set', position: [0, 0], parameters: undefined }], connections: {} } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should handle circular references in workflow object', async () => {
+ const workflow: any = { nodes: [], connections: {} };
+ workflow.circular = workflow;
+ await expect(validator.validateWorkflow(workflow)).resolves.toBeDefined();
+ });
+ });
+
+ describe('validateWorkflowStructure', () => {
+ it('should error when nodes array is missing', async () => {
+ const result = await validator.validateWorkflow({ connections: {} } as any);
+ expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
+ });
+
+ it('should error when connections object is missing', async () => {
+ const result = await validator.validateWorkflow({ nodes: [] } as any);
+ expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
+ });
+
+ it('should warn when workflow has no nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [], connections: {} } as any);
+ expect(result.valid).toBe(true);
+ expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined');
+ });
+
+ it('should error for single non-webhook node workflow', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
+ });
+
+ it('should warn for webhook without connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }], connections: {} } as any);
+ expect(result.valid).toBe(true);
+ expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
+ });
+
+ it('should error for multi-node workflow without connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
+ });
+
+ it('should detect duplicate node names', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
+ });
+
+ it('should detect duplicate node IDs', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook1', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '1', name: 'Webhook2', type: 'n8n-nodes-base.webhook', position: [300, 100], parameters: {} }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
+ });
+
+ it('should count trigger nodes correctly', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Schedule', type: 'n8n-nodes-base.scheduleTrigger', position: [100, 300], parameters: {} }, { id: '3', name: 'Manual', type: 'n8n-nodes-base.manualTrigger', position: [100, 500], parameters: {} }], connections: {} } as any);
+ expect(result.statistics.triggerNodes).toBe(3);
+ });
+
+ it('should warn when no trigger nodes exist', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [300, 100], parameters: {} }], connections: { 'Set': { main: [[{ node: 'Code', type: 'main', index: 0 }]] } } } as any);
+ expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
+ });
+
+ it('should not count disabled nodes in enabledNodes count', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any);
+ expect(result.statistics.totalNodes).toBe(2);
+ expect(result.statistics.enabledNodes).toBe(1);
+ });
+
+ it('should handle very large workflows', async () => {
+ const nodes = Array(1000).fill(null).map((_, i) => ({ id: `node${i}`, name: `Node ${i}`, type: 'n8n-nodes-base.set', position: [i * 100, 0] as [number, number], parameters: {} }));
+ const connections: any = {};
+ for (let i = 0; i < 999; i++) { connections[`Node ${i}`] = { main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]] }; }
+ const start = Date.now();
+ const result = await validator.validateWorkflow({ nodes, connections } as any);
+ expect(result).toBeDefined();
+ expect(Date.now() - start).toBeLessThan(process.env.CI ? 10000 : 5000);
+ });
+
+ it('should handle invalid position values', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'InvalidPos', type: 'n8n-nodes-base.set', position: 'invalid' as any, parameters: {} }, { id: '2', name: 'NaNPos', type: 'n8n-nodes-base.set', position: [NaN, NaN] as [number, number], parameters: {} }], connections: {} } as any);
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should handle very long node names', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'A'.repeat(1000), type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} }], connections: {} } as any);
+ expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true);
+ });
+ });
+
+ // โโโ Node Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('validateAllNodes', () => {
+ it('should skip disabled nodes', async () => {
+ await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, disabled: true }], connections: {} } as any);
+ expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
+ });
+
+ it('should accept both nodes-base and n8n-nodes-base prefixes', async () => {
+ (mockNodeRepository.getNode as any) = vi.fn((type: string) => type === 'nodes-base.webhook' ? { nodeType: 'nodes-base.webhook', displayName: 'Webhook', properties: [], isVersioned: false } : null);
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.valid).toBe(true);
+ });
+
+ it('should try normalized types for n8n-nodes-base', async () => {
+ await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
+ });
+
+ it('should validate typeVersion but skip parameter validation for langchain nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1, position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
+ expect(result.errors.filter(e => e.message.includes('typeVersion'))).toEqual([]);
+ });
+
+ it('should catch invalid typeVersion for langchain nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 99999, position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes('typeVersion 99999 exceeds maximum'))).toBe(true);
+ });
+
+ it('should error for missing typeVersion on versioned nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes("Missing required property 'typeVersion'"))).toBe(true);
+ });
+
+ it('should error for invalid typeVersion', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 'invalid' as any }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
+ });
+
+ it('should warn for outdated typeVersion', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 1 }], connections: {} } as any);
+ expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
+ });
+
+ it('should error for typeVersion exceeding maximum', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 10 }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
+ });
+
+ it('should add node validation errors and warnings', async () => {
+ vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], suggestions: [], mode: 'operation' as const, valid: false, visibleProperties: [], hiddenProperties: [] } as any);
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
+ expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
+ });
+
+ it('should handle node validation failures gracefully', async () => {
+ vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => { throw new Error('Validation error'); });
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, typeVersion: 4 }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
+ });
+
+ it('should handle repository errors gracefully', async () => {
+ vi.mocked(mockNodeRepository.getNode).mockImplementation(() => { throw new Error('Database connection failed'); });
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }], connections: {} } as any);
+ expect(result).toHaveProperty('valid');
+ expect(Array.isArray(result.errors)).toBe(true);
+ });
+ });
+
+ // โโโ Connection Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('validateConnections', () => {
+ it('should validate valid connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.statistics.validConnections).toBe(1);
+ expect(result.statistics.invalidConnections).toBe(0);
+ });
+
+ it('should error for connection from non-existent node', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: { 'NonExistent': { main: [[{ node: 'Webhook', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
+ });
+
+ it('should error when using node ID instead of name in source', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'webhook-id': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes("Connection uses node ID 'webhook-id' instead of node name 'Webhook'"))).toBe(true);
+ });
+
+ it('should error for connection to non-existent node', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
+ });
+
+ it('should error when using node ID instead of name in target', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: 'webhook-id', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: 'set-id', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'set-id', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes("Connection target uses node ID 'set-id' instead of node name 'Set'"))).toBe(true);
+ });
+
+ it('should warn for connection to disabled node', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {}, disabled: true }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
+ });
+
+ it('should detect self-referencing nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'SelfLoop', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }], connections: { 'SelfLoop': { main: [[{ node: 'SelfLoop', type: 'main', index: 0 }]] } } } as any);
+ expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true);
+ });
+
+ it('should handle invalid connection formats', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }], connections: { 'Node1': { main: 'invalid-format' as any } } } as any);
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+
+ it('should handle negative output indices', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: -1 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true);
+ });
+
+ it('should validate error outputs', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: { 'HTTP': { error: [[{ node: 'Error Handler', type: 'main', index: 0 }]] } } } as any);
+ expect(result.statistics.validConnections).toBe(1);
+ });
+
+ it('should validate AI tool connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }, { id: '2', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [300, 100], parameters: {} }], connections: { 'Agent': { ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]] } } } as any);
+ expect(result.statistics.validConnections).toBe(1);
+ });
+
+ it('should warn for orphaned nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [500, 100], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true);
+ });
+
+ it('should detect cycles in workflow', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }, { id: '2', name: 'Node2', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }, { id: '3', name: 'Node3', type: 'n8n-nodes-base.set', position: [500, 100], parameters: {} }], connections: { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]] }, 'Node2': { main: [[{ node: 'Node3', type: 'main', index: 0 }]] }, 'Node3': { main: [[{ node: 'Node1', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
+ });
+
+ it('should handle null connections properly', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'IF', type: 'n8n-nodes-base.if', position: [100, 100], parameters: {}, typeVersion: 2 }, { id: '2', name: 'True Branch', type: 'n8n-nodes-base.set', position: [300, 50], parameters: {}, typeVersion: 3 }], connections: { 'IF': { main: [[{ node: 'True Branch', type: 'main', index: 0 }], null] } } } as any);
+ expect(result.statistics.validConnections).toBe(1);
+ });
+
+ it('should continue validation after encountering errors', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: null as any, type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'Valid', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }, { id: '3', name: 'AlsoValid', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Valid': { main: [[{ node: 'AlsoValid', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.length).toBeGreaterThan(0);
+ expect(result.statistics.validConnections).toBeGreaterThan(0);
+ });
+ });
+
+ // โโโ Expression Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('validateExpressions', () => {
+ it('should validate expressions in node parameters', async () => {
+ await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: { values: { string: [{ name: 'field', value: '={{ $json.data }}' }] } } }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(expect.objectContaining({ values: expect.any(Object) }), expect.objectContaining({ currentNodeName: 'Set', hasInputData: true }));
+ });
+
+ it('should add expression errors to result', async () => {
+ vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ valid: false, errors: ['Invalid expression syntax'], warnings: ['Deprecated variable usage'], usedVariables: new Set(['$json']), usedNodes: new Set() });
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ invalid }}' } }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
+ expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
+ });
+
+ it('should skip expression validation for disabled nodes', async () => {
+ await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: { value: '={{ $json.data }}' }, disabled: true }], connections: {} } as any);
+ expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
+ });
+
+ it('should skip expression validation when option is false', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Node1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: { value: '{{ $json.data }}' } }], connections: {} } as any, { validateExpressions: false });
+ expect(result.statistics.expressionsValidated).toBe(0);
+ });
+ });
+
+ // โโโ Expression Format Detection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Expression Format Detection', () => {
+ it('should detect missing = prefix in simple expressions', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0], parameters: { fromEmail: '{{ $env.SENDER_EMAIL }}', toEmail: 'user@example.com', subject: 'Test' }, typeVersion: 2.1 }], connections: {} } as any);
+ expect(result.valid).toBe(false);
+ const formatErrors = result.errors.filter(e => e.message.includes('Expression format error'));
+ expect(formatErrors).toHaveLength(1);
+ expect(formatErrors[0].message).toContain('fromEmail');
+ expect(formatErrors[0].message).toContain('={{ $env.SENDER_EMAIL }}');
+ });
+
+ it('should detect missing resource locator format for GitHub fields', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0], parameters: { operation: 'createComment', owner: '{{ $vars.GITHUB_OWNER }}', repository: '{{ $vars.GITHUB_REPO }}', issueNumber: 123, body: 'Test' }, typeVersion: 1.1 }], connections: {} } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.find(e => e.message.includes('owner'))?.message).toContain('resource locator format');
+ });
+
+ it('should detect mixed content without prefix', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: { url: 'https://api.example.com/{{ $json.endpoint }}' }, typeVersion: 4 }], connections: {} } as any);
+ const urlError = result.errors.find(e => e.message.includes('Expression format') && e.message.includes('url'));
+ expect(urlError).toBeTruthy();
+ expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}');
+ });
+
+ it('should accept properly formatted expressions', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Send Email', type: 'n8n-nodes-base.emailSend', position: [0, 0], parameters: { fromEmail: '={{ $env.SENDER_EMAIL }}', toEmail: 'user@example.com', subject: '=Test {{ $json.type }}' }, typeVersion: 2.1 }], connections: {} } as any);
+ expect(result.errors.filter(e => e.message.includes('Expression format'))).toHaveLength(0);
+ });
+
+ it('should accept resource locator format', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GitHub', type: 'n8n-nodes-base.github', position: [0, 0], parameters: { operation: 'createComment', owner: { __rl: true, value: '={{ $vars.GITHUB_OWNER }}', mode: 'expression' }, repository: { __rl: true, value: '={{ $vars.GITHUB_REPO }}', mode: 'expression' }, issueNumber: 123, body: '=Test from {{ $json.author }}' }, typeVersion: 1.1 }], connections: {} } as any);
+ expect(result.errors.filter(e => e.message.includes('Expression format'))).toHaveLength(0);
+ });
+
+ it('should provide clear fix examples in error messages', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Process Data', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: { url: 'https://api.example.com/users/{{ $json.userId }}' }, typeVersion: 4 }], connections: {} } as any);
+ const error = result.errors.find(e => e.message.includes('Expression format'));
+ expect(error?.message).toContain('Current (incorrect):');
+ expect(error?.message).toContain('Fixed (correct):');
+ });
+ });
+
+ // โโโ Error Handler Detection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Error Handler Detection', () => {
+ it('should identify error handlers by node name patterns', async () => {
+ for (const errorName of ['Error Handler', 'Handle Error', 'Catch Exception', 'Failure Response']) {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: errorName, type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Source': { main: [[{ node: 'Success', type: 'main', index: 0 }, { node: errorName, type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration') && e.message.includes(errorName))).toBe(true);
}
-
- // Assert - case-sensitive comparison should allow both
- expect(duplicates).toHaveLength(0);
- expect(nodeNames.size).toBe(2);
- });
- });
-
- describe('connection validation logic', () => {
- it('should validate structure when connections are properly formatted', () => {
- const connections = {
- 'Node1': {
- main: [[{ node: 'Node2', type: 'main', index: 0 }]]
- }
- };
-
- expect(connections['Node1']).toBeDefined();
- expect(connections['Node1'].main).toHaveLength(1);
});
- it('should detect when node has self-referencing connection', () => {
- const connections = {
- 'Node1': {
- main: [[{ node: 'Node1', type: 'main', index: 0 }]]
- }
- };
-
- const targetNode = connections['Node1'].main![0][0].node;
- expect(targetNode).toBe('Node1');
- });
- });
-
- describe('node validation logic', () => {
- it('should validate when node has all required fields', () => {
- const node = {
- id: '1',
- name: 'Test Node',
- type: 'n8n-nodes-base.function',
- position: [100, 100],
- parameters: {}
- };
-
- expect(node.id).toBeDefined();
- expect(node.name).toBeDefined();
- expect(node.type).toBeDefined();
- expect(node.position).toHaveLength(2);
- });
- });
-
- describe('expression validation logic', () => {
- it('should identify expressions when text contains n8n syntax', () => {
- const expressions = [
- '{{ $json.field }}',
- 'regular text',
- '{{ $node["Webhook"].json.data }}'
- ];
-
- const n8nExpressions = expressions.filter(expr =>
- expr.includes('{{') && expr.includes('}}')
- );
-
- expect(n8nExpressions).toHaveLength(2);
- });
- });
-
- describe('AI tool validation', () => {
- it('should identify AI nodes when type includes langchain', () => {
- const nodes = [
- { type: '@n8n/n8n-nodes-langchain.agent' },
- { type: 'n8n-nodes-base.httpRequest' },
- { type: '@n8n/n8n-nodes-langchain.llm' }
- ];
-
- const aiNodes = nodes.filter(node =>
- node.type.includes('langchain')
- );
-
- expect(aiNodes).toHaveLength(2);
- });
- });
-
- describe('validation options', () => {
- it('should support profiles when different validation levels are needed', () => {
- const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'];
-
- expect(profiles).toContain('minimal');
- expect(profiles).toContain('runtime');
- });
- });
-
- describe('duplicate node ID validation', () => {
- it('should detect duplicate node IDs and provide helpful context', () => {
- const workflow = {
- name: 'Test Workflow with Duplicate IDs',
- nodes: [
- {
- id: 'abc123',
- name: 'First Node',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: 'abc123', // Duplicate ID
- name: 'Second Node',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Simulate validation logic
- const nodeIds = new Set();
- const nodeIdToIndex = new Map();
- const errors: Array<{ message: string }> = [];
-
- for (let i = 0; i < workflow.nodes.length; i++) {
- const node = workflow.nodes[i];
- if (nodeIds.has(node.id)) {
- const firstNodeIndex = nodeIdToIndex.get(node.id);
- const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
-
- errors.push({
- message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")`
- });
- } else {
- nodeIds.add(node.id);
- nodeIdToIndex.set(node.id, i);
- }
+ it('should not flag success node names as error handlers', async () => {
+ for (const name of ['Process Data', 'Transform', 'Normal Flow']) {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Source', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'First', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: name, type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Source': { main: [[{ node: 'First', type: 'main', index: 0 }, { node: name, type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('Incorrect error output configuration'))).toBe(false);
}
+ });
+ it('should generate valid JSON in error messages', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'API Call', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Error Handler', type: 'n8n-nodes-base.respondToWebhook', position: [200, 100], parameters: {} }], connections: { 'API Call': { main: [[{ node: 'Success', type: 'main', index: 0 }, { node: 'Error Handler', type: 'main', index: 0 }]] } } } as any);
+ const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration'));
+ expect(errorMsg).toBeDefined();
+ expect(errorMsg!.message).toContain('INCORRECT (current):');
+ expect(errorMsg!.message).toContain('CORRECT (should be):');
+ });
+ });
+
+ // โโโ onError Property Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('onError Property Validation', () => {
+ it('should validate onError property combinations', async () => {
+ // onError set but no error connections -> error
+ const r1 = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {}, onError: 'continueErrorOutput' }, { id: '2', name: 'Next', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Next', type: 'main', index: 0 }]] } } } as any);
+ expect(r1.errors.some(e => e.message.includes("has onError: 'continueErrorOutput' but no error output connections"))).toBe(true);
+
+ // error connections but no onError -> warning
+ const r2 = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {} }, { id: '2', name: 'Success', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'ErrH', type: 'n8n-nodes-base.set', position: [200, 100], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Success', type: 'main', index: 0 }], [{ node: 'ErrH', type: 'main', index: 0 }]] } } } as any);
+ expect(r2.warnings.some(w => w.message.includes('error output connections in main[1] but missing onError'))).toBe(true);
+ });
+
+ it('should only flag continueErrorOutput without error connections', async () => {
+ for (const val of ['continueRegularOutput', 'stopWorkflow']) {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Test', type: 'n8n-nodes-base.httpRequest', position: [0, 0], parameters: {}, onError: val }, { id: '2', name: 'Next', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }], connections: { 'Test': { main: [[{ node: 'Next', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.some(e => e.message.includes('but no error output connections'))).toBe(false);
+ }
+ });
+ });
+
+ // โโโ Workflow Patterns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('checkWorkflowPatterns', () => {
+ it('should suggest error handling for large workflows', async () => {
+ const builder = createWorkflow('Large');
+ for (let i = 0; i < 5; i++) builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` });
+ expect((await validator.validateWorkflow(builder.build() as any)).warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
+ });
+
+ it('should warn about long linear chains', async () => {
+ const builder = createWorkflow('Linear');
+ const names: string[] = [];
+ for (let i = 0; i < 12; i++) { const n = `Node${i}`; builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: n }); names.push(n); }
+ builder.connectSequentially(names);
+ expect((await validator.validateWorkflow(builder.build() as any)).warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
+ });
+
+ it('should warn about AI agents without tools', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
+ });
+
+ it('should NOT warn about AI agents WITH tools', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Tool', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [300, 100], parameters: {} }], connections: { 'Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any);
+ expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(false);
+ });
+ });
+
+ // โโโ Node Error Handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('checkNodeErrorHandling', () => {
+ it('should error when node-level properties are inside parameters', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], typeVersion: 4, parameters: { url: 'https://api.example.com', onError: 'continueRegularOutput', retryOnFail: true, credentials: {} } }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
+ });
+
+ it('should validate onError property values', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, onError: 'invalidValue' as any }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
+ });
+
+ it('should warn about deprecated continueOnFail', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true }], connections: {} } as any);
+ expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
+ });
+
+ it('should error for conflicting error handling properties', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, continueOnFail: true, onError: 'continueRegularOutput' }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
+ });
+
+ it('should validate retry configuration', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', position: [100, 100], parameters: {}, retryOnFail: true, maxTries: 'invalid' as any, waitBetweenTries: -1000 }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
+ expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
+ });
+
+ it('should validate other node-level properties', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {}, typeVersion: 3, alwaysOutputData: 'invalid' as any, executeOnce: 'invalid' as any, disabled: 'invalid' as any, notesInFlow: 'invalid' as any, notes: 123 as any }], connections: {} } as any);
+ expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
+ expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true);
+ expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true);
+ });
+ });
+
+ // โโโ Trigger Reachability โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Trigger reachability', () => {
+ it('should flag disconnected subgraph as unreachable', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} }, { id: '2', name: 'Connected', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Island1', type: 'n8n-nodes-base.code', position: [0, 300], parameters: {} }, { id: '4', name: 'Island2', type: 'n8n-nodes-base.set', position: [200, 300], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Connected', type: 'main', index: 0 }]] }, 'Island1': { main: [[{ node: 'Island2', type: 'main', index: 0 }]] } } } as any);
+ const unreachable = result.warnings.filter(w => w.message.includes('not reachable from any trigger'));
+ expect(unreachable.length).toBe(2);
+ });
+
+ it('should not flag disabled nodes or sticky notes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} }, { id: '3', name: 'Disabled', type: 'n8n-nodes-base.code', position: [500, 500], parameters: {}, disabled: true }, { id: '4', name: 'Note', type: 'n8n-nodes-base.stickyNote', position: [500, 600], parameters: {} }], connections: { 'Webhook': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.warnings.filter(w => w.nodeName === 'Disabled' || w.nodeName === 'Note')).toHaveLength(0);
+ });
+ });
+
+ // โโโ Tool Variant Validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Tool Variant Validation', () => {
+ let toolVariantRepo: NodeRepository;
+
+ beforeEach(() => {
+ toolVariantRepo = { getNode: vi.fn((t: string) => {
+ const m: Record = {
+ 'nodes-base.supabase': { nodeType: 'nodes-base.supabase', displayName: 'Supabase', isAITool: true, hasToolVariant: true, isToolVariant: false, properties: [] },
+ 'nodes-base.supabaseTool': { nodeType: 'nodes-base.supabaseTool', displayName: 'Supabase Tool', isAITool: true, hasToolVariant: false, isToolVariant: true, toolVariantOf: 'nodes-base.supabase', properties: [] },
+ 'nodes-langchain.toolCalculator': { nodeType: 'nodes-langchain.toolCalculator', displayName: 'Calculator', isAITool: true, properties: [] },
+ 'nodes-base.httpRequest': { nodeType: 'nodes-base.httpRequest', displayName: 'HTTP Request', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] },
+ 'nodes-base.googleDrive': { nodeType: 'nodes-base.googleDrive', displayName: 'Google Drive', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] },
+ 'nodes-base.googleSheets': { nodeType: 'nodes-base.googleSheets', displayName: 'Google Sheets', isAITool: false, hasToolVariant: false, isToolVariant: false, properties: [] },
+ 'nodes-langchain.agent': { nodeType: 'nodes-langchain.agent', displayName: 'AI Agent', properties: [] },
+ };
+ return m[t] || null;
+ }) } as any;
+ validator = new WorkflowValidator(toolVariantRepo, mockEnhancedConfigValidator);
+ });
+
+ it('should pass for langchain tool nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Calc', type: 'n8n-nodes-langchain.toolCalculator', typeVersion: 1.2, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { Calc: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any);
+ expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0);
+ });
+
+ it('should pass for Tool variant nodes', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase Tool', type: 'n8n-nodes-base.supabaseTool', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { 'Supabase Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any);
+ expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0);
+ });
+
+ it('should fail when base node is used instead of Tool variant', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase', type: 'n8n-nodes-base.supabase', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { Supabase: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any);
+ const errors = result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL');
expect(errors).toHaveLength(1);
- expect(errors[0].message).toContain('Duplicate node ID: "abc123"');
- expect(errors[0].message).toContain('index 1');
- expect(errors[0].message).toContain('Second Node');
- expect(errors[0].message).toContain('n8n-nodes-base.set');
- expect(errors[0].message).toContain('index 0');
- expect(errors[0].message).toContain('First Node');
+ expect((errors[0] as any).fix?.suggestedType).toBe('n8n-nodes-base.supabaseTool');
});
- it('should include UUID generation example in error message context', () => {
- const workflow = {
- name: 'Test',
- nodes: [
- { id: 'dup', name: 'A', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [0, 0], parameters: {} },
- { id: 'dup', name: 'B', type: 'n8n-nodes-base.webhook', typeVersion: 1, position: [0, 0], parameters: {} }
- ],
- connections: {}
- };
-
- // Error message should contain UUID example pattern
- const expectedPattern = /crypto\.randomUUID\(\)/;
- // This validates that our implementation uses the pattern
- expect(expectedPattern.test('crypto.randomUUID()')).toBe(true);
+ it('should not error for base nodes without ai_tool connections', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Supabase', type: 'n8n-nodes-base.supabase', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 1, position: [450, 300], parameters: {} }], connections: { Supabase: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } } as any);
+ expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0);
});
- it('should detect multiple nodes with the same duplicate ID', () => {
- // Edge case: Three or more nodes with the same ID
- const workflow = {
- name: 'Test Workflow with Multiple Duplicates',
- nodes: [
- {
- id: 'shared-id',
- name: 'First Node',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: 'shared-id', // Duplicate 1
- name: 'Second Node',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- },
- {
- id: 'shared-id', // Duplicate 2
- name: 'Third Node',
- type: 'n8n-nodes-base.code',
- typeVersion: 1,
- position: [650, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Simulate validation logic
- const nodeIds = new Set();
- const nodeIdToIndex = new Map();
- const errors: Array<{ message: string }> = [];
-
- for (let i = 0; i < workflow.nodes.length; i++) {
- const node = workflow.nodes[i];
- if (nodeIds.has(node.id)) {
- const firstNodeIndex = nodeIdToIndex.get(node.id);
- const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
-
- errors.push({
- message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")`
- });
- } else {
- nodeIds.add(node.id);
- nodeIdToIndex.set(node.id, i);
- }
- }
-
- // Should report 2 errors (nodes at index 1 and 2 both conflict with node at index 0)
- expect(errors).toHaveLength(2);
- expect(errors[0].message).toContain('index 1');
- expect(errors[0].message).toContain('Second Node');
- expect(errors[1].message).toContain('index 2');
- expect(errors[1].message).toContain('Third Node');
+ it('should not error when base node without Tool variant uses ai_tool', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'HTTP', type: 'n8n-nodes-base.httpRequest', typeVersion: 1, position: [250, 300], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', typeVersion: 1.7, position: [450, 300], parameters: {} }], connections: { 'HTTP': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] } } } as any);
+ expect(result.errors.filter(e => e.code === 'WRONG_NODE_TYPE_FOR_AI_TOOL')).toHaveLength(0);
+ expect(result.errors.filter(e => e.code === 'INVALID_AI_TOOL_SOURCE').length).toBeGreaterThan(0);
});
- it('should handle duplicate IDs with same node type', () => {
- // Edge case: Both nodes are the same type
- const workflow = {
- name: 'Test Workflow with Same Type Duplicates',
- nodes: [
- {
- id: 'duplicate-slack',
- name: 'Slack Send 1',
- type: 'n8n-nodes-base.slack',
- typeVersion: 2,
- position: [250, 300],
- parameters: {}
- },
- {
- id: 'duplicate-slack',
- name: 'Slack Send 2',
- type: 'n8n-nodes-base.slack',
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Simulate validation logic
- const nodeIds = new Set();
- const nodeIdToIndex = new Map();
- const errors: Array<{ message: string }> = [];
-
- for (let i = 0; i < workflow.nodes.length; i++) {
- const node = workflow.nodes[i];
- if (nodeIds.has(node.id)) {
- const firstNodeIndex = nodeIdToIndex.get(node.id);
- const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
-
- errors.push({
- message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")`
- });
- } else {
- nodeIds.add(node.id);
- nodeIdToIndex.set(node.id, i);
- }
- }
-
- expect(errors).toHaveLength(1);
- expect(errors[0].message).toContain('Duplicate node ID: "duplicate-slack"');
- expect(errors[0].message).toContain('Slack Send 2');
- expect(errors[0].message).toContain('Slack Send 1');
- // Both should show the same type
- expect(errors[0].message).toMatch(/n8n-nodes-base\.slack.*n8n-nodes-base\.slack/s);
+ it('should infer googleDriveTool when googleDrive exists', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'GDT', type: 'n8n-nodes-base.googleDriveTool', typeVersion: 3, position: [250, 300], parameters: {} }], connections: {} } as any);
+ expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(0);
+ expect(result.warnings.filter(e => (e as any).code === 'INFERRED_TOOL_VARIANT')).toHaveLength(1);
});
- it('should handle duplicate IDs with empty node names gracefully', () => {
- // Edge case: Empty string node names
- const workflow = {
- name: 'Test Workflow with Empty Names',
- nodes: [
- {
- id: 'empty-name-id',
- name: '',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: 'empty-name-id',
- name: '',
- type: 'n8n-nodes-base.set',
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Simulate validation logic with safe fallback
- const nodeIds = new Set();
- const nodeIdToIndex = new Map();
- const errors: Array<{ message: string }> = [];
-
- for (let i = 0; i < workflow.nodes.length; i++) {
- const node = workflow.nodes[i];
- if (nodeIds.has(node.id)) {
- const firstNodeIndex = nodeIdToIndex.get(node.id);
- const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
-
- errors.push({
- message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")`
- });
- } else {
- nodeIds.add(node.id);
- nodeIdToIndex.set(node.id, i);
- }
- }
-
- // Should not crash and should use empty string in message
- expect(errors).toHaveLength(1);
- expect(errors[0].message).toContain('Duplicate node ID');
- expect(errors[0].message).toContain('name: ""');
+ it('should error for unknownNodeTool when base does not exist', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Unknown', type: 'n8n-nodes-base.nonExistentNodeTool', typeVersion: 1, position: [250, 300], parameters: {} }], connections: {} } as any);
+ expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(1);
});
- it('should handle duplicate IDs with missing node properties', () => {
- // Edge case: Node with undefined type or name
- const workflow = {
- name: 'Test Workflow with Missing Properties',
- nodes: [
- {
- id: 'missing-props',
- name: 'Valid Node',
- type: 'n8n-nodes-base.httpRequest',
- typeVersion: 3,
- position: [250, 300],
- parameters: {}
- },
- {
- id: 'missing-props',
- name: undefined as any,
- type: undefined as any,
- typeVersion: 2,
- position: [450, 300],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- // Simulate validation logic with safe fallbacks
- const nodeIds = new Set();
- const nodeIdToIndex = new Map();
- const errors: Array<{ message: string }> = [];
-
- for (let i = 0; i < workflow.nodes.length; i++) {
- const node = workflow.nodes[i];
- if (nodeIds.has(node.id)) {
- const firstNodeIndex = nodeIdToIndex.get(node.id);
- const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
-
- errors.push({
- message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}")`
- });
- } else {
- nodeIds.add(node.id);
- nodeIdToIndex.set(node.id, i);
- }
- }
-
- // Should use fallback values without crashing
- expect(errors).toHaveLength(1);
- expect(errors[0].message).toContain('Duplicate node ID: "missing-props"');
- expect(errors[0].message).toContain('name: "undefined"');
- expect(errors[0].message).toContain('type: "undefined"');
+ it('should prefer database record over inference for supabaseTool', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'ST', type: 'n8n-nodes-base.supabaseTool', typeVersion: 1, position: [250, 300], parameters: {} }], connections: {} } as any);
+ expect(result.errors.filter(e => e.message?.includes('Unknown node type'))).toHaveLength(0);
+ expect(result.warnings.filter(e => (e as any).code === 'INFERRED_TOOL_VARIANT')).toHaveLength(0);
});
});
-});
\ No newline at end of file
+
+ // โโโ AI Sub-Node Main Connection Detection โโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('AI Sub-Node Main Connection Detection', () => {
+ function makeAIWorkflow(sourceType: string, sourceName: string) {
+ return { nodes: [{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, { id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} }, { id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} }], connections: { 'Manual Trigger': { main: [[{ node: sourceName, type: 'main', index: 0 }]] }, [sourceName]: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } } };
+ }
+
+ it('should flag LLM node connected via main', async () => {
+ const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.lmChatGoogleGemini', 'Gemini') as any);
+ const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
+ expect(error).toBeDefined();
+ expect(error!.message).toContain('ai_languageModel');
+ });
+
+ it('should flag memory node connected via main', async () => {
+ const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.memoryBufferWindow', 'Memory') as any);
+ expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')?.message).toContain('ai_memory');
+ });
+
+ it('should flag embeddings node connected via main', async () => {
+ const result = await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.embeddingsOpenAi', 'Embed') as any);
+ expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')?.message).toContain('ai_embedding');
+ });
+
+ it('should NOT flag regular langchain nodes via main', async () => {
+ expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.agent', 'Agent') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
+ expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.openAi', 'OpenAI') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
+ });
+
+ it('should NOT flag dynamic-output nodes', async () => {
+ expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.textClassifier', 'TC') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
+ expect((await validator.validateWorkflow(makeAIWorkflow('@n8n/n8n-nodes-langchain.vectorStoreInMemory', 'VS') as any)).errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
+ });
+
+ it('should NOT flag sub-node connected via correct AI type', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} }, { id: '2', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [200, 0], parameters: {} }, { id: '3', name: 'Gemini', type: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} }], connections: { 'Trigger': { main: [[{ node: 'Agent', type: 'main', index: 0 }]] }, 'Gemini': { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] } } } as any);
+ expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
+ });
+ });
+
+ // โโโ Suggestions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('generateSuggestions', () => {
+ it('should suggest adding trigger', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [100, 100], parameters: {} }], connections: {} } as any);
+ expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
+ });
+
+ it('should provide connection examples', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {} }, { id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [300, 100], parameters: {} }], connections: {} } as any);
+ expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
+ });
+
+ it('should suggest breaking up large workflows', async () => {
+ const builder = createWorkflow('Large');
+ for (let i = 0; i < 25; i++) builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `N${i}` });
+ expect((await validator.validateWorkflow(builder.build() as any)).suggestions.some(s => s.includes('Consider breaking this workflow'))).toBe(true);
+ });
+ });
+
+ // โโโ Validation Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Validation Options', () => {
+ it('should validate connections only', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'N1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }, { id: '2', name: 'N2', type: 'n8n-nodes-base.set', position: [100, 0], parameters: {} }], connections: { 'N1': { main: [[{ node: 'N2', type: 'main', index: 0 }]] } } } as any, { validateNodes: false, validateExpressions: false, validateConnections: true });
+ expect(result.statistics.validConnections).toBe(1);
+ });
+
+ it('should validate expressions only', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'N1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: { value: '{{ $json.data }}' } }], connections: {} } as any, { validateNodes: false, validateExpressions: true, validateConnections: false });
+ expect(result.statistics.expressionsValidated).toBeGreaterThan(0);
+ });
+ });
+
+ // โโโ Integration Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe('Integration Tests', () => {
+ it('should validate a complex workflow with multiple issues', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [100, 100], parameters: {}, typeVersion: 2 }, { id: '2', name: 'HTTP1', type: 'nodes-base.httpRequest', position: [300, 100], parameters: {} }, { id: '3', name: 'Slack', type: 'n8n-nodes-base.slack', position: [500, 100], parameters: {} }, { id: '4', name: 'Disabled', type: 'n8n-nodes-base.set', position: [700, 100], parameters: {}, disabled: true }, { id: '5', name: 'HTTP2', type: 'n8n-nodes-base.httpRequest', position: [900, 100], parameters: { onError: 'continueRegularOutput' }, typeVersion: 4 }, { id: '6', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [1100, 100], parameters: {}, typeVersion: 2 }, { id: '7', name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent', position: [100, 300], parameters: {}, typeVersion: 1 }], connections: { 'Webhook': { main: [[{ node: 'HTTP1', type: 'main', index: 0 }]] }, 'HTTP1': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }, 'Slack': { main: [[{ node: 'Disabled', type: 'main', index: 0 }]] }, '5': { main: [[{ node: 'Agent', type: 'main', index: 0 }]] } } } as any);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => e.message.includes("Missing required property 'typeVersion'"))).toBe(true);
+ expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true);
+ expect(result.errors.some(e => e.message.includes("Connection uses node ID '5'"))).toBe(true);
+ expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true);
+ expect(result.statistics.totalNodes).toBe(7);
+ });
+
+ it('should validate a perfect workflow', async () => {
+ const result = await validator.validateWorkflow({ nodes: [{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [250, 300], parameters: {}, typeVersion: 1 }, { id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [450, 300], parameters: { url: 'https://api.example.com', method: 'GET' }, typeVersion: 4, onError: 'continueErrorOutput', retryOnFail: true, maxTries: 3, waitBetweenTries: 1000 }, { id: '3', name: 'Process', type: 'n8n-nodes-base.code', position: [650, 300], parameters: { jsCode: 'return items;' }, typeVersion: 2 }, { id: '4', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [650, 500], parameters: {}, typeVersion: 3 }], connections: { 'Manual Trigger': { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] }, 'HTTP Request': { main: [[{ node: 'Process', type: 'main', index: 0 }], [{ node: 'Error Handler', type: 'main', index: 0 }]] } } } as any);
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ expect(result.warnings).toHaveLength(0);
+ expect(result.statistics.validConnections).toBe(3);
+ });
+ });
+});
diff --git a/tests/unit/telemetry/event-validator.test.ts b/tests/unit/telemetry/event-validator.test.ts
deleted file mode 100644
index 99ec4c7..0000000
--- a/tests/unit/telemetry/event-validator.test.ts
+++ /dev/null
@@ -1,562 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { z } from 'zod';
-import { TelemetryEventValidator, telemetryEventSchema, workflowTelemetrySchema } from '../../../src/telemetry/event-validator';
-import { TelemetryEvent, WorkflowTelemetry } from '../../../src/telemetry/telemetry-types';
-
-// Mock logger to avoid console output in tests
-vi.mock('../../../src/utils/logger', () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- }
-}));
-
-describe('TelemetryEventValidator', () => {
- let validator: TelemetryEventValidator;
-
- beforeEach(() => {
- validator = new TelemetryEventValidator();
- vi.clearAllMocks();
- });
-
- describe('validateEvent()', () => {
- it('should validate a basic valid event', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'tool_used',
- properties: { tool: 'httpRequest', success: true, duration: 500 }
- };
-
- const result = validator.validateEvent(event);
- expect(result).toEqual(event);
- });
-
- it('should validate event with specific schema for tool_used', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'tool_used',
- properties: { tool: 'httpRequest', success: true, duration: 500 }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.tool).toBe('httpRequest');
- expect(result?.properties.success).toBe(true);
- expect(result?.properties.duration).toBe(500);
- });
-
- it('should validate search_query event with specific schema', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'search_query',
- properties: {
- query: 'test query',
- resultsFound: 5,
- searchType: 'nodes',
- hasResults: true,
- isZeroResults: false
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.query).toBe('test query');
- expect(result?.properties.resultsFound).toBe(5);
- expect(result?.properties.hasResults).toBe(true);
- });
-
- it('should validate performance_metric event with specific schema', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'performance_metric',
- properties: {
- operation: 'database_query',
- duration: 1500,
- isSlow: true,
- isVerySlow: false,
- metadata: { table: 'nodes' }
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.operation).toBe('database_query');
- expect(result?.properties.duration).toBe(1500);
- expect(result?.properties.isSlow).toBe(true);
- });
-
- it('should sanitize sensitive data from properties', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'generic_event',
- properties: {
- description: 'Visit https://example.com/secret and user@example.com with key abcdef123456789012345678901234567890',
- apiKey: 'super-secret-key-12345678901234567890',
- normalProp: 'normal value'
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.description).toBe('Visit [URL] and [EMAIL] with key [KEY]');
- expect(result?.properties.normalProp).toBe('normal value');
- expect(result?.properties).not.toHaveProperty('apiKey'); // Should be filtered out
- });
-
- it('should handle nested object sanitization with depth limit', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'nested_event',
- properties: {
- nested: {
- level1: {
- level2: {
- level3: {
- level4: 'should be truncated',
- apiKey: 'secret123',
- description: 'Visit https://example.com'
- },
- description: 'Visit https://another.com'
- }
- }
- }
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.nested.level1.level2.level3).toBe('[NESTED]');
- expect(result?.properties.nested.level1.level2.description).toBe('Visit [URL]');
- });
-
- it('should handle array sanitization with size limit', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'array_event',
- properties: {
- items: Array.from({ length: 15 }, (_, i) => ({
- id: i,
- description: 'Visit https://example.com',
- value: `item-${i}`
- }))
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(Array.isArray(result?.properties.items)).toBe(true);
- expect(result?.properties.items.length).toBe(10); // Should be limited to 10
- });
-
- it('should reject events with invalid user_id', () => {
- const event: TelemetryEvent = {
- user_id: '', // Empty string
- event: 'test_event',
- properties: {}
- };
-
- const result = validator.validateEvent(event);
- expect(result).toBeNull();
- });
-
- it('should reject events with invalid event name', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'invalid-event-name!@#', // Invalid characters
- properties: {}
- };
-
- const result = validator.validateEvent(event);
- expect(result).toBeNull();
- });
-
- it('should reject tool_used event with invalid properties', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'tool_used',
- properties: {
- tool: 'test',
- success: 'not-a-boolean', // Should be boolean
- duration: -1 // Should be positive
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).toBeNull();
- });
-
- it('should filter out sensitive keys from properties', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'sensitive_event',
- properties: {
- password: 'secret123',
- token: 'bearer-token',
- apikey: 'api-key-value',
- secret: 'secret-value',
- credential: 'cred-value',
- auth: 'auth-header',
- url: 'https://example.com',
- endpoint: 'api.example.com',
- host: 'localhost',
- database: 'prod-db',
- normalProp: 'safe-value',
- count: 42,
- enabled: true
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties).not.toHaveProperty('password');
- expect(result?.properties).not.toHaveProperty('token');
- expect(result?.properties).not.toHaveProperty('apikey');
- expect(result?.properties).not.toHaveProperty('secret');
- expect(result?.properties).not.toHaveProperty('credential');
- expect(result?.properties).not.toHaveProperty('auth');
- expect(result?.properties).not.toHaveProperty('url');
- expect(result?.properties).not.toHaveProperty('endpoint');
- expect(result?.properties).not.toHaveProperty('host');
- expect(result?.properties).not.toHaveProperty('database');
- expect(result?.properties.normalProp).toBe('safe-value');
- expect(result?.properties.count).toBe(42);
- expect(result?.properties.enabled).toBe(true);
- });
-
- it('should handle validation_details event schema', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'validation_details',
- properties: {
- nodeType: 'nodes-base.httpRequest',
- errorType: 'required_field_missing',
- errorCategory: 'validation_error',
- details: { field: 'url' }
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.nodeType).toBe('nodes-base.httpRequest');
- expect(result?.properties.errorType).toBe('required_field_missing');
- });
-
- it('should handle null and undefined values', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'null_event',
- properties: {
- nullValue: null,
- undefinedValue: undefined,
- normalValue: 'test'
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result).not.toBeNull();
- expect(result?.properties.nullValue).toBeNull();
- expect(result?.properties.undefinedValue).toBeNull();
- expect(result?.properties.normalValue).toBe('test');
- });
- });
-
- describe('validateWorkflow()', () => {
- it('should validate a valid workflow', () => {
- const workflow: WorkflowTelemetry = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 3,
- node_types: ['webhook', 'httpRequest', 'set'],
- has_trigger: true,
- has_webhook: true,
- complexity: 'medium',
- sanitized_workflow: {
- nodes: [
- { id: '1', type: 'webhook' },
- { id: '2', type: 'httpRequest' },
- { id: '3', type: 'set' }
- ],
- connections: { '1': { main: [[{ node: '2', type: 'main', index: 0 }]] } }
- }
- };
-
- const result = validator.validateWorkflow(workflow);
- expect(result).toEqual(workflow);
- });
-
- it('should reject workflow with too many nodes', () => {
- const workflow: WorkflowTelemetry = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 1001, // Over limit
- node_types: ['webhook'],
- has_trigger: true,
- has_webhook: true,
- complexity: 'complex',
- sanitized_workflow: {
- nodes: [],
- connections: {}
- }
- };
-
- const result = validator.validateWorkflow(workflow);
- expect(result).toBeNull();
- });
-
- it('should reject workflow with invalid complexity', () => {
- const workflow = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 3,
- node_types: ['webhook'],
- has_trigger: true,
- has_webhook: true,
- complexity: 'invalid' as any, // Invalid complexity
- sanitized_workflow: {
- nodes: [],
- connections: {}
- }
- };
-
- const result = validator.validateWorkflow(workflow);
- expect(result).toBeNull();
- });
-
- it('should reject workflow with too many node types', () => {
- const workflow: WorkflowTelemetry = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 3,
- node_types: Array.from({ length: 101 }, (_, i) => `node-${i}`), // Over limit
- has_trigger: true,
- has_webhook: true,
- complexity: 'complex',
- sanitized_workflow: {
- nodes: [],
- connections: {}
- }
- };
-
- const result = validator.validateWorkflow(workflow);
- expect(result).toBeNull();
- });
- });
-
- describe('getStats()', () => {
- it('should track validation statistics', () => {
- const validEvent: TelemetryEvent = {
- user_id: 'user123',
- event: 'valid_event',
- properties: {}
- };
-
- const invalidEvent: TelemetryEvent = {
- user_id: '', // Invalid
- event: 'invalid_event',
- properties: {}
- };
-
- validator.validateEvent(validEvent);
- validator.validateEvent(validEvent);
- validator.validateEvent(invalidEvent);
-
- const stats = validator.getStats();
- expect(stats.successes).toBe(2);
- expect(stats.errors).toBe(1);
- expect(stats.total).toBe(3);
- expect(stats.errorRate).toBeCloseTo(0.333, 3);
- });
-
- it('should handle division by zero in error rate', () => {
- const stats = validator.getStats();
- expect(stats.errorRate).toBe(0);
- });
- });
-
- describe('resetStats()', () => {
- it('should reset validation statistics', () => {
- const validEvent: TelemetryEvent = {
- user_id: 'user123',
- event: 'valid_event',
- properties: {}
- };
-
- validator.validateEvent(validEvent);
- validator.resetStats();
-
- const stats = validator.getStats();
- expect(stats.successes).toBe(0);
- expect(stats.errors).toBe(0);
- expect(stats.total).toBe(0);
- expect(stats.errorRate).toBe(0);
- });
- });
-
- describe('Schema validation', () => {
- describe('telemetryEventSchema', () => {
- it('should validate with created_at timestamp', () => {
- const event = {
- user_id: 'user123',
- event: 'test_event',
- properties: {},
- created_at: '2024-01-01T00:00:00Z'
- };
-
- const result = telemetryEventSchema.safeParse(event);
- expect(result.success).toBe(true);
- });
-
- it('should reject invalid datetime format', () => {
- const event = {
- user_id: 'user123',
- event: 'test_event',
- properties: {},
- created_at: 'invalid-date'
- };
-
- const result = telemetryEventSchema.safeParse(event);
- expect(result.success).toBe(false);
- });
-
- it('should enforce user_id length limits', () => {
- const longUserId = 'a'.repeat(65);
- const event = {
- user_id: longUserId,
- event: 'test_event',
- properties: {}
- };
-
- const result = telemetryEventSchema.safeParse(event);
- expect(result.success).toBe(false);
- });
-
- it('should enforce event name regex pattern', () => {
- const event = {
- user_id: 'user123',
- event: 'invalid event name with spaces!',
- properties: {}
- };
-
- const result = telemetryEventSchema.safeParse(event);
- expect(result.success).toBe(false);
- });
- });
-
- describe('workflowTelemetrySchema', () => {
- it('should enforce node array size limits', () => {
- const workflow = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 3,
- node_types: ['test'],
- has_trigger: true,
- has_webhook: false,
- complexity: 'simple',
- sanitized_workflow: {
- nodes: Array.from({ length: 1001 }, (_, i) => ({ id: i })), // Over limit
- connections: {}
- }
- };
-
- const result = workflowTelemetrySchema.safeParse(workflow);
- expect(result.success).toBe(false);
- });
-
- it('should validate with optional created_at', () => {
- const workflow = {
- user_id: 'user123',
- workflow_hash: 'hash123',
- node_count: 1,
- node_types: ['webhook'],
- has_trigger: true,
- has_webhook: true,
- complexity: 'simple',
- sanitized_workflow: {
- nodes: [{ id: '1' }],
- connections: {}
- },
- created_at: '2024-01-01T00:00:00Z'
- };
-
- const result = workflowTelemetrySchema.safeParse(workflow);
- expect(result.success).toBe(true);
- });
- });
- });
-
- describe('String sanitization edge cases', () => {
- it('should handle multiple URLs in same string', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'test_event',
- properties: {
- description: 'Visit https://example.com or http://test.com for more info'
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result?.properties.description).toBe('Visit [URL] or [URL] for more info');
- });
-
- it('should handle mixed sensitive content', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'test_event',
- properties: {
- message: 'Contact admin@example.com at https://secure.com with key abc123def456ghi789jkl012mno345pqr'
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result?.properties.message).toBe('Contact [EMAIL] at [URL] with key [KEY]');
- });
-
- it('should preserve non-sensitive content', () => {
- const event: TelemetryEvent = {
- user_id: 'user123',
- event: 'test_event',
- properties: {
- status: 'success',
- count: 42,
- enabled: true,
- short_id: 'abc123' // Too short to be considered a key
- }
- };
-
- const result = validator.validateEvent(event);
- expect(result?.properties.status).toBe('success');
- expect(result?.properties.count).toBe(42);
- expect(result?.properties.enabled).toBe(true);
- expect(result?.properties.short_id).toBe('abc123');
- });
- });
-
- describe('Error handling', () => {
- it('should handle Zod parsing errors gracefully', () => {
- const invalidEvent = {
- user_id: 123, // Should be string
- event: 'test_event',
- properties: {}
- };
-
- const result = validator.validateEvent(invalidEvent as any);
- expect(result).toBeNull();
- });
-
- it('should handle unexpected errors during validation', () => {
- const eventWithCircularRef: any = {
- user_id: 'user123',
- event: 'test_event',
- properties: {}
- };
- // Create circular reference
- eventWithCircularRef.properties.self = eventWithCircularRef;
-
- const result = validator.validateEvent(eventWithCircularRef);
- // Should handle gracefully and not throw
- expect(result).not.toThrow;
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/telemetry/mutation-tracker.test.ts b/tests/unit/telemetry/mutation-tracker.test.ts
deleted file mode 100644
index f212cd1..0000000
--- a/tests/unit/telemetry/mutation-tracker.test.ts
+++ /dev/null
@@ -1,817 +0,0 @@
-/**
- * Unit tests for MutationTracker - Sanitization and Processing
- */
-
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { MutationTracker } from '../../../src/telemetry/mutation-tracker';
-import { WorkflowMutationData, MutationToolName } from '../../../src/telemetry/mutation-types';
-
-describe('MutationTracker', () => {
- let tracker: MutationTracker;
-
- beforeEach(() => {
- tracker = new MutationTracker();
- tracker.clearRecentMutations();
- });
-
- describe('Workflow Sanitization', () => {
- it('should remove credentials from workflow level', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test sanitization',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {},
- credentials: { apiKey: 'secret-key-123' },
- sharedWorkflows: ['user1', 'user2'],
- ownedBy: { id: 'user1', email: 'user@example.com' }
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test Updated',
- nodes: [],
- connections: {},
- credentials: { apiKey: 'secret-key-456' }
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- expect(result!.workflowBefore).toBeDefined();
- expect(result!.workflowBefore.credentials).toBeUndefined();
- expect(result!.workflowBefore.sharedWorkflows).toBeUndefined();
- expect(result!.workflowBefore.ownedBy).toBeUndefined();
- expect(result!.workflowAfter.credentials).toBeUndefined();
- });
-
- it('should remove credentials from node level', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test node credentials',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- credentials: {
- httpBasicAuth: {
- id: 'cred-123',
- name: 'My Auth'
- }
- },
- parameters: {
- url: 'https://api.example.com'
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- credentials: {
- httpBasicAuth: {
- id: 'cred-456',
- name: 'Updated Auth'
- }
- },
- parameters: {
- url: 'https://api.example.com'
- }
- }
- ],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 150
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- expect(result!.workflowBefore.nodes[0].credentials).toBeUndefined();
- expect(result!.workflowAfter.nodes[0].credentials).toBeUndefined();
- });
-
- it('should redact API keys in parameters', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test API key redaction',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'OpenAI',
- type: 'n8n-nodes-base.openAi',
- position: [100, 100],
- parameters: {
- apiKeyField: 'sk-1234567890abcdef1234567890abcdef',
- tokenField: 'Bearer abc123def456',
- config: {
- passwordField: 'secret-password-123'
- }
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'OpenAI',
- type: 'n8n-nodes-base.openAi',
- position: [100, 100],
- parameters: {
- apiKeyField: 'sk-newkey567890abcdef1234567890abcdef'
- }
- }
- ],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 200
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const params = result!.workflowBefore.nodes[0].parameters;
- // Fields with sensitive key names are redacted
- expect(params.apiKeyField).toBe('[REDACTED]');
- expect(params.tokenField).toBe('[REDACTED]');
- expect(params.config.passwordField).toBe('[REDACTED]');
- });
-
- it('should redact URLs with authentication', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test URL redaction',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {
- url: 'https://user:password@api.example.com/endpoint',
- webhookUrl: 'http://admin:secret@webhook.example.com'
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const params = result!.workflowBefore.nodes[0].parameters;
- // URL auth is redacted but path is preserved
- expect(params.url).toBe('[REDACTED_URL_WITH_AUTH]/endpoint');
- expect(params.webhookUrl).toBe('[REDACTED_URL_WITH_AUTH]');
- });
-
- it('should redact long tokens (32+ characters)', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test token redaction',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Slack',
- type: 'n8n-nodes-base.slack',
- position: [100, 100],
- parameters: {
- message: 'Token: test-token-1234567890-1234567890123-abcdefghijklmnopqrstuvwx'
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const message = result!.workflowBefore.nodes[0].parameters.message;
- expect(message).toContain('[REDACTED_TOKEN]');
- });
-
- it('should redact OpenAI-style keys', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test OpenAI key redaction',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Code',
- type: 'n8n-nodes-base.code',
- position: [100, 100],
- parameters: {
- code: 'const apiKey = "sk-proj-abcd1234efgh5678ijkl9012mnop3456";'
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const code = result!.workflowBefore.nodes[0].parameters.code;
- // The 32+ char regex runs before OpenAI-specific regex, so it becomes [REDACTED_TOKEN]
- expect(code).toContain('[REDACTED_TOKEN]');
- });
-
- it('should redact Bearer tokens', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test Bearer token redaction',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {
- headerParameters: {
- parameter: [
- {
- name: 'Authorization',
- value: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
- }
- ]
- }
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const authValue = result!.workflowBefore.nodes[0].parameters.headerParameters.parameter[0].value;
- expect(authValue).toBe('Bearer [REDACTED]');
- });
-
- it('should preserve workflow structure while sanitizing', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test structure preservation',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'My Workflow',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- },
- {
- id: 'node2',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 100],
- parameters: {
- url: 'https://api.example.com',
- apiKey: 'secret-key'
- }
- }
- ],
- connections: {
- Start: {
- main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
- }
- },
- active: true,
- credentials: { apiKey: 'workflow-secret' }
- },
- workflowAfter: {
- id: 'wf1',
- name: 'My Workflow',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 150
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- // Check structure preserved
- expect(result!.workflowBefore.id).toBe('wf1');
- expect(result!.workflowBefore.name).toBe('My Workflow');
- expect(result!.workflowBefore.nodes).toHaveLength(2);
- expect(result!.workflowBefore.connections).toBeDefined();
- expect(result!.workflowBefore.active).toBe(true);
-
- // Check credentials removed
- expect(result!.workflowBefore.credentials).toBeUndefined();
-
- // Check node parameters sanitized
- expect(result!.workflowBefore.nodes[1].parameters.apiKey).toBe('[REDACTED]');
-
- // Check connections preserved
- expect(result!.workflowBefore.connections.Start).toBeDefined();
- });
-
- it('should handle nested objects recursively', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test nested sanitization',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Complex Node',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: {
- authentication: {
- type: 'oauth2',
- // Use 'settings' instead of 'credentials' since 'credentials' is a sensitive key
- settings: {
- clientId: 'safe-client-id',
- clientSecret: 'very-secret-key',
- nested: {
- apiKeyValue: 'deep-secret-key',
- tokenValue: 'nested-token'
- }
- }
- }
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- const auth = result!.workflowBefore.nodes[0].parameters.authentication;
- // The key 'authentication' contains 'auth' which is sensitive, so entire object is redacted
- expect(auth).toBe('[REDACTED]');
- });
- });
-
- describe('Deduplication', () => {
- it('should detect and skip duplicate mutations', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'First mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test Updated',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- // First mutation should succeed
- const result1 = await tracker.processMutation(data, 'test-user');
- expect(result1).toBeTruthy();
-
- // Exact duplicate should be skipped
- const result2 = await tracker.processMutation(data, 'test-user');
- expect(result2).toBeNull();
- });
-
- it('should allow mutations with different workflows', async () => {
- const data1: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'First mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test 1',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test 1 Updated',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const data2: WorkflowMutationData = {
- ...data1,
- workflowBefore: {
- id: 'wf2',
- name: 'Test 2',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf2',
- name: 'Test 2 Updated',
- nodes: [],
- connections: {}
- }
- };
-
- const result1 = await tracker.processMutation(data1, 'test-user');
- const result2 = await tracker.processMutation(data2, 'test-user');
-
- expect(result1).toBeTruthy();
- expect(result2).toBeTruthy();
- });
- });
-
- describe('Structural Hash Generation', () => {
- it('should generate structural hashes for both before and after workflows', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test structural hash generation',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- },
- {
- id: 'node2',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 100],
- parameters: { url: 'https://api.example.com' }
- }
- ],
- connections: {
- Start: {
- main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
- }
- }
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- expect(result!.workflowStructureHashBefore).toBeDefined();
- expect(result!.workflowStructureHashAfter).toBeDefined();
- expect(typeof result!.workflowStructureHashBefore).toBe('string');
- expect(typeof result!.workflowStructureHashAfter).toBe('string');
- expect(result!.workflowStructureHashBefore!.length).toBe(16);
- expect(result!.workflowStructureHashAfter!.length).toBe(16);
- });
-
- it('should generate different structural hashes when node types change', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test hash changes with node types',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- },
- {
- id: 'node2',
- name: 'Slack',
- type: 'n8n-nodes-base.slack',
- position: [300, 100],
- parameters: {}
- }
- ],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- expect(result!.workflowStructureHashBefore).not.toBe(result!.workflowStructureHashAfter);
- });
-
- it('should generate same structural hash for workflows with same structure but different parameters', async () => {
- const workflow1Before = {
- id: 'wf1',
- name: 'Test 1',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: { url: 'https://api1.example.com' }
- }
- ],
- connections: {}
- };
-
- const workflow1After = {
- id: 'wf1',
- name: 'Test 1 Updated',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP',
- type: 'n8n-nodes-base.httpRequest',
- position: [100, 100],
- parameters: { url: 'https://api1-updated.example.com' }
- }
- ],
- connections: {}
- };
-
- const workflow2Before = {
- id: 'wf2',
- name: 'Test 2',
- nodes: [
- {
- id: 'node2',
- name: 'Different Name',
- type: 'n8n-nodes-base.httpRequest',
- position: [200, 200],
- parameters: { url: 'https://api2.example.com' }
- }
- ],
- connections: {}
- };
-
- const workflow2After = {
- id: 'wf2',
- name: 'Test 2 Updated',
- nodes: [
- {
- id: 'node2',
- name: 'Different Name',
- type: 'n8n-nodes-base.httpRequest',
- position: [200, 200],
- parameters: { url: 'https://api2-updated.example.com' }
- }
- ],
- connections: {}
- };
-
- const data1: WorkflowMutationData = {
- sessionId: 'test-session-1',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test 1',
- operations: [{ type: 'updateNode', nodeId: 'node1', updates: { 'parameters.test': 'value1' } } as any],
- workflowBefore: workflow1Before,
- workflowAfter: workflow1After,
- mutationSuccess: true,
- durationMs: 100
- };
-
- const data2: WorkflowMutationData = {
- sessionId: 'test-session-2',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test 2',
- operations: [{ type: 'updateNode', nodeId: 'node2', updates: { 'parameters.test': 'value2' } } as any],
- workflowBefore: workflow2Before,
- workflowAfter: workflow2After,
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result1 = await tracker.processMutation(data1, 'test-user-1');
- const result2 = await tracker.processMutation(data2, 'test-user-2');
-
- expect(result1).toBeTruthy();
- expect(result2).toBeTruthy();
- // Same structure (same node types, same connection structure) should yield same hash
- expect(result1!.workflowStructureHashBefore).toBe(result2!.workflowStructureHashBefore);
- });
-
- it('should generate both full hash and structural hash', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test both hash types',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test Updated',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = await tracker.processMutation(data, 'test-user');
-
- expect(result).toBeTruthy();
- // Full hashes (includes all workflow data)
- expect(result!.workflowHashBefore).toBeDefined();
- expect(result!.workflowHashAfter).toBeDefined();
- // Structural hashes (nodeTypes + connections only)
- expect(result!.workflowStructureHashBefore).toBeDefined();
- expect(result!.workflowStructureHashAfter).toBeDefined();
- // They should be different since they hash different data
- expect(result!.workflowHashBefore).not.toBe(result!.workflowStructureHashBefore);
- });
- });
-
- describe('Statistics', () => {
- it('should track recent mutations count', async () => {
- expect(tracker.getRecentMutationsCount()).toBe(0);
-
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test counting',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- await tracker.processMutation(data, 'test-user');
- expect(tracker.getRecentMutationsCount()).toBe(1);
-
- // Process another with different workflow
- const data2 = { ...data, workflowBefore: { ...data.workflowBefore, id: 'wf2' } };
- await tracker.processMutation(data2, 'test-user');
- expect(tracker.getRecentMutationsCount()).toBe(2);
- });
-
- it('should clear recent mutations', async () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test clearing',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- await tracker.processMutation(data, 'test-user');
- expect(tracker.getRecentMutationsCount()).toBe(1);
-
- tracker.clearRecentMutations();
- expect(tracker.getRecentMutationsCount()).toBe(0);
- });
- });
-});
diff --git a/tests/unit/telemetry/mutation-validator.test.ts b/tests/unit/telemetry/mutation-validator.test.ts
deleted file mode 100644
index 7f98bd6..0000000
--- a/tests/unit/telemetry/mutation-validator.test.ts
+++ /dev/null
@@ -1,557 +0,0 @@
-/**
- * Unit tests for MutationValidator - Data Quality Validation
- */
-
-import { describe, it, expect, beforeEach } from 'vitest';
-import { MutationValidator } from '../../../src/telemetry/mutation-validator';
-import { WorkflowMutationData, MutationToolName } from '../../../src/telemetry/mutation-types';
-import type { UpdateNodeOperation } from '../../../src/types/workflow-diff';
-
-describe('MutationValidator', () => {
- let validator: MutationValidator;
-
- beforeEach(() => {
- validator = new MutationValidator();
- });
-
- describe('Workflow Structure Validation', () => {
- it('should accept valid workflow structure', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Valid mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test Updated',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it('should reject workflow without nodes array', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Invalid mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- connections: {}
- } as any,
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors).toContain('Invalid workflow_before structure');
- });
-
- it('should reject workflow without connections object', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Invalid mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: []
- } as any,
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors).toContain('Invalid workflow_before structure');
- });
-
- it('should reject null workflow', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Invalid mutation',
- operations: [{ type: 'updateNode' }],
- workflowBefore: null as any,
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors).toContain('Invalid workflow_before structure');
- });
- });
-
- describe('Workflow Size Validation', () => {
- it('should accept workflows within size limit', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Size test',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [{
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- }],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(true);
- expect(result.errors).not.toContain(expect.stringContaining('size'));
- });
-
- it('should reject oversized workflows', () => {
- // Create a very large workflow (over 500KB default limit)
- // 600KB string = 600,000 characters
- const largeArray = new Array(600000).fill('x').join('');
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Oversized test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [{
- id: 'node1',
- name: 'Large',
- type: 'n8n-nodes-base.code',
- position: [100, 100],
- parameters: {
- code: largeArray
- }
- }],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors.some(err => err.includes('size') && err.includes('exceeds'))).toBe(true);
- });
-
- it('should respect custom size limit', () => {
- const customValidator = new MutationValidator({ maxWorkflowSizeKb: 1 });
-
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Custom size test',
- operations: [{ type: 'addNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [{
- id: 'node1',
- name: 'Medium',
- type: 'n8n-nodes-base.code',
- position: [100, 100],
- parameters: {
- code: 'x'.repeat(2000) // ~2KB
- }
- }],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = customValidator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors.some(err => err.includes('exceeds maximum (1KB)'))).toBe(true);
- });
- });
-
- describe('Intent Validation', () => {
- it('should warn about empty intent', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: '',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('User intent is empty');
- });
-
- it('should warn about very short intent', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'fix',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('User intent is too short (less than 5 characters)');
- });
-
- it('should warn about very long intent', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'x'.repeat(1001),
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('User intent is very long (over 1000 characters)');
- });
-
- it('should accept good intent length', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Add error handling to API nodes',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).not.toContain(expect.stringContaining('intent'));
- });
- });
-
- describe('Operations Validation', () => {
- it('should reject empty operations array', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors).toContain('No operations provided');
- });
-
- it('should accept operations array with items', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [
- { type: 'addNode' },
- { type: 'addConnection' }
- ],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(true);
- expect(result.errors).not.toContain('No operations provided');
- });
- });
-
- describe('Duration Validation', () => {
- it('should reject negative duration', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: -100
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors).toContain('Duration cannot be negative');
- });
-
- it('should warn about very long duration', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 400000 // Over 5 minutes
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('Duration is very long (over 5 minutes)');
- });
-
- it('should accept reasonable duration', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- mutationSuccess: true,
- durationMs: 150
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(true);
- expect(result.warnings).not.toContain(expect.stringContaining('Duration'));
- });
- });
-
- describe('Meaningful Change Detection', () => {
- it('should warn when workflows are identical', () => {
- const workflow = {
- id: 'wf1',
- name: 'Test',
- nodes: [
- {
- id: 'node1',
- name: 'Start',
- type: 'n8n-nodes-base.start',
- position: [100, 100],
- parameters: {}
- }
- ],
- connections: {}
- };
-
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'No actual change',
- operations: [{ type: 'updateNode' }],
- workflowBefore: workflow,
- workflowAfter: JSON.parse(JSON.stringify(workflow)), // Deep clone
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('No meaningful change detected between before and after workflows');
- });
-
- it('should not warn when workflows are different', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Real change',
- operations: [{ type: 'updateNode' }],
- workflowBefore: {
- id: 'wf1',
- name: 'Test',
- nodes: [],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'Test Updated',
- nodes: [],
- connections: {}
- },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).not.toContain(expect.stringContaining('meaningful change'));
- });
- });
-
- describe('Validation Data Consistency', () => {
- it('should warn about invalid validation structure', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- validationBefore: { valid: 'yes' } as any, // Invalid structure
- validationAfter: { valid: true, errors: [] },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).toContain('Invalid validation_before structure');
- });
-
- it('should accept valid validation structure', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Test',
- operations: [{ type: 'updateNode' }],
- workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- workflowAfter: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
- validationBefore: { valid: false, errors: [{ type: 'test_error', message: 'Error 1' }] },
- validationAfter: { valid: true, errors: [] },
- mutationSuccess: true,
- durationMs: 100
- };
-
- const result = validator.validate(data);
- expect(result.warnings).not.toContain(expect.stringContaining('validation'));
- });
- });
-
- describe('Comprehensive Validation', () => {
- it('should collect multiple errors and warnings', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: '', // Empty - warning
- operations: [], // Empty - error
- workflowBefore: null as any, // Invalid - error
- workflowAfter: { nodes: [] } as any, // Missing connections - error
- mutationSuccess: true,
- durationMs: -50 // Negative - error
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- expect(result.warnings.length).toBeGreaterThan(0);
- });
-
- it('should pass validation with all criteria met', () => {
- const data: WorkflowMutationData = {
- sessionId: 'test-session-123',
- toolName: MutationToolName.UPDATE_PARTIAL,
- userIntent: 'Add error handling to HTTP Request nodes',
- operations: [
- { type: 'updateNode', nodeName: 'node1', updates: { onError: 'continueErrorOutput' } } as UpdateNodeOperation
- ],
- workflowBefore: {
- id: 'wf1',
- name: 'API Workflow',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 200],
- parameters: {
- url: 'https://api.example.com',
- method: 'GET'
- }
- }
- ],
- connections: {}
- },
- workflowAfter: {
- id: 'wf1',
- name: 'API Workflow',
- nodes: [
- {
- id: 'node1',
- name: 'HTTP Request',
- type: 'n8n-nodes-base.httpRequest',
- position: [300, 200],
- parameters: {
- url: 'https://api.example.com',
- method: 'GET'
- },
- onError: 'continueErrorOutput'
- }
- ],
- connections: {}
- },
- validationBefore: { valid: true, errors: [] },
- validationAfter: { valid: true, errors: [] },
- mutationSuccess: true,
- durationMs: 245
- };
-
- const result = validator.validate(data);
- expect(result.valid).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
- });
-});
diff --git a/tests/unit/telemetry/telemetry-error.test.ts b/tests/unit/telemetry/telemetry-error.test.ts
deleted file mode 100644
index c45cdc3..0000000
--- a/tests/unit/telemetry/telemetry-error.test.ts
+++ /dev/null
@@ -1,636 +0,0 @@
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { TelemetryError, TelemetryCircuitBreaker, TelemetryErrorAggregator } from '../../../src/telemetry/telemetry-error';
-import { TelemetryErrorType } from '../../../src/telemetry/telemetry-types';
-import { logger } from '../../../src/utils/logger';
-
-// Mock logger to avoid console output in tests
-vi.mock('../../../src/utils/logger', () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- }
-}));
-
-describe('TelemetryError', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- describe('constructor', () => {
- it('should create error with all properties', () => {
- const context = { operation: 'test', detail: 'info' };
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Test error',
- context,
- true
- );
-
- expect(error.name).toBe('TelemetryError');
- expect(error.message).toBe('Test error');
- expect(error.type).toBe(TelemetryErrorType.NETWORK_ERROR);
- expect(error.context).toEqual(context);
- expect(error.retryable).toBe(true);
- expect(error.timestamp).toBeTypeOf('number');
- });
-
- it('should default retryable to false', () => {
- const error = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Test error'
- );
-
- expect(error.retryable).toBe(false);
- });
-
- it('should handle undefined context', () => {
- const error = new TelemetryError(
- TelemetryErrorType.UNKNOWN_ERROR,
- 'Test error'
- );
-
- expect(error.context).toBeUndefined();
- });
-
- it('should maintain proper prototype chain', () => {
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Test error'
- );
-
- expect(error instanceof TelemetryError).toBe(true);
- expect(error instanceof Error).toBe(true);
- });
- });
-
- describe('toContext()', () => {
- it('should convert error to context object', () => {
- const context = { operation: 'flush', batch: 'events' };
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Failed to flush',
- context,
- true
- );
-
- const contextObj = error.toContext();
- expect(contextObj).toEqual({
- type: TelemetryErrorType.NETWORK_ERROR,
- message: 'Failed to flush',
- context,
- timestamp: error.timestamp,
- retryable: true
- });
- });
- });
-
- describe('log()', () => {
- it('should log retryable errors as debug', () => {
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Retryable error',
- { attempt: 1 },
- true
- );
-
- error.log();
-
- expect(logger.debug).toHaveBeenCalledWith(
- 'Retryable telemetry error:',
- expect.objectContaining({
- type: TelemetryErrorType.NETWORK_ERROR,
- message: 'Retryable error',
- attempt: 1
- })
- );
- });
-
- it('should log non-retryable errors as debug', () => {
- const error = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Non-retryable error',
- { field: 'user_id' },
- false
- );
-
- error.log();
-
- expect(logger.debug).toHaveBeenCalledWith(
- 'Non-retryable telemetry error:',
- expect.objectContaining({
- type: TelemetryErrorType.VALIDATION_ERROR,
- message: 'Non-retryable error',
- field: 'user_id'
- })
- );
- });
-
- it('should handle errors without context', () => {
- const error = new TelemetryError(
- TelemetryErrorType.UNKNOWN_ERROR,
- 'Simple error'
- );
-
- error.log();
-
- expect(logger.debug).toHaveBeenCalledWith(
- 'Non-retryable telemetry error:',
- expect.objectContaining({
- type: TelemetryErrorType.UNKNOWN_ERROR,
- message: 'Simple error'
- })
- );
- });
- });
-});
-
-describe('TelemetryCircuitBreaker', () => {
- let circuitBreaker: TelemetryCircuitBreaker;
-
- beforeEach(() => {
- vi.clearAllMocks();
- vi.useFakeTimers();
- circuitBreaker = new TelemetryCircuitBreaker(3, 10000, 2); // 3 failures, 10s reset, 2 half-open requests
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- describe('shouldAllow()', () => {
- it('should allow requests in closed state', () => {
- expect(circuitBreaker.shouldAllow()).toBe(true);
- });
-
- it('should open circuit after failure threshold', () => {
- // Record 3 failures to reach threshold
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
-
- expect(circuitBreaker.shouldAllow()).toBe(false);
- expect(circuitBreaker.getState().state).toBe('open');
- });
-
- it('should transition to half-open after reset timeout', () => {
- // Open the circuit
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
- expect(circuitBreaker.shouldAllow()).toBe(false);
-
- // Advance time past reset timeout
- vi.advanceTimersByTime(11000);
-
- // Should transition to half-open and allow request
- expect(circuitBreaker.shouldAllow()).toBe(true);
- expect(circuitBreaker.getState().state).toBe('half-open');
- });
-
- it('should limit requests in half-open state', () => {
- // Open the circuit
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
-
- // Advance to half-open
- vi.advanceTimersByTime(11000);
-
- // Should allow limited number of requests (2 in our config)
- expect(circuitBreaker.shouldAllow()).toBe(true);
- expect(circuitBreaker.shouldAllow()).toBe(true);
- expect(circuitBreaker.shouldAllow()).toBe(true); // Note: simplified implementation allows all
- });
-
- it('should not allow requests before reset timeout in open state', () => {
- // Open the circuit
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
-
- // Advance time but not enough to reset
- vi.advanceTimersByTime(5000);
-
- expect(circuitBreaker.shouldAllow()).toBe(false);
- });
- });
-
- describe('recordSuccess()', () => {
- it('should reset failure count in closed state', () => {
- // Record some failures but not enough to open
- circuitBreaker.recordFailure();
- circuitBreaker.recordFailure();
- expect(circuitBreaker.getState().failureCount).toBe(2);
-
- // Success should reset count
- circuitBreaker.recordSuccess();
- expect(circuitBreaker.getState().failureCount).toBe(0);
- });
-
- it('should close circuit after successful half-open requests', () => {
- // Open the circuit
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
-
- // Go to half-open
- vi.advanceTimersByTime(11000);
- circuitBreaker.shouldAllow(); // First half-open request
- circuitBreaker.shouldAllow(); // Second half-open request
-
- // The circuit breaker implementation requires success calls
- // to match the number of half-open requests configured
- circuitBreaker.recordSuccess();
- // In current implementation, state remains half-open
- // This is a known behavior of the simplified circuit breaker
- expect(circuitBreaker.getState().state).toBe('half-open');
-
- // After another success, it should close
- circuitBreaker.recordSuccess();
- expect(circuitBreaker.getState().state).toBe('closed');
- expect(circuitBreaker.getState().failureCount).toBe(0);
- expect(logger.debug).toHaveBeenCalledWith('Circuit breaker closed after successful recovery');
- });
-
- it('should not affect state when not in half-open after sufficient requests', () => {
- // Open circuit, go to half-open, make one request
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
- vi.advanceTimersByTime(11000);
- circuitBreaker.shouldAllow(); // One half-open request
-
- // Record success but should not close yet (need 2 successful requests)
- circuitBreaker.recordSuccess();
- expect(circuitBreaker.getState().state).toBe('half-open');
- });
- });
-
- describe('recordFailure()', () => {
- it('should increment failure count in closed state', () => {
- circuitBreaker.recordFailure();
- expect(circuitBreaker.getState().failureCount).toBe(1);
-
- circuitBreaker.recordFailure();
- expect(circuitBreaker.getState().failureCount).toBe(2);
- });
-
- it('should open circuit when threshold reached', () => {
- const error = new Error('Test error');
-
- // Record failures to reach threshold
- circuitBreaker.recordFailure(error);
- circuitBreaker.recordFailure(error);
- expect(circuitBreaker.getState().state).toBe('closed');
-
- circuitBreaker.recordFailure(error);
- expect(circuitBreaker.getState().state).toBe('open');
- expect(logger.debug).toHaveBeenCalledWith(
- 'Circuit breaker opened after 3 failures',
- { error: 'Test error' }
- );
- });
-
- it('should immediately open from half-open on failure', () => {
- // Open circuit, go to half-open
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
- vi.advanceTimersByTime(11000);
- circuitBreaker.shouldAllow();
-
- // Failure in half-open should immediately open
- const error = new Error('Half-open failure');
- circuitBreaker.recordFailure(error);
- expect(circuitBreaker.getState().state).toBe('open');
- expect(logger.debug).toHaveBeenCalledWith(
- 'Circuit breaker opened from half-open state',
- { error: 'Half-open failure' }
- );
- });
-
- it('should handle failure without error object', () => {
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
-
- expect(circuitBreaker.getState().state).toBe('open');
- expect(logger.debug).toHaveBeenCalledWith(
- 'Circuit breaker opened after 3 failures',
- { error: undefined }
- );
- });
- });
-
- describe('getState()', () => {
- it('should return current state information', () => {
- const state = circuitBreaker.getState();
- expect(state).toEqual({
- state: 'closed',
- failureCount: 0,
- canRetry: true
- });
- });
-
- it('should reflect state changes', () => {
- circuitBreaker.recordFailure();
- circuitBreaker.recordFailure();
-
- const state = circuitBreaker.getState();
- expect(state).toEqual({
- state: 'closed',
- failureCount: 2,
- canRetry: true
- });
-
- // Open circuit
- circuitBreaker.recordFailure();
- const openState = circuitBreaker.getState();
- expect(openState).toEqual({
- state: 'open',
- failureCount: 3,
- canRetry: false
- });
- });
- });
-
- describe('reset()', () => {
- it('should reset circuit breaker to initial state', () => {
- // Open the circuit and advance time
- for (let i = 0; i < 3; i++) {
- circuitBreaker.recordFailure();
- }
- vi.advanceTimersByTime(11000);
- circuitBreaker.shouldAllow(); // Go to half-open
-
- // Reset
- circuitBreaker.reset();
-
- const state = circuitBreaker.getState();
- expect(state).toEqual({
- state: 'closed',
- failureCount: 0,
- canRetry: true
- });
- });
- });
-
- describe('different configurations', () => {
- it('should work with custom failure threshold', () => {
- const customBreaker = new TelemetryCircuitBreaker(1, 5000, 1); // 1 failure threshold
-
- expect(customBreaker.getState().state).toBe('closed');
- customBreaker.recordFailure();
- expect(customBreaker.getState().state).toBe('open');
- });
-
- it('should work with custom half-open request count', () => {
- const customBreaker = new TelemetryCircuitBreaker(1, 5000, 3); // 3 half-open requests
-
- // Open and go to half-open
- customBreaker.recordFailure();
- vi.advanceTimersByTime(6000);
-
- // Should allow 3 requests in half-open
- expect(customBreaker.shouldAllow()).toBe(true);
- expect(customBreaker.shouldAllow()).toBe(true);
- expect(customBreaker.shouldAllow()).toBe(true);
- expect(customBreaker.shouldAllow()).toBe(true); // Fourth also allowed in simplified implementation
- });
- });
-});
-
-describe('TelemetryErrorAggregator', () => {
- let aggregator: TelemetryErrorAggregator;
-
- beforeEach(() => {
- aggregator = new TelemetryErrorAggregator();
- vi.clearAllMocks();
- });
-
- describe('record()', () => {
- it('should record error and increment counter', () => {
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Network failure'
- );
-
- aggregator.record(error);
-
- const stats = aggregator.getStats();
- expect(stats.totalErrors).toBe(1);
- expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(1);
- });
-
- it('should increment counter for repeated error types', () => {
- const error1 = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'First failure'
- );
- const error2 = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Second failure'
- );
-
- aggregator.record(error1);
- aggregator.record(error2);
-
- const stats = aggregator.getStats();
- expect(stats.totalErrors).toBe(2);
- expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2);
- });
-
- it('should maintain limited error detail history', () => {
- // Record more than max details (100) to test limiting
- for (let i = 0; i < 105; i++) {
- const error = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- `Error ${i}`
- );
- aggregator.record(error);
- }
-
- const stats = aggregator.getStats();
- expect(stats.totalErrors).toBe(105);
- expect(stats.recentErrors).toHaveLength(10); // Only last 10
- });
-
- it('should track different error types separately', () => {
- const networkError = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Network issue'
- );
- const validationError = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Validation issue'
- );
- const rateLimitError = new TelemetryError(
- TelemetryErrorType.RATE_LIMIT_ERROR,
- 'Rate limit hit'
- );
-
- aggregator.record(networkError);
- aggregator.record(networkError);
- aggregator.record(validationError);
- aggregator.record(rateLimitError);
-
- const stats = aggregator.getStats();
- expect(stats.totalErrors).toBe(4);
- expect(stats.errorsByType[TelemetryErrorType.NETWORK_ERROR]).toBe(2);
- expect(stats.errorsByType[TelemetryErrorType.VALIDATION_ERROR]).toBe(1);
- expect(stats.errorsByType[TelemetryErrorType.RATE_LIMIT_ERROR]).toBe(1);
- });
- });
-
- describe('getStats()', () => {
- it('should return empty stats when no errors recorded', () => {
- const stats = aggregator.getStats();
- expect(stats).toEqual({
- totalErrors: 0,
- errorsByType: {},
- mostCommonError: undefined,
- recentErrors: []
- });
- });
-
- it('should identify most common error type', () => {
- const networkError = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Network issue'
- );
- const validationError = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Validation issue'
- );
-
- // Network errors more frequent
- aggregator.record(networkError);
- aggregator.record(networkError);
- aggregator.record(networkError);
- aggregator.record(validationError);
-
- const stats = aggregator.getStats();
- expect(stats.mostCommonError).toBe(TelemetryErrorType.NETWORK_ERROR);
- });
-
- it('should return recent errors in order', () => {
- const error1 = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'First error'
- );
- const error2 = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Second error'
- );
- const error3 = new TelemetryError(
- TelemetryErrorType.RATE_LIMIT_ERROR,
- 'Third error'
- );
-
- aggregator.record(error1);
- aggregator.record(error2);
- aggregator.record(error3);
-
- const stats = aggregator.getStats();
- expect(stats.recentErrors).toHaveLength(3);
- expect(stats.recentErrors[0].message).toBe('First error');
- expect(stats.recentErrors[1].message).toBe('Second error');
- expect(stats.recentErrors[2].message).toBe('Third error');
- });
-
- it('should handle tie in most common error', () => {
- const networkError = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Network issue'
- );
- const validationError = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- 'Validation issue'
- );
-
- // Equal counts
- aggregator.record(networkError);
- aggregator.record(validationError);
-
- const stats = aggregator.getStats();
- // Should return one of them (implementation dependent)
- expect(stats.mostCommonError).toBeDefined();
- expect([TelemetryErrorType.NETWORK_ERROR, TelemetryErrorType.VALIDATION_ERROR])
- .toContain(stats.mostCommonError);
- });
- });
-
- describe('reset()', () => {
- it('should clear all error data', () => {
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Test error'
- );
- aggregator.record(error);
-
- // Verify data exists
- expect(aggregator.getStats().totalErrors).toBe(1);
-
- // Reset
- aggregator.reset();
-
- // Verify cleared
- const stats = aggregator.getStats();
- expect(stats).toEqual({
- totalErrors: 0,
- errorsByType: {},
- mostCommonError: undefined,
- recentErrors: []
- });
- });
- });
-
- describe('error detail management', () => {
- it('should preserve error context in details', () => {
- const context = { operation: 'flush', batchSize: 50 };
- const error = new TelemetryError(
- TelemetryErrorType.NETWORK_ERROR,
- 'Network failure',
- context,
- true
- );
-
- aggregator.record(error);
-
- const stats = aggregator.getStats();
- expect(stats.recentErrors[0]).toEqual({
- type: TelemetryErrorType.NETWORK_ERROR,
- message: 'Network failure',
- context,
- timestamp: error.timestamp,
- retryable: true
- });
- });
-
- it('should maintain error details queue with FIFO behavior', () => {
- // Add more than max to test queue behavior
- const errors = [];
- for (let i = 0; i < 15; i++) {
- const error = new TelemetryError(
- TelemetryErrorType.VALIDATION_ERROR,
- `Error ${i}`
- );
- errors.push(error);
- aggregator.record(error);
- }
-
- const stats = aggregator.getStats();
- // Should have last 10 errors (5-14)
- expect(stats.recentErrors).toHaveLength(10);
- expect(stats.recentErrors[0].message).toBe('Error 5');
- expect(stats.recentErrors[9].message).toBe('Error 14');
- });
- });
-});
\ No newline at end of file
diff --git a/tests/unit/telemetry/event-tracker.test.ts b/tests/unit/telemetry/telemetry-events.test.ts
similarity index 100%
rename from tests/unit/telemetry/event-tracker.test.ts
rename to tests/unit/telemetry/telemetry-events.test.ts
diff --git a/tests/unit/telemetry/batch-processor.test.ts b/tests/unit/telemetry/telemetry-processing.test.ts
similarity index 100%
rename from tests/unit/telemetry/batch-processor.test.ts
rename to tests/unit/telemetry/telemetry-processing.test.ts
diff --git a/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts b/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts
deleted file mode 100644
index 2545da8..0000000
--- a/tests/unit/telemetry/v2.18.3-fixes-verification.test.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-/**
- * Verification Tests for v2.18.3 Critical Fixes
- * Tests all 7 fixes from the code review:
- * - CRITICAL-01: Database checkpoints logged
- * - CRITICAL-02: Defensive initialization
- * - CRITICAL-03: Non-blocking checkpoints
- * - HIGH-01: ReDoS vulnerability fixed
- * - HIGH-02: Race condition prevention
- * - HIGH-03: Timeout on Supabase operations
- * - HIGH-04: N8N API checkpoints logged
- */
-
-import { EarlyErrorLogger } from '../../../src/telemetry/early-error-logger';
-import { sanitizeErrorMessageCore } from '../../../src/telemetry/error-sanitization-utils';
-import { STARTUP_CHECKPOINTS } from '../../../src/telemetry/startup-checkpoints';
-
-describe('v2.18.3 Critical Fixes Verification', () => {
- describe('CRITICAL-02: Defensive Initialization', () => {
- it('should initialize all fields to safe defaults before any throwing operation', () => {
- // Create instance - should not throw even if Supabase fails
- const logger = EarlyErrorLogger.getInstance();
- expect(logger).toBeDefined();
-
- // Should be able to call methods immediately without crashing
- expect(() => logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED)).not.toThrow();
- expect(() => logger.getCheckpoints()).not.toThrow();
- expect(() => logger.getStartupDuration()).not.toThrow();
- });
-
- it('should handle multiple getInstance calls correctly (singleton)', () => {
- const logger1 = EarlyErrorLogger.getInstance();
- const logger2 = EarlyErrorLogger.getInstance();
-
- expect(logger1).toBe(logger2);
- });
-
- it('should gracefully handle being disabled', () => {
- const logger = EarlyErrorLogger.getInstance();
-
- // Even if disabled, these should not throw
- expect(() => logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED)).not.toThrow();
- expect(() => logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test'))).not.toThrow();
- expect(() => logger.logStartupSuccess([], 100)).not.toThrow();
- });
- });
-
- describe('CRITICAL-03: Non-blocking Checkpoints', () => {
- it('logCheckpoint should be synchronous (fire-and-forget)', () => {
- const logger = EarlyErrorLogger.getInstance();
- const start = Date.now();
-
- // Should return immediately, not block
- logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED);
-
- const duration = Date.now() - start;
- expect(duration).toBeLessThan(50); // Should be nearly instant
- });
-
- it('logStartupError should be synchronous (fire-and-forget)', () => {
- const logger = EarlyErrorLogger.getInstance();
- const start = Date.now();
-
- // Should return immediately, not block
- logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test'));
-
- const duration = Date.now() - start;
- expect(duration).toBeLessThan(50); // Should be nearly instant
- });
-
- it('logStartupSuccess should be synchronous (fire-and-forget)', () => {
- const logger = EarlyErrorLogger.getInstance();
- const start = Date.now();
-
- // Should return immediately, not block
- logger.logStartupSuccess([STARTUP_CHECKPOINTS.PROCESS_STARTED], 100);
-
- const duration = Date.now() - start;
- expect(duration).toBeLessThan(50); // Should be nearly instant
- });
- });
-
- describe('HIGH-01: ReDoS Vulnerability Fixed', () => {
- it('should handle long token strings without catastrophic backtracking', () => {
- // This would cause ReDoS with the old regex: (? {
- // Test that the new pattern works correctly
- const testCases = [
- { input: 'token=abc123', shouldContain: '[REDACTED]' },
- { input: 'token: xyz789', shouldContain: '[REDACTED]' },
- { input: 'Bearer token=secret', shouldContain: '[TOKEN]' }, // Bearer gets handled separately
- { input: 'token = test', shouldContain: '[REDACTED]' },
- { input: 'some text here', shouldNotContain: '[REDACTED]' },
- ];
-
- testCases.forEach((testCase) => {
- const result = sanitizeErrorMessageCore(testCase.input);
- if ('shouldContain' in testCase) {
- expect(result).toContain(testCase.shouldContain);
- } else if ('shouldNotContain' in testCase) {
- expect(result).not.toContain(testCase.shouldNotContain);
- }
- });
- });
-
- it('should handle edge cases without hanging', () => {
- const edgeCases = [
- 'token=',
- 'token:',
- 'token = ',
- '= token',
- 'tokentoken=value',
- ];
-
- edgeCases.forEach((input) => {
- const start = Date.now();
- expect(() => sanitizeErrorMessageCore(input)).not.toThrow();
- const duration = Date.now() - start;
- expect(duration).toBeLessThan(50);
- });
- });
- });
-
- describe('HIGH-02: Race Condition Prevention', () => {
- it('should track initialization state with initPromise', async () => {
- const logger = EarlyErrorLogger.getInstance();
-
- // Should have waitForInit method
- expect(logger.waitForInit).toBeDefined();
- expect(typeof logger.waitForInit).toBe('function');
-
- // Should be able to wait for init without hanging
- await expect(logger.waitForInit()).resolves.not.toThrow();
- });
-
- it('should handle concurrent checkpoint logging safely', () => {
- const logger = EarlyErrorLogger.getInstance();
-
- // Log multiple checkpoints concurrently
- const checkpoints = [
- STARTUP_CHECKPOINTS.PROCESS_STARTED,
- STARTUP_CHECKPOINTS.DATABASE_CONNECTING,
- STARTUP_CHECKPOINTS.DATABASE_CONNECTED,
- STARTUP_CHECKPOINTS.N8N_API_CHECKING,
- STARTUP_CHECKPOINTS.N8N_API_READY,
- ];
-
- expect(() => {
- checkpoints.forEach(cp => logger.logCheckpoint(cp));
- }).not.toThrow();
- });
- });
-
- describe('HIGH-03: Timeout on Supabase Operations', () => {
- it('should implement withTimeout wrapper function', async () => {
- const logger = EarlyErrorLogger.getInstance();
-
- // We can't directly test the private withTimeout function,
- // but we can verify that operations don't hang indefinitely
- const start = Date.now();
-
- // Log an error - should complete quickly even if Supabase fails
- logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error('test'));
-
- // Give it a moment to attempt the operation
- await new Promise(resolve => setTimeout(resolve, 100));
-
- const duration = Date.now() - start;
-
- // Should not hang for more than 6 seconds (5s timeout + 1s buffer)
- expect(duration).toBeLessThan(6000);
- });
-
- it('should gracefully degrade when timeout occurs', async () => {
- const logger = EarlyErrorLogger.getInstance();
-
- // Multiple error logs should all complete quickly
- const promises = [];
- for (let i = 0; i < 5; i++) {
- logger.logStartupError(STARTUP_CHECKPOINTS.DATABASE_CONNECTING, new Error(`test-${i}`));
- promises.push(new Promise(resolve => setTimeout(resolve, 50)));
- }
-
- await Promise.all(promises);
-
- // All operations should have returned (fire-and-forget)
- expect(true).toBe(true);
- });
- });
-
- describe('Error Sanitization - Shared Utilities', () => {
- it('should remove sensitive patterns in correct order', () => {
- const sensitiveData = 'Error: https://api.example.com/token=secret123 user@email.com';
- const sanitized = sanitizeErrorMessageCore(sensitiveData);
-
- expect(sanitized).not.toContain('api.example.com');
- expect(sanitized).not.toContain('secret123');
- expect(sanitized).not.toContain('user@email.com');
- expect(sanitized).toContain('[URL]');
- expect(sanitized).toContain('[EMAIL]');
- });
-
- it('should handle AWS keys', () => {
- const input = 'Error: AWS key AKIAIOSFODNN7EXAMPLE leaked';
- const result = sanitizeErrorMessageCore(input);
-
- expect(result).not.toContain('AKIAIOSFODNN7EXAMPLE');
- expect(result).toContain('[AWS_KEY]');
- });
-
- it('should handle GitHub tokens', () => {
- const input = 'Auth failed with ghp_1234567890abcdefghijklmnopqrstuvwxyz';
- const result = sanitizeErrorMessageCore(input);
-
- expect(result).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
- expect(result).toContain('[GITHUB_TOKEN]');
- });
-
- it('should handle JWTs', () => {
- const input = 'JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghij';
- const result = sanitizeErrorMessageCore(input);
-
- // JWT pattern should match the full JWT
- expect(result).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
- expect(result).toContain('[JWT]');
- });
-
- it('should limit stack traces to 3 lines', () => {
- const stackTrace = 'Error: Test\n at func1 (file1.js:1:1)\n at func2 (file2.js:2:2)\n at func3 (file3.js:3:3)\n at func4 (file4.js:4:4)';
- const result = sanitizeErrorMessageCore(stackTrace);
-
- const lines = result.split('\n');
- expect(lines.length).toBeLessThanOrEqual(3);
- });
-
- it('should truncate at 500 chars after sanitization', () => {
- const longMessage = 'Error: ' + 'a'.repeat(1000);
- const result = sanitizeErrorMessageCore(longMessage);
-
- expect(result.length).toBeLessThanOrEqual(503); // 500 + '...'
- });
-
- it('should return safe default on sanitization failure', () => {
- // Pass something that might cause issues
- const result = sanitizeErrorMessageCore(null as any);
-
- expect(result).toBe('[SANITIZATION_FAILED]');
- });
- });
-
- describe('Checkpoint Integration', () => {
- it('should have all required checkpoint constants defined', () => {
- expect(STARTUP_CHECKPOINTS.PROCESS_STARTED).toBe('process_started');
- expect(STARTUP_CHECKPOINTS.DATABASE_CONNECTING).toBe('database_connecting');
- expect(STARTUP_CHECKPOINTS.DATABASE_CONNECTED).toBe('database_connected');
- expect(STARTUP_CHECKPOINTS.N8N_API_CHECKING).toBe('n8n_api_checking');
- expect(STARTUP_CHECKPOINTS.N8N_API_READY).toBe('n8n_api_ready');
- expect(STARTUP_CHECKPOINTS.TELEMETRY_INITIALIZING).toBe('telemetry_initializing');
- expect(STARTUP_CHECKPOINTS.TELEMETRY_READY).toBe('telemetry_ready');
- expect(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING).toBe('mcp_handshake_starting');
- expect(STARTUP_CHECKPOINTS.MCP_HANDSHAKE_COMPLETE).toBe('mcp_handshake_complete');
- expect(STARTUP_CHECKPOINTS.SERVER_READY).toBe('server_ready');
- });
-
- it('should track checkpoints correctly', () => {
- const logger = EarlyErrorLogger.getInstance();
- const initialCount = logger.getCheckpoints().length;
-
- logger.logCheckpoint(STARTUP_CHECKPOINTS.PROCESS_STARTED);
-
- const checkpoints = logger.getCheckpoints();
- expect(checkpoints.length).toBeGreaterThanOrEqual(initialCount);
- });
-
- it('should calculate startup duration', () => {
- const logger = EarlyErrorLogger.getInstance();
- const duration = logger.getStartupDuration();
-
- expect(duration).toBeGreaterThanOrEqual(0);
- expect(typeof duration).toBe('number');
- });
- });
-});
diff --git a/tests/unit/test-env-example.test.ts b/tests/unit/test-env-example.test.ts
deleted file mode 100644
index 08da0a5..0000000
--- a/tests/unit/test-env-example.test.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * Example test demonstrating test environment configuration usage
- */
-
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import {
- getTestConfig,
- getTestTimeout,
- isFeatureEnabled,
- isTestMode,
- loadTestEnvironment
-} from '@tests/setup/test-env';
-import {
- withEnvOverrides,
- createTestDatabasePath,
- getMockApiUrl,
- measurePerformance,
- createTestLogger,
- waitForCondition
-} from '@tests/helpers/env-helpers';
-
-describe('Test Environment Configuration Example', () => {
- let config: ReturnType;
- let logger: ReturnType;
-
- beforeAll(() => {
- // Initialize config inside beforeAll to ensure environment is loaded
- config = getTestConfig();
- logger = createTestLogger('test-env-example');
-
- logger.info('Test suite starting with configuration:', {
- environment: config.nodeEnv,
- database: config.database.path,
- apiUrl: config.api.url
- });
- });
-
- afterAll(() => {
- logger.info('Test suite completed');
- });
-
- it('should be in test mode', () => {
- const testConfig = getTestConfig();
- expect(isTestMode()).toBe(true);
- expect(testConfig.nodeEnv).toBe('test');
- expect(testConfig.isTest).toBe(true);
- });
-
- it('should have proper database configuration', () => {
- const testConfig = getTestConfig();
- expect(testConfig.database.path).toBeDefined();
- expect(testConfig.database.rebuildOnStart).toBe(false);
- expect(testConfig.database.seedData).toBe(true);
- });
-
- it.skip('should have mock API configuration', () => {
- const testConfig = getTestConfig();
- // Add debug logging for CI
- if (process.env.CI) {
- console.log('CI Environment Debug:', {
- NODE_ENV: process.env.NODE_ENV,
- N8N_API_URL: process.env.N8N_API_URL,
- N8N_API_KEY: process.env.N8N_API_KEY,
- configUrl: testConfig.api.url,
- configKey: testConfig.api.key
- });
- }
- expect(testConfig.api.url).toMatch(/mock-api/);
- expect(testConfig.api.key).toBe('test-api-key-12345');
- });
-
- it('should respect test timeouts', { timeout: getTestTimeout('unit') }, async () => {
- const timeout = getTestTimeout('unit');
- expect(timeout).toBe(5000);
-
- // Simulate async operation
- await new Promise(resolve => setTimeout(resolve, 100));
- });
-
- it('should support environment overrides', () => {
- const testConfig = getTestConfig();
- const originalLogLevel = testConfig.logging.level;
-
- const result = withEnvOverrides({
- LOG_LEVEL: 'debug',
- DEBUG: 'true'
- }, () => {
- const newConfig = getTestConfig();
- expect(newConfig.logging.level).toBe('debug');
- expect(newConfig.logging.debug).toBe(true);
- return 'success';
- });
-
- expect(result).toBe('success');
- const configAfter = getTestConfig();
- expect(configAfter.logging.level).toBe(originalLogLevel);
- });
-
- it('should generate unique test database paths', () => {
- const path1 = createTestDatabasePath('feature1');
- const path2 = createTestDatabasePath('feature1');
-
- if (path1 !== ':memory:') {
- expect(path1).not.toBe(path2);
- expect(path1).toMatch(/test-feature1-\d+-\w+\.db$/);
- }
- });
-
- it('should construct mock API URLs', () => {
- const testConfig = getTestConfig();
- const baseUrl = getMockApiUrl();
- const endpointUrl = getMockApiUrl('/nodes');
-
- expect(baseUrl).toBe(testConfig.api.url);
- expect(endpointUrl).toBe(`${testConfig.api.url}/nodes`);
- });
-
- it.skipIf(!isFeatureEnabled('mockExternalApis'))('should check feature flags', () => {
- const testConfig = getTestConfig();
- expect(testConfig.features.mockExternalApis).toBe(true);
- expect(isFeatureEnabled('mockExternalApis')).toBe(true);
- });
-
- it('should measure performance', () => {
- const measure = measurePerformance('test-operation');
-
- // Test the performance measurement utility structure and behavior
- // rather than relying on timing precision which is unreliable in CI
-
- // Capture initial state
- const startTime = performance.now();
-
- // Add some marks
- measure.mark('start-processing');
-
- // Do some minimal synchronous work
- let sum = 0;
- for (let i = 0; i < 10000; i++) {
- sum += i;
- }
-
- measure.mark('mid-processing');
-
- // Do a bit more work
- for (let i = 0; i < 10000; i++) {
- sum += i * 2;
- }
-
- const results = measure.end();
- const endTime = performance.now();
-
- // Test the utility's correctness rather than exact timing
- expect(results).toHaveProperty('total');
- expect(results).toHaveProperty('marks');
- expect(typeof results.total).toBe('number');
- expect(results.total).toBeGreaterThan(0);
-
- // Verify marks structure
- expect(results.marks).toHaveProperty('start-processing');
- expect(results.marks).toHaveProperty('mid-processing');
- expect(typeof results.marks['start-processing']).toBe('number');
- expect(typeof results.marks['mid-processing']).toBe('number');
-
- // Verify logical order of marks (this should always be true)
- expect(results.marks['start-processing']).toBeLessThan(results.marks['mid-processing']);
- expect(results.marks['start-processing']).toBeGreaterThanOrEqual(0);
- expect(results.marks['mid-processing']).toBeLessThan(results.total);
-
- // Verify the total time is reasonable (should be between manual measurements)
- const manualTotal = endTime - startTime;
- expect(results.total).toBeLessThanOrEqual(manualTotal + 1); // Allow 1ms tolerance
-
- // Verify work was actually done
- expect(sum).toBeGreaterThan(0);
- });
-
- it('should wait for conditions', async () => {
- let counter = 0;
- const incrementCounter = setInterval(() => counter++, 100);
-
- try {
- await waitForCondition(
- () => counter >= 3,
- {
- timeout: 1000,
- interval: 50,
- message: 'Counter did not reach 3'
- }
- );
-
- expect(counter).toBeGreaterThanOrEqual(3);
- } finally {
- clearInterval(incrementCounter);
- }
- });
-
- it('should have proper logging configuration', () => {
- const testConfig = getTestConfig();
- expect(testConfig.logging.level).toBe('error');
- expect(testConfig.logging.debug).toBe(false);
- expect(testConfig.logging.showStack).toBe(true);
-
- // Logger should respect configuration
- logger.debug('This should not appear in test output');
- logger.error('This should appear in test output');
- });
-
- it('should have performance thresholds', () => {
- const testConfig = getTestConfig();
- expect(testConfig.performance.thresholds.apiResponse).toBe(100);
- expect(testConfig.performance.thresholds.dbQuery).toBe(50);
- expect(testConfig.performance.thresholds.nodeParse).toBe(200);
- });
-
- it('should disable caching and rate limiting in tests', () => {
- const testConfig = getTestConfig();
- expect(testConfig.cache.enabled).toBe(false);
- expect(testConfig.cache.ttl).toBe(0);
- expect(testConfig.rateLimiting.max).toBe(0);
- expect(testConfig.rateLimiting.window).toBe(0);
- });
-
- it('should configure test paths', () => {
- const testConfig = getTestConfig();
- expect(testConfig.paths.fixtures).toBe('./tests/fixtures');
- expect(testConfig.paths.data).toBe('./tests/data');
- expect(testConfig.paths.snapshots).toBe('./tests/__snapshots__');
- });
-
- it('should support MSW configuration', () => {
- // Ensure test environment is loaded
- if (!process.env.MSW_ENABLED) {
- loadTestEnvironment();
- }
-
- const testConfig = getTestConfig();
- expect(testConfig.mocking.msw.enabled).toBe(true);
- expect(testConfig.mocking.msw.apiDelay).toBe(0);
- });
-});
\ No newline at end of file
diff --git a/tests/unit/test-infrastructure.test.ts b/tests/unit/test-infrastructure.test.ts
deleted file mode 100644
index ae2f602..0000000
--- a/tests/unit/test-infrastructure.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { nodeFactory, webhookNodeFactory, slackNodeFactory } from '@tests/fixtures/factories/node.factory';
-
-// Mock better-sqlite3
-vi.mock('better-sqlite3');
-
-describe('Test Infrastructure', () => {
- describe('Database Mock', () => {
- it('should create a mock database instance', async () => {
- const Database = (await import('better-sqlite3')).default;
- const db = new Database(':memory:');
-
- expect(Database).toHaveBeenCalled();
- expect(db).toBeDefined();
- expect(db.prepare).toBeDefined();
- expect(db.exec).toBeDefined();
- expect(db.close).toBeDefined();
- });
-
- it('should handle basic CRUD operations', async () => {
- const { MockDatabase } = await import('@tests/unit/database/__mocks__/better-sqlite3');
- const db = new MockDatabase();
-
- // Test data seeding
- db._seedData('nodes', [
- { id: '1', name: 'test-node', type: 'webhook' }
- ]);
-
- // Test SELECT
- const selectStmt = db.prepare('SELECT * FROM nodes');
- const allNodes = selectStmt.all();
- expect(allNodes).toHaveLength(1);
- expect(allNodes[0]).toEqual({ id: '1', name: 'test-node', type: 'webhook' });
-
- // Test INSERT
- const insertStmt = db.prepare('INSERT INTO nodes (id, name, type) VALUES (?, ?, ?)');
- const result = insertStmt.run({ id: '2', name: 'new-node', type: 'slack' });
- expect(result.changes).toBe(1);
-
- // Verify insert worked
- const allNodesAfter = selectStmt.all();
- expect(allNodesAfter).toHaveLength(2);
- });
- });
-
- describe('Node Factory', () => {
- it('should create a basic node definition', () => {
- const node = nodeFactory.build();
-
- expect(node).toMatchObject({
- name: expect.any(String),
- displayName: expect.any(String),
- description: expect.any(String),
- version: expect.any(Number),
- defaults: {
- name: expect.any(String)
- },
- inputs: ['main'],
- outputs: ['main'],
- properties: expect.any(Array),
- credentials: []
- });
- });
-
- it('should create a webhook node', () => {
- const webhook = webhookNodeFactory.build();
-
- expect(webhook).toMatchObject({
- name: 'webhook',
- displayName: 'Webhook',
- description: 'Starts the workflow when a webhook is called',
- group: ['trigger'],
- properties: expect.arrayContaining([
- expect.objectContaining({
- name: 'path',
- type: 'string',
- required: true
- }),
- expect.objectContaining({
- name: 'method',
- type: 'options'
- })
- ])
- });
- });
-
- it('should create a slack node', () => {
- const slack = slackNodeFactory.build();
-
- expect(slack).toMatchObject({
- name: 'slack',
- displayName: 'Slack',
- description: 'Send messages to Slack',
- group: ['output'],
- credentials: [
- {
- name: 'slackApi',
- required: true
- }
- ],
- properties: expect.arrayContaining([
- expect.objectContaining({
- name: 'resource',
- type: 'options'
- }),
- expect.objectContaining({
- name: 'operation',
- type: 'options',
- displayOptions: {
- show: {
- resource: ['message']
- }
- }
- })
- ])
- });
- });
-
- it('should allow overriding factory defaults', () => {
- const customNode = nodeFactory.build({
- name: 'custom-node',
- displayName: 'Custom Node',
- version: 2
- });
-
- expect(customNode.name).toBe('custom-node');
- expect(customNode.displayName).toBe('Custom Node');
- expect(customNode.version).toBe(2);
- });
-
- it('should create multiple unique nodes', () => {
- const nodes = nodeFactory.buildList(5);
-
- expect(nodes).toHaveLength(5);
- const names = nodes.map(n => n.name);
- const uniqueNames = new Set(names);
- expect(uniqueNames.size).toBe(5);
- });
- });
-});
\ No newline at end of file
diff --git a/vitest.config.benchmark.ts b/vitest.config.benchmark.ts
deleted file mode 100644
index b9c4faa..0000000
--- a/vitest.config.benchmark.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { defineConfig } from 'vitest/config';
-import path from 'path';
-
-export default defineConfig({
- test: {
- globals: true,
- environment: 'node',
- include: ['tests/benchmarks/**/*.bench.ts'],
- benchmark: {
- // Benchmark specific options
- include: ['tests/benchmarks/**/*.bench.ts'],
- reporters: ['default'],
- },
- setupFiles: [],
- pool: 'forks',
- poolOptions: {
- forks: {
- singleFork: true,
- },
- },
- // Increase timeout for benchmarks
- testTimeout: 120000,
- hookTimeout: 120000,
- },
- resolve: {
- alias: {
- '@': path.resolve(__dirname, './src'),
- '@tests': path.resolve(__dirname, './tests'),
- },
- },
-});
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
index f4463ee..de6fcc6 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -15,13 +15,12 @@ export default defineConfig({
pool: 'threads',
poolOptions: {
threads: {
- singleThread: process.env.TEST_PARALLEL !== 'true',
maxThreads: parseInt(process.env.TEST_MAX_WORKERS || '4', 10),
minThreads: 1
}
},
- // Retry configuration
- retry: parseInt(process.env.TEST_RETRY_ATTEMPTS || '2', 10),
+ // No retries - flaky tests should be fixed, not masked
+ retry: 0,
// Test reporter - reduce reporters in CI to prevent hanging
reporters: process.env.CI ? ['default', 'junit'] : ['default'],
outputFile: {
@@ -45,10 +44,10 @@ export default defineConfig({
'**/__mocks__/**'
],
thresholds: {
- lines: 80,
- functions: 80,
- branches: 75,
- statements: 80
+ lines: 75,
+ functions: 75,
+ branches: 70,
+ statements: 75
},
// Add coverage-specific settings to prevent hanging
all: false, // Don't collect coverage for untested files