This is page 29 of 59. Use http://codebase.md/czlonkowski/n8n-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── _config.yml
├── .claude
│ └── agents
│ ├── code-reviewer.md
│ ├── context-manager.md
│ ├── debugger.md
│ ├── deployment-engineer.md
│ ├── mcp-backend-engineer.md
│ ├── n8n-mcp-tester.md
│ ├── technical-researcher.md
│ └── test-automator.md
├── .dockerignore
├── .env.docker
├── .env.example
├── .env.n8n.example
├── .env.test
├── .env.test.example
├── .github
│ ├── ABOUT.md
│ ├── BENCHMARK_THRESHOLDS.md
│ ├── FUNDING.yml
│ ├── gh-pages.yml
│ ├── secret_scanning.yml
│ └── workflows
│ ├── benchmark-pr.yml
│ ├── benchmark.yml
│ ├── docker-build-fast.yml
│ ├── docker-build-n8n.yml
│ ├── docker-build.yml
│ ├── release.yml
│ ├── test.yml
│ └── update-n8n-deps.yml
├── .gitignore
├── .npmignore
├── ATTRIBUTION.md
├── CHANGELOG.md
├── CLAUDE.md
├── codecov.yml
├── coverage.json
├── data
│ ├── .gitkeep
│ ├── nodes.db
│ ├── nodes.db-shm
│ ├── nodes.db-wal
│ └── templates.db
├── deploy
│ └── quick-deploy-n8n.sh
├── docker
│ ├── docker-entrypoint.sh
│ ├── n8n-mcp
│ ├── parse-config.js
│ └── README.md
├── docker-compose.buildkit.yml
├── docker-compose.extract.yml
├── docker-compose.n8n.yml
├── docker-compose.override.yml.example
├── docker-compose.test-n8n.yml
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.railway
├── Dockerfile.test
├── docs
│ ├── AUTOMATED_RELEASES.md
│ ├── BENCHMARKS.md
│ ├── CHANGELOG.md
│ ├── CLAUDE_CODE_SETUP.md
│ ├── CLAUDE_INTERVIEW.md
│ ├── CODECOV_SETUP.md
│ ├── CODEX_SETUP.md
│ ├── CURSOR_SETUP.md
│ ├── DEPENDENCY_UPDATES.md
│ ├── DOCKER_README.md
│ ├── DOCKER_TROUBLESHOOTING.md
│ ├── FINAL_AI_VALIDATION_SPEC.md
│ ├── FLEXIBLE_INSTANCE_CONFIGURATION.md
│ ├── HTTP_DEPLOYMENT.md
│ ├── img
│ │ ├── cc_command.png
│ │ ├── cc_connected.png
│ │ ├── codex_connected.png
│ │ ├── cursor_tut.png
│ │ ├── Railway_api.png
│ │ ├── Railway_server_address.png
│ │ ├── vsc_ghcp_chat_agent_mode.png
│ │ ├── vsc_ghcp_chat_instruction_files.png
│ │ ├── vsc_ghcp_chat_thinking_tool.png
│ │ └── windsurf_tut.png
│ ├── INSTALLATION.md
│ ├── LIBRARY_USAGE.md
│ ├── local
│ │ ├── DEEP_DIVE_ANALYSIS_2025-10-02.md
│ │ ├── DEEP_DIVE_ANALYSIS_README.md
│ │ ├── Deep_dive_p1_p2.md
│ │ ├── integration-testing-plan.md
│ │ ├── integration-tests-phase1-summary.md
│ │ ├── N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
│ │ ├── P0_IMPLEMENTATION_PLAN.md
│ │ └── TEMPLATE_MINING_ANALYSIS.md
│ ├── MCP_ESSENTIALS_README.md
│ ├── MCP_QUICK_START_GUIDE.md
│ ├── N8N_DEPLOYMENT.md
│ ├── RAILWAY_DEPLOYMENT.md
│ ├── README_CLAUDE_SETUP.md
│ ├── README.md
│ ├── tools-documentation-usage.md
│ ├── VS_CODE_PROJECT_SETUP.md
│ ├── WINDSURF_SETUP.md
│ └── workflow-diff-examples.md
├── examples
│ └── enhanced-documentation-demo.js
├── fetch_log.txt
├── LICENSE
├── MEMORY_N8N_UPDATE.md
├── MEMORY_TEMPLATE_UPDATE.md
├── monitor_fetch.sh
├── N8N_HTTP_STREAMABLE_SETUP.md
├── n8n-nodes.db
├── P0-R3-TEST-PLAN.md
├── package-lock.json
├── package.json
├── package.runtime.json
├── PRIVACY.md
├── railway.json
├── README.md
├── renovate.json
├── scripts
│ ├── analyze-optimization.sh
│ ├── audit-schema-coverage.ts
│ ├── build-optimized.sh
│ ├── compare-benchmarks.js
│ ├── demo-optimization.sh
│ ├── deploy-http.sh
│ ├── deploy-to-vm.sh
│ ├── export-webhook-workflows.ts
│ ├── extract-changelog.js
│ ├── extract-from-docker.js
│ ├── extract-nodes-docker.sh
│ ├── extract-nodes-simple.sh
│ ├── format-benchmark-results.js
│ ├── generate-benchmark-stub.js
│ ├── generate-detailed-reports.js
│ ├── generate-test-summary.js
│ ├── http-bridge.js
│ ├── mcp-http-client.js
│ ├── migrate-nodes-fts.ts
│ ├── migrate-tool-docs.ts
│ ├── n8n-docs-mcp.service
│ ├── nginx-n8n-mcp.conf
│ ├── prebuild-fts5.ts
│ ├── prepare-release.js
│ ├── publish-npm-quick.sh
│ ├── publish-npm.sh
│ ├── quick-test.ts
│ ├── run-benchmarks-ci.js
│ ├── sync-runtime-version.js
│ ├── test-ai-validation-debug.ts
│ ├── test-code-node-enhancements.ts
│ ├── test-code-node-fixes.ts
│ ├── test-docker-config.sh
│ ├── test-docker-fingerprint.ts
│ ├── test-docker-optimization.sh
│ ├── test-docker.sh
│ ├── test-empty-connection-validation.ts
│ ├── test-error-message-tracking.ts
│ ├── test-error-output-validation.ts
│ ├── test-error-validation.js
│ ├── test-essentials.ts
│ ├── test-expression-code-validation.ts
│ ├── test-expression-format-validation.js
│ ├── test-fts5-search.ts
│ ├── test-fuzzy-fix.ts
│ ├── test-fuzzy-simple.ts
│ ├── test-helpers-validation.ts
│ ├── test-http-search.ts
│ ├── test-http.sh
│ ├── test-jmespath-validation.ts
│ ├── test-multi-tenant-simple.ts
│ ├── test-multi-tenant.ts
│ ├── test-n8n-integration.sh
│ ├── test-node-info.js
│ ├── test-node-type-validation.ts
│ ├── test-nodes-base-prefix.ts
│ ├── test-operation-validation.ts
│ ├── test-optimized-docker.sh
│ ├── test-release-automation.js
│ ├── test-search-improvements.ts
│ ├── test-security.ts
│ ├── test-single-session.sh
│ ├── test-sqljs-triggers.ts
│ ├── test-telemetry-debug.ts
│ ├── test-telemetry-direct.ts
│ ├── test-telemetry-env.ts
│ ├── test-telemetry-integration.ts
│ ├── test-telemetry-no-select.ts
│ ├── test-telemetry-security.ts
│ ├── test-telemetry-simple.ts
│ ├── test-typeversion-validation.ts
│ ├── test-url-configuration.ts
│ ├── test-user-id-persistence.ts
│ ├── test-webhook-validation.ts
│ ├── test-workflow-insert.ts
│ ├── test-workflow-sanitizer.ts
│ ├── test-workflow-tracking-debug.ts
│ ├── update-and-publish-prep.sh
│ ├── update-n8n-deps.js
│ ├── update-readme-version.js
│ ├── vitest-benchmark-json-reporter.js
│ └── vitest-benchmark-reporter.ts
├── SECURITY.md
├── src
│ ├── config
│ │ └── n8n-api.ts
│ ├── data
│ │ └── canonical-ai-tool-examples.json
│ ├── database
│ │ ├── database-adapter.ts
│ │ ├── migrations
│ │ │ └── add-template-node-configs.sql
│ │ ├── node-repository.ts
│ │ ├── nodes.db
│ │ ├── schema-optimized.sql
│ │ └── schema.sql
│ ├── errors
│ │ └── validation-service-error.ts
│ ├── http-server-single-session.ts
│ ├── http-server.ts
│ ├── index.ts
│ ├── loaders
│ │ └── node-loader.ts
│ ├── mappers
│ │ └── docs-mapper.ts
│ ├── mcp
│ │ ├── handlers-n8n-manager.ts
│ │ ├── handlers-workflow-diff.ts
│ │ ├── index.ts
│ │ ├── server.ts
│ │ ├── stdio-wrapper.ts
│ │ ├── tool-docs
│ │ │ ├── configuration
│ │ │ │ ├── get-node-as-tool-info.ts
│ │ │ │ ├── get-node-documentation.ts
│ │ │ │ ├── get-node-essentials.ts
│ │ │ │ ├── get-node-info.ts
│ │ │ │ ├── get-property-dependencies.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── search-node-properties.ts
│ │ │ ├── discovery
│ │ │ │ ├── get-database-statistics.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── list-ai-tools.ts
│ │ │ │ ├── list-nodes.ts
│ │ │ │ └── search-nodes.ts
│ │ │ ├── guides
│ │ │ │ ├── ai-agents-guide.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── system
│ │ │ │ ├── index.ts
│ │ │ │ ├── n8n-diagnostic.ts
│ │ │ │ ├── n8n-health-check.ts
│ │ │ │ ├── n8n-list-available-tools.ts
│ │ │ │ └── tools-documentation.ts
│ │ │ ├── templates
│ │ │ │ ├── get-template.ts
│ │ │ │ ├── get-templates-for-task.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── list-node-templates.ts
│ │ │ │ ├── list-tasks.ts
│ │ │ │ ├── search-templates-by-metadata.ts
│ │ │ │ └── search-templates.ts
│ │ │ ├── types.ts
│ │ │ ├── validation
│ │ │ │ ├── index.ts
│ │ │ │ ├── validate-node-minimal.ts
│ │ │ │ ├── validate-node-operation.ts
│ │ │ │ ├── validate-workflow-connections.ts
│ │ │ │ ├── validate-workflow-expressions.ts
│ │ │ │ └── validate-workflow.ts
│ │ │ └── workflow_management
│ │ │ ├── index.ts
│ │ │ ├── n8n-autofix-workflow.ts
│ │ │ ├── n8n-create-workflow.ts
│ │ │ ├── n8n-delete-execution.ts
│ │ │ ├── n8n-delete-workflow.ts
│ │ │ ├── n8n-get-execution.ts
│ │ │ ├── n8n-get-workflow-details.ts
│ │ │ ├── n8n-get-workflow-minimal.ts
│ │ │ ├── n8n-get-workflow-structure.ts
│ │ │ ├── n8n-get-workflow.ts
│ │ │ ├── n8n-list-executions.ts
│ │ │ ├── n8n-list-workflows.ts
│ │ │ ├── n8n-trigger-webhook-workflow.ts
│ │ │ ├── n8n-update-full-workflow.ts
│ │ │ ├── n8n-update-partial-workflow.ts
│ │ │ └── n8n-validate-workflow.ts
│ │ ├── tools-documentation.ts
│ │ ├── tools-n8n-friendly.ts
│ │ ├── tools-n8n-manager.ts
│ │ ├── tools.ts
│ │ └── workflow-examples.ts
│ ├── mcp-engine.ts
│ ├── mcp-tools-engine.ts
│ ├── n8n
│ │ ├── MCPApi.credentials.ts
│ │ └── MCPNode.node.ts
│ ├── parsers
│ │ ├── node-parser.ts
│ │ ├── property-extractor.ts
│ │ └── simple-parser.ts
│ ├── scripts
│ │ ├── debug-http-search.ts
│ │ ├── extract-from-docker.ts
│ │ ├── fetch-templates-robust.ts
│ │ ├── fetch-templates.ts
│ │ ├── rebuild-database.ts
│ │ ├── rebuild-optimized.ts
│ │ ├── rebuild.ts
│ │ ├── sanitize-templates.ts
│ │ ├── seed-canonical-ai-examples.ts
│ │ ├── test-autofix-documentation.ts
│ │ ├── test-autofix-workflow.ts
│ │ ├── test-execution-filtering.ts
│ │ ├── test-node-suggestions.ts
│ │ ├── test-protocol-negotiation.ts
│ │ ├── test-summary.ts
│ │ ├── test-webhook-autofix.ts
│ │ ├── validate.ts
│ │ └── validation-summary.ts
│ ├── services
│ │ ├── ai-node-validator.ts
│ │ ├── ai-tool-validators.ts
│ │ ├── confidence-scorer.ts
│ │ ├── config-validator.ts
│ │ ├── enhanced-config-validator.ts
│ │ ├── example-generator.ts
│ │ ├── execution-processor.ts
│ │ ├── expression-format-validator.ts
│ │ ├── expression-validator.ts
│ │ ├── n8n-api-client.ts
│ │ ├── n8n-validation.ts
│ │ ├── node-documentation-service.ts
│ │ ├── node-sanitizer.ts
│ │ ├── node-similarity-service.ts
│ │ ├── node-specific-validators.ts
│ │ ├── operation-similarity-service.ts
│ │ ├── property-dependencies.ts
│ │ ├── property-filter.ts
│ │ ├── resource-similarity-service.ts
│ │ ├── sqlite-storage-service.ts
│ │ ├── task-templates.ts
│ │ ├── universal-expression-validator.ts
│ │ ├── workflow-auto-fixer.ts
│ │ ├── workflow-diff-engine.ts
│ │ └── workflow-validator.ts
│ ├── telemetry
│ │ ├── batch-processor.ts
│ │ ├── config-manager.ts
│ │ ├── early-error-logger.ts
│ │ ├── error-sanitization-utils.ts
│ │ ├── error-sanitizer.ts
│ │ ├── event-tracker.ts
│ │ ├── event-validator.ts
│ │ ├── index.ts
│ │ ├── performance-monitor.ts
│ │ ├── rate-limiter.ts
│ │ ├── startup-checkpoints.ts
│ │ ├── telemetry-error.ts
│ │ ├── telemetry-manager.ts
│ │ ├── telemetry-types.ts
│ │ └── workflow-sanitizer.ts
│ ├── templates
│ │ ├── batch-processor.ts
│ │ ├── metadata-generator.ts
│ │ ├── README.md
│ │ ├── template-fetcher.ts
│ │ ├── template-repository.ts
│ │ └── template-service.ts
│ ├── types
│ │ ├── index.ts
│ │ ├── instance-context.ts
│ │ ├── n8n-api.ts
│ │ ├── node-types.ts
│ │ └── workflow-diff.ts
│ └── utils
│ ├── auth.ts
│ ├── bridge.ts
│ ├── cache-utils.ts
│ ├── console-manager.ts
│ ├── documentation-fetcher.ts
│ ├── enhanced-documentation-fetcher.ts
│ ├── error-handler.ts
│ ├── example-generator.ts
│ ├── fixed-collection-validator.ts
│ ├── logger.ts
│ ├── mcp-client.ts
│ ├── n8n-errors.ts
│ ├── node-source-extractor.ts
│ ├── node-type-normalizer.ts
│ ├── node-type-utils.ts
│ ├── node-utils.ts
│ ├── npm-version-checker.ts
│ ├── protocol-version.ts
│ ├── simple-cache.ts
│ ├── ssrf-protection.ts
│ ├── template-node-resolver.ts
│ ├── template-sanitizer.ts
│ ├── url-detector.ts
│ ├── validation-schemas.ts
│ └── version.ts
├── test-output.txt
├── test-reinit-fix.sh
├── tests
│ ├── __snapshots__
│ │ └── .gitkeep
│ ├── auth.test.ts
│ ├── benchmarks
│ │ ├── database-queries.bench.ts
│ │ ├── index.ts
│ │ ├── mcp-tools.bench.ts
│ │ ├── mcp-tools.bench.ts.disabled
│ │ ├── mcp-tools.bench.ts.skip
│ │ ├── node-loading.bench.ts.disabled
│ │ ├── README.md
│ │ ├── search-operations.bench.ts.disabled
│ │ └── validation-performance.bench.ts.disabled
│ ├── bridge.test.ts
│ ├── comprehensive-extraction-test.js
│ ├── data
│ │ └── .gitkeep
│ ├── debug-slack-doc.js
│ ├── demo-enhanced-documentation.js
│ ├── docker-tests-README.md
│ ├── error-handler.test.ts
│ ├── examples
│ │ └── using-database-utils.test.ts
│ ├── extracted-nodes-db
│ │ ├── database-import.json
│ │ ├── extraction-report.json
│ │ ├── insert-nodes.sql
│ │ ├── n8n-nodes-base__Airtable.json
│ │ ├── n8n-nodes-base__Discord.json
│ │ ├── n8n-nodes-base__Function.json
│ │ ├── n8n-nodes-base__HttpRequest.json
│ │ ├── n8n-nodes-base__If.json
│ │ ├── n8n-nodes-base__Slack.json
│ │ ├── n8n-nodes-base__SplitInBatches.json
│ │ └── n8n-nodes-base__Webhook.json
│ ├── factories
│ │ ├── node-factory.ts
│ │ └── property-definition-factory.ts
│ ├── fixtures
│ │ ├── .gitkeep
│ │ ├── database
│ │ │ └── test-nodes.json
│ │ ├── factories
│ │ │ ├── node.factory.ts
│ │ │ └── parser-node.factory.ts
│ │ └── template-configs.ts
│ ├── helpers
│ │ └── env-helpers.ts
│ ├── http-server-auth.test.ts
│ ├── integration
│ │ ├── ai-validation
│ │ │ ├── ai-agent-validation.test.ts
│ │ │ ├── ai-tool-validation.test.ts
│ │ │ ├── chat-trigger-validation.test.ts
│ │ │ ├── e2e-validation.test.ts
│ │ │ ├── helpers.ts
│ │ │ ├── llm-chain-validation.test.ts
│ │ │ ├── README.md
│ │ │ └── TEST_REPORT.md
│ │ ├── ci
│ │ │ └── database-population.test.ts
│ │ ├── database
│ │ │ ├── connection-management.test.ts
│ │ │ ├── empty-database.test.ts
│ │ │ ├── fts5-search.test.ts
│ │ │ ├── node-fts5-search.test.ts
│ │ │ ├── node-repository.test.ts
│ │ │ ├── performance.test.ts
│ │ │ ├── sqljs-memory-leak.test.ts
│ │ │ ├── template-node-configs.test.ts
│ │ │ ├── template-repository.test.ts
│ │ │ ├── test-utils.ts
│ │ │ └── transactions.test.ts
│ │ ├── database-integration.test.ts
│ │ ├── docker
│ │ │ ├── docker-config.test.ts
│ │ │ ├── docker-entrypoint.test.ts
│ │ │ └── test-helpers.ts
│ │ ├── flexible-instance-config.test.ts
│ │ ├── mcp
│ │ │ └── template-examples-e2e.test.ts
│ │ ├── mcp-protocol
│ │ │ ├── basic-connection.test.ts
│ │ │ ├── error-handling.test.ts
│ │ │ ├── performance.test.ts
│ │ │ ├── protocol-compliance.test.ts
│ │ │ ├── README.md
│ │ │ ├── session-management.test.ts
│ │ │ ├── test-helpers.ts
│ │ │ ├── tool-invocation.test.ts
│ │ │ └── workflow-error-validation.test.ts
│ │ ├── msw-setup.test.ts
│ │ ├── n8n-api
│ │ │ ├── executions
│ │ │ │ ├── delete-execution.test.ts
│ │ │ │ ├── get-execution.test.ts
│ │ │ │ ├── list-executions.test.ts
│ │ │ │ └── trigger-webhook.test.ts
│ │ │ ├── scripts
│ │ │ │ └── cleanup-orphans.ts
│ │ │ ├── system
│ │ │ │ ├── diagnostic.test.ts
│ │ │ │ ├── health-check.test.ts
│ │ │ │ └── list-tools.test.ts
│ │ │ ├── test-connection.ts
│ │ │ ├── types
│ │ │ │ └── mcp-responses.ts
│ │ │ ├── utils
│ │ │ │ ├── cleanup-helpers.ts
│ │ │ │ ├── credentials.ts
│ │ │ │ ├── factories.ts
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── mcp-context.ts
│ │ │ │ ├── n8n-client.ts
│ │ │ │ ├── node-repository.ts
│ │ │ │ ├── response-types.ts
│ │ │ │ ├── test-context.ts
│ │ │ │ └── webhook-workflows.ts
│ │ │ └── workflows
│ │ │ ├── autofix-workflow.test.ts
│ │ │ ├── create-workflow.test.ts
│ │ │ ├── delete-workflow.test.ts
│ │ │ ├── get-workflow-details.test.ts
│ │ │ ├── get-workflow-minimal.test.ts
│ │ │ ├── get-workflow-structure.test.ts
│ │ │ ├── get-workflow.test.ts
│ │ │ ├── list-workflows.test.ts
│ │ │ ├── smart-parameters.test.ts
│ │ │ ├── update-partial-workflow.test.ts
│ │ │ ├── update-workflow.test.ts
│ │ │ └── validate-workflow.test.ts
│ │ ├── security
│ │ │ ├── command-injection-prevention.test.ts
│ │ │ └── rate-limiting.test.ts
│ │ ├── setup
│ │ │ ├── integration-setup.ts
│ │ │ └── msw-test-server.ts
│ │ ├── telemetry
│ │ │ ├── docker-user-id-stability.test.ts
│ │ │ └── mcp-telemetry.test.ts
│ │ ├── templates
│ │ │ └── metadata-operations.test.ts
│ │ └── workflow-creation-node-type-format.test.ts
│ ├── logger.test.ts
│ ├── MOCKING_STRATEGY.md
│ ├── mocks
│ │ ├── n8n-api
│ │ │ ├── data
│ │ │ │ ├── credentials.ts
│ │ │ │ ├── executions.ts
│ │ │ │ └── workflows.ts
│ │ │ ├── handlers.ts
│ │ │ └── index.ts
│ │ └── README.md
│ ├── node-storage-export.json
│ ├── setup
│ │ ├── global-setup.ts
│ │ ├── msw-setup.ts
│ │ ├── TEST_ENV_DOCUMENTATION.md
│ │ └── test-env.ts
│ ├── test-database-extraction.js
│ ├── test-direct-extraction.js
│ ├── test-enhanced-documentation.js
│ ├── test-enhanced-integration.js
│ ├── test-mcp-extraction.js
│ ├── test-mcp-server-extraction.js
│ ├── test-mcp-tools-integration.js
│ ├── test-node-documentation-service.js
│ ├── test-node-list.js
│ ├── test-package-info.js
│ ├── test-parsing-operations.js
│ ├── test-slack-node-complete.js
│ ├── test-small-rebuild.js
│ ├── test-sqlite-search.js
│ ├── test-storage-system.js
│ ├── unit
│ │ ├── __mocks__
│ │ │ ├── n8n-nodes-base.test.ts
│ │ │ ├── n8n-nodes-base.ts
│ │ │ └── README.md
│ │ ├── database
│ │ │ ├── __mocks__
│ │ │ │ └── better-sqlite3.ts
│ │ │ ├── database-adapter-unit.test.ts
│ │ │ ├── node-repository-core.test.ts
│ │ │ ├── node-repository-operations.test.ts
│ │ │ ├── node-repository-outputs.test.ts
│ │ │ ├── README.md
│ │ │ └── template-repository-core.test.ts
│ │ ├── docker
│ │ │ ├── config-security.test.ts
│ │ │ ├── edge-cases.test.ts
│ │ │ ├── parse-config.test.ts
│ │ │ └── serve-command.test.ts
│ │ ├── errors
│ │ │ └── validation-service-error.test.ts
│ │ ├── examples
│ │ │ └── using-n8n-nodes-base-mock.test.ts
│ │ ├── flexible-instance-security-advanced.test.ts
│ │ ├── flexible-instance-security.test.ts
│ │ ├── http-server
│ │ │ └── multi-tenant-support.test.ts
│ │ ├── http-server-n8n-mode.test.ts
│ │ ├── http-server-n8n-reinit.test.ts
│ │ ├── http-server-session-management.test.ts
│ │ ├── loaders
│ │ │ └── node-loader.test.ts
│ │ ├── mappers
│ │ │ └── docs-mapper.test.ts
│ │ ├── mcp
│ │ │ ├── get-node-essentials-examples.test.ts
│ │ │ ├── handlers-n8n-manager-simple.test.ts
│ │ │ ├── handlers-n8n-manager.test.ts
│ │ │ ├── handlers-workflow-diff.test.ts
│ │ │ ├── lru-cache-behavior.test.ts
│ │ │ ├── multi-tenant-tool-listing.test.ts.disabled
│ │ │ ├── parameter-validation.test.ts
│ │ │ ├── search-nodes-examples.test.ts
│ │ │ ├── tools-documentation.test.ts
│ │ │ └── tools.test.ts
│ │ ├── monitoring
│ │ │ └── cache-metrics.test.ts
│ │ ├── MULTI_TENANT_TEST_COVERAGE.md
│ │ ├── multi-tenant-integration.test.ts
│ │ ├── parsers
│ │ │ ├── node-parser-outputs.test.ts
│ │ │ ├── node-parser.test.ts
│ │ │ ├── property-extractor.test.ts
│ │ │ └── simple-parser.test.ts
│ │ ├── scripts
│ │ │ └── fetch-templates-extraction.test.ts
│ │ ├── services
│ │ │ ├── ai-node-validator.test.ts
│ │ │ ├── ai-tool-validators.test.ts
│ │ │ ├── confidence-scorer.test.ts
│ │ │ ├── config-validator-basic.test.ts
│ │ │ ├── config-validator-edge-cases.test.ts
│ │ │ ├── config-validator-node-specific.test.ts
│ │ │ ├── config-validator-security.test.ts
│ │ │ ├── debug-validator.test.ts
│ │ │ ├── enhanced-config-validator-integration.test.ts
│ │ │ ├── enhanced-config-validator-operations.test.ts
│ │ │ ├── enhanced-config-validator.test.ts
│ │ │ ├── example-generator.test.ts
│ │ │ ├── execution-processor.test.ts
│ │ │ ├── expression-format-validator.test.ts
│ │ │ ├── expression-validator-edge-cases.test.ts
│ │ │ ├── expression-validator.test.ts
│ │ │ ├── fixed-collection-validation.test.ts
│ │ │ ├── loop-output-edge-cases.test.ts
│ │ │ ├── n8n-api-client.test.ts
│ │ │ ├── n8n-validation.test.ts
│ │ │ ├── node-sanitizer.test.ts
│ │ │ ├── node-similarity-service.test.ts
│ │ │ ├── node-specific-validators.test.ts
│ │ │ ├── operation-similarity-service-comprehensive.test.ts
│ │ │ ├── operation-similarity-service.test.ts
│ │ │ ├── property-dependencies.test.ts
│ │ │ ├── property-filter-edge-cases.test.ts
│ │ │ ├── property-filter.test.ts
│ │ │ ├── resource-similarity-service-comprehensive.test.ts
│ │ │ ├── resource-similarity-service.test.ts
│ │ │ ├── task-templates.test.ts
│ │ │ ├── template-service.test.ts
│ │ │ ├── universal-expression-validator.test.ts
│ │ │ ├── validation-fixes.test.ts
│ │ │ ├── workflow-auto-fixer.test.ts
│ │ │ ├── workflow-diff-engine.test.ts
│ │ │ ├── workflow-fixed-collection-validation.test.ts
│ │ │ ├── workflow-validator-comprehensive.test.ts
│ │ │ ├── workflow-validator-edge-cases.test.ts
│ │ │ ├── workflow-validator-error-outputs.test.ts
│ │ │ ├── workflow-validator-expression-format.test.ts
│ │ │ ├── workflow-validator-loops-simple.test.ts
│ │ │ ├── workflow-validator-loops.test.ts
│ │ │ ├── workflow-validator-mocks.test.ts
│ │ │ ├── workflow-validator-performance.test.ts
│ │ │ ├── workflow-validator-with-mocks.test.ts
│ │ │ └── workflow-validator.test.ts
│ │ ├── telemetry
│ │ │ ├── batch-processor.test.ts
│ │ │ ├── config-manager.test.ts
│ │ │ ├── event-tracker.test.ts
│ │ │ ├── event-validator.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── telemetry-error.test.ts
│ │ │ ├── telemetry-manager.test.ts
│ │ │ ├── v2.18.3-fixes-verification.test.ts
│ │ │ └── workflow-sanitizer.test.ts
│ │ ├── templates
│ │ │ ├── batch-processor.test.ts
│ │ │ ├── metadata-generator.test.ts
│ │ │ ├── template-repository-metadata.test.ts
│ │ │ └── template-repository-security.test.ts
│ │ ├── test-env-example.test.ts
│ │ ├── test-infrastructure.test.ts
│ │ ├── types
│ │ │ ├── instance-context-coverage.test.ts
│ │ │ └── instance-context-multi-tenant.test.ts
│ │ ├── utils
│ │ │ ├── auth-timing-safe.test.ts
│ │ │ ├── cache-utils.test.ts
│ │ │ ├── console-manager.test.ts
│ │ │ ├── database-utils.test.ts
│ │ │ ├── fixed-collection-validator.test.ts
│ │ │ ├── n8n-errors.test.ts
│ │ │ ├── node-type-normalizer.test.ts
│ │ │ ├── node-type-utils.test.ts
│ │ │ ├── node-utils.test.ts
│ │ │ ├── simple-cache-memory-leak-fix.test.ts
│ │ │ ├── ssrf-protection.test.ts
│ │ │ └── template-node-resolver.test.ts
│ │ └── validation-fixes.test.ts
│ └── utils
│ ├── assertions.ts
│ ├── builders
│ │ └── workflow.builder.ts
│ ├── data-generators.ts
│ ├── database-utils.ts
│ ├── README.md
│ └── test-helpers.ts
├── thumbnail.png
├── tsconfig.build.json
├── tsconfig.json
├── types
│ ├── mcp.d.ts
│ └── test-env.d.ts
├── verify-telemetry-fix.js
├── versioned-nodes.md
├── vitest.config.benchmark.ts
├── vitest.config.integration.ts
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/mcp/tool-docs/guides/ai-agents-guide.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ToolDocumentation } from '../types';
2 |
3 | export const aiAgentsGuide: ToolDocumentation = {
4 | name: 'ai_agents_guide',
5 | category: 'guides',
6 | essentials: {
7 | description: 'Comprehensive guide to building AI Agent workflows in n8n. Covers architecture, connections, tools, validation, and best practices for production AI systems.',
8 | keyParameters: [],
9 | example: 'Use tools_documentation({topic: "ai_agents_guide"}) to access this guide',
10 | performance: 'N/A - Documentation only',
11 | tips: [
12 | 'Start with Chat Trigger → AI Agent → Language Model pattern',
13 | 'Always connect language model BEFORE enabling AI Agent',
14 | 'Use proper toolDescription for all AI tools (15+ characters)',
15 | 'Validate workflows with n8n_validate_workflow before deployment',
16 | 'Use includeExamples=true when searching for AI nodes',
17 | 'Check FINAL_AI_VALIDATION_SPEC.md for detailed requirements'
18 | ]
19 | },
20 | full: {
21 | description: `# Complete Guide to AI Agents in n8n
22 |
23 | This comprehensive guide covers everything you need to build production-ready AI Agent workflows in n8n.
24 |
25 | ## Table of Contents
26 | 1. [AI Agent Architecture](#architecture)
27 | 2. [Essential Connection Types](#connections)
28 | 3. [Building Your First AI Agent](#first-agent)
29 | 4. [AI Tools Deep Dive](#tools)
30 | 5. [Advanced Patterns](#advanced)
31 | 6. [Validation & Best Practices](#validation)
32 | 7. [Troubleshooting](#troubleshooting)
33 |
34 | ---
35 |
36 | ## 1. AI Agent Architecture {#architecture}
37 |
38 | ### Core Components
39 |
40 | An n8n AI Agent workflow typically consists of:
41 |
42 | 1. **Chat Trigger**: Entry point for user interactions
43 | - Webhook-based or manual trigger
44 | - Supports streaming responses (responseMode)
45 | - Passes user message to AI Agent
46 |
47 | 2. **AI Agent**: The orchestrator
48 | - Manages conversation flow
49 | - Decides when to use tools
50 | - Iterates until task is complete
51 | - Supports fallback models (v2.1+)
52 |
53 | 3. **Language Model**: The AI brain
54 | - OpenAI GPT-4, Claude, Gemini, etc.
55 | - Connected via ai_languageModel port
56 | - Can have primary + fallback for reliability
57 |
58 | 4. **Tools**: AI Agent's capabilities
59 | - HTTP Request, Code, Vector Store, etc.
60 | - Connected via ai_tool port
61 | - Each tool needs clear toolDescription
62 |
63 | 5. **Optional Components**:
64 | - Memory (conversation history)
65 | - Output Parser (structured responses)
66 | - Vector Store (knowledge retrieval)
67 |
68 | ### Connection Flow
69 |
70 | **CRITICAL**: AI connections flow TO the consumer (reversed from standard n8n):
71 |
72 | \`\`\`
73 | Standard n8n: [Source] --main--> [Target]
74 | AI pattern: [Language Model] --ai_languageModel--> [AI Agent]
75 | [HTTP Tool] --ai_tool--> [AI Agent]
76 | \`\`\`
77 |
78 | This is why you use \`sourceOutput: "ai_languageModel"\` when connecting components.
79 |
80 | ---
81 |
82 | ## 2. Essential Connection Types {#connections}
83 |
84 | ### The 8 AI Connection Types
85 |
86 | 1. **ai_languageModel**
87 | - FROM: OpenAI Chat Model, Anthropic, Google Gemini, etc.
88 | - TO: AI Agent, Basic LLM Chain
89 | - REQUIRED: Every AI Agent needs 1-2 language models
90 | - Example: \`{type: "addConnection", source: "OpenAI", target: "AI Agent", sourceOutput: "ai_languageModel"}\`
91 |
92 | 2. **ai_tool**
93 | - FROM: Any tool node (HTTP Request Tool, Code Tool, etc.)
94 | - TO: AI Agent
95 | - REQUIRED: At least 1 tool recommended
96 | - Example: \`{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}\`
97 |
98 | 3. **ai_memory**
99 | - FROM: Window Buffer Memory, Conversation Summary, etc.
100 | - TO: AI Agent
101 | - OPTIONAL: 0-1 memory system
102 | - Enables conversation history tracking
103 |
104 | 4. **ai_outputParser**
105 | - FROM: Structured Output Parser, JSON Parser, etc.
106 | - TO: AI Agent
107 | - OPTIONAL: For structured responses
108 | - Must set hasOutputParser=true on AI Agent
109 |
110 | 5. **ai_embedding**
111 | - FROM: Embeddings OpenAI, Embeddings Google, etc.
112 | - TO: Vector Store (Pinecone, In-Memory, etc.)
113 | - REQUIRED: For vector-based retrieval
114 |
115 | 6. **ai_vectorStore**
116 | - FROM: Vector Store node
117 | - TO: Vector Store Tool
118 | - REQUIRED: For retrieval-augmented generation (RAG)
119 |
120 | 7. **ai_document**
121 | - FROM: Document Loader, Default Data Loader
122 | - TO: Vector Store
123 | - REQUIRED: Provides data for vector storage
124 |
125 | 8. **ai_textSplitter**
126 | - FROM: Text Splitter nodes
127 | - TO: Document processing chains
128 | - OPTIONAL: Chunk large documents
129 |
130 | ### Connection Examples
131 |
132 | \`\`\`typescript
133 | // Basic AI Agent setup
134 | n8n_update_partial_workflow({
135 | id: "workflow_id",
136 | operations: [
137 | // Connect language model (REQUIRED)
138 | {
139 | type: "addConnection",
140 | source: "OpenAI Chat Model",
141 | target: "AI Agent",
142 | sourceOutput: "ai_languageModel"
143 | },
144 | // Connect tools
145 | {
146 | type: "addConnection",
147 | source: "HTTP Request Tool",
148 | target: "AI Agent",
149 | sourceOutput: "ai_tool"
150 | },
151 | {
152 | type: "addConnection",
153 | source: "Code Tool",
154 | target: "AI Agent",
155 | sourceOutput: "ai_tool"
156 | },
157 | // Add memory (optional)
158 | {
159 | type: "addConnection",
160 | source: "Window Buffer Memory",
161 | target: "AI Agent",
162 | sourceOutput: "ai_memory"
163 | }
164 | ]
165 | })
166 | \`\`\`
167 |
168 | ---
169 |
170 | ## 3. Building Your First AI Agent {#first-agent}
171 |
172 | ### Step-by-Step Tutorial
173 |
174 | #### Step 1: Create Chat Trigger
175 |
176 | Use \`n8n_create_workflow\` or manually create a workflow with:
177 |
178 | \`\`\`typescript
179 | {
180 | name: "My First AI Agent",
181 | nodes: [
182 | {
183 | id: "chat_trigger",
184 | name: "Chat Trigger",
185 | type: "@n8n/n8n-nodes-langchain.chatTrigger",
186 | position: [100, 100],
187 | parameters: {
188 | options: {
189 | responseMode: "lastNode" // or "streaming" for real-time
190 | }
191 | }
192 | }
193 | ],
194 | connections: {}
195 | }
196 | \`\`\`
197 |
198 | #### Step 2: Add Language Model
199 |
200 | \`\`\`typescript
201 | n8n_update_partial_workflow({
202 | id: "workflow_id",
203 | operations: [
204 | {
205 | type: "addNode",
206 | node: {
207 | name: "OpenAI Chat Model",
208 | type: "@n8n/n8n-nodes-langchain.lmChatOpenAi",
209 | position: [300, 50],
210 | parameters: {
211 | model: "gpt-4",
212 | temperature: 0.7
213 | }
214 | }
215 | }
216 | ]
217 | })
218 | \`\`\`
219 |
220 | #### Step 3: Add AI Agent
221 |
222 | \`\`\`typescript
223 | n8n_update_partial_workflow({
224 | id: "workflow_id",
225 | operations: [
226 | {
227 | type: "addNode",
228 | node: {
229 | name: "AI Agent",
230 | type: "@n8n/n8n-nodes-langchain.agent",
231 | position: [300, 150],
232 | parameters: {
233 | promptType: "auto",
234 | systemMessage: "You are a helpful assistant. Be concise and accurate."
235 | }
236 | }
237 | }
238 | ]
239 | })
240 | \`\`\`
241 |
242 | #### Step 4: Connect Components
243 |
244 | \`\`\`typescript
245 | n8n_update_partial_workflow({
246 | id: "workflow_id",
247 | operations: [
248 | // Chat Trigger → AI Agent (main connection)
249 | {
250 | type: "addConnection",
251 | source: "Chat Trigger",
252 | target: "AI Agent"
253 | },
254 | // Language Model → AI Agent (AI connection)
255 | {
256 | type: "addConnection",
257 | source: "OpenAI Chat Model",
258 | target: "AI Agent",
259 | sourceOutput: "ai_languageModel"
260 | }
261 | ]
262 | })
263 | \`\`\`
264 |
265 | #### Step 5: Validate
266 |
267 | \`\`\`typescript
268 | n8n_validate_workflow({id: "workflow_id"})
269 | \`\`\`
270 |
271 | ---
272 |
273 | ## 4. AI Tools Deep Dive {#tools}
274 |
275 | ### Tool Types and When to Use Them
276 |
277 | #### 1. HTTP Request Tool
278 | **Use when**: AI needs to call external APIs
279 |
280 | **Critical Requirements**:
281 | - \`toolDescription\`: Clear, 15+ character description
282 | - \`url\`: API endpoint (can include placeholders)
283 | - \`placeholderDefinitions\`: Define all {placeholders}
284 | - Proper authentication if needed
285 |
286 | **Example**:
287 | \`\`\`typescript
288 | {
289 | type: "addNode",
290 | node: {
291 | name: "GitHub Issues Tool",
292 | type: "@n8n/n8n-nodes-langchain.toolHttpRequest",
293 | position: [500, 100],
294 | parameters: {
295 | method: "POST",
296 | url: "https://api.github.com/repos/{owner}/{repo}/issues",
297 | toolDescription: "Create GitHub issues. Requires owner (username), repo (repository name), title, and body.",
298 | placeholderDefinitions: {
299 | values: [
300 | {name: "owner", description: "Repository owner username"},
301 | {name: "repo", description: "Repository name"},
302 | {name: "title", description: "Issue title"},
303 | {name: "body", description: "Issue description"}
304 | ]
305 | },
306 | sendBody: true,
307 | jsonBody: "={{ { title: $json.title, body: $json.body } }}"
308 | }
309 | }
310 | }
311 | \`\`\`
312 |
313 | #### 2. Code Tool
314 | **Use when**: AI needs to run custom logic
315 |
316 | **Critical Requirements**:
317 | - \`name\`: Function name (alphanumeric + underscore)
318 | - \`description\`: 10+ character explanation
319 | - \`code\`: JavaScript or Python code
320 | - \`inputSchema\`: Define expected inputs (recommended)
321 |
322 | **Example**:
323 | \`\`\`typescript
324 | {
325 | type: "addNode",
326 | node: {
327 | name: "Calculate Shipping",
328 | type: "@n8n/n8n-nodes-langchain.toolCode",
329 | position: [500, 200],
330 | parameters: {
331 | name: "calculate_shipping",
332 | description: "Calculate shipping cost based on weight (kg) and distance (km)",
333 | language: "javaScript",
334 | code: "const cost = 5 + ($input.weight * 2) + ($input.distance * 0.1); return { cost };",
335 | specifyInputSchema: true,
336 | inputSchema: "{ \\"type\\": \\"object\\", \\"properties\\": { \\"weight\\": { \\"type\\": \\"number\\" }, \\"distance\\": { \\"type\\": \\"number\\" } } }"
337 | }
338 | }
339 | }
340 | \`\`\`
341 |
342 | #### 3. Vector Store Tool
343 | **Use when**: AI needs to search knowledge base
344 |
345 | **Setup**: Requires Vector Store + Embeddings + Documents
346 |
347 | **Example**:
348 | \`\`\`typescript
349 | // Step 1: Create Vector Store with embeddings and documents
350 | n8n_update_partial_workflow({
351 | operations: [
352 | {type: "addConnection", source: "Embeddings OpenAI", target: "Pinecone", sourceOutput: "ai_embedding"},
353 | {type: "addConnection", source: "Document Loader", target: "Pinecone", sourceOutput: "ai_document"}
354 | ]
355 | })
356 |
357 | // Step 2: Connect Vector Store to Vector Store Tool
358 | n8n_update_partial_workflow({
359 | operations: [
360 | {type: "addConnection", source: "Pinecone", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"}
361 | ]
362 | })
363 |
364 | // Step 3: Connect tool to AI Agent
365 | n8n_update_partial_workflow({
366 | operations: [
367 | {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}
368 | ]
369 | })
370 | \`\`\`
371 |
372 | #### 4. AI Agent Tool (Sub-Agents)
373 | **Use when**: Need specialized expertise
374 |
375 | **Example**: Research specialist sub-agent
376 | \`\`\`typescript
377 | {
378 | type: "addNode",
379 | node: {
380 | name: "Research Specialist",
381 | type: "@n8n/n8n-nodes-langchain.agentTool",
382 | position: [500, 300],
383 | parameters: {
384 | name: "research_specialist",
385 | description: "Expert researcher that searches multiple sources and synthesizes information. Use for detailed research tasks.",
386 | systemMessage: "You are a research specialist. Search thoroughly, cite sources, and provide comprehensive analysis."
387 | }
388 | }
389 | }
390 | \`\`\`
391 |
392 | #### 5. MCP Client Tool
393 | **Use when**: Need to use Model Context Protocol servers
394 |
395 | **Example**: Filesystem access
396 | \`\`\`typescript
397 | {
398 | type: "addNode",
399 | node: {
400 | name: "Filesystem Tool",
401 | type: "@n8n/n8n-nodes-langchain.mcpClientTool",
402 | position: [500, 400],
403 | parameters: {
404 | description: "Access file system to read files, list directories, and search content",
405 | mcpServer: {
406 | transport: "stdio",
407 | command: "npx",
408 | args: ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"]
409 | },
410 | tool: "read_file"
411 | }
412 | }
413 | }
414 | \`\`\`
415 |
416 | ---
417 |
418 | ## 5. Advanced Patterns {#advanced}
419 |
420 | ### Pattern 1: Streaming Responses
421 |
422 | For real-time user experience:
423 |
424 | \`\`\`typescript
425 | // Set Chat Trigger to streaming mode
426 | {
427 | parameters: {
428 | options: {
429 | responseMode: "streaming"
430 | }
431 | }
432 | }
433 |
434 | // CRITICAL: AI Agent must NOT have main output connections in streaming mode
435 | // Responses stream back through Chat Trigger automatically
436 | \`\`\`
437 |
438 | **Validation will fail if**:
439 | - Chat Trigger has streaming but target is not AI Agent
440 | - AI Agent in streaming mode has main output connections
441 |
442 | ### Pattern 2: Fallback Language Models
443 |
444 | For production reliability (requires AI Agent v2.1+):
445 |
446 | \`\`\`typescript
447 | n8n_update_partial_workflow({
448 | operations: [
449 | // Primary model
450 | {
451 | type: "addConnection",
452 | source: "OpenAI GPT-4",
453 | target: "AI Agent",
454 | sourceOutput: "ai_languageModel",
455 | targetIndex: 0
456 | },
457 | // Fallback model
458 | {
459 | type: "addConnection",
460 | source: "Anthropic Claude",
461 | target: "AI Agent",
462 | sourceOutput: "ai_languageModel",
463 | targetIndex: 1
464 | }
465 | ]
466 | })
467 |
468 | // Enable fallback on AI Agent
469 | {
470 | type: "updateNode",
471 | nodeName: "AI Agent",
472 | updates: {
473 | "parameters.needsFallback": true
474 | }
475 | }
476 | \`\`\`
477 |
478 | ### Pattern 3: RAG (Retrieval-Augmented Generation)
479 |
480 | Complete knowledge base setup:
481 |
482 | \`\`\`typescript
483 | // 1. Load documents
484 | {type: "addConnection", source: "PDF Loader", target: "Text Splitter", sourceOutput: "ai_document"}
485 |
486 | // 2. Split and embed
487 | {type: "addConnection", source: "Text Splitter", target: "Vector Store"}
488 | {type: "addConnection", source: "Embeddings", target: "Vector Store", sourceOutput: "ai_embedding"}
489 |
490 | // 3. Create search tool
491 | {type: "addConnection", source: "Vector Store", target: "Vector Store Tool", sourceOutput: "ai_vectorStore"}
492 |
493 | // 4. Give tool to agent
494 | {type: "addConnection", source: "Vector Store Tool", target: "AI Agent", sourceOutput: "ai_tool"}
495 | \`\`\`
496 |
497 | ### Pattern 4: Multi-Agent Systems
498 |
499 | Specialized sub-agents for complex tasks:
500 |
501 | \`\`\`typescript
502 | // Create sub-agents with specific expertise
503 | [
504 | {name: "research_agent", description: "Deep research specialist"},
505 | {name: "data_analyst", description: "Data analysis expert"},
506 | {name: "writer_agent", description: "Content writing specialist"}
507 | ].forEach(agent => {
508 | // Add as AI Agent Tool to main coordinator agent
509 | {
510 | type: "addConnection",
511 | source: agent.name,
512 | target: "Coordinator Agent",
513 | sourceOutput: "ai_tool"
514 | }
515 | })
516 | \`\`\`
517 |
518 | ---
519 |
520 | ## 6. Validation & Best Practices {#validation}
521 |
522 | ### Always Validate Before Deployment
523 |
524 | \`\`\`typescript
525 | const result = n8n_validate_workflow({id: "workflow_id"})
526 |
527 | if (!result.valid) {
528 | console.log("Errors:", result.errors)
529 | console.log("Warnings:", result.warnings)
530 | console.log("Suggestions:", result.suggestions)
531 | }
532 | \`\`\`
533 |
534 | ### Common Validation Errors
535 |
536 | 1. **MISSING_LANGUAGE_MODEL**
537 | - Problem: AI Agent has no ai_languageModel connection
538 | - Fix: Connect a language model before creating AI Agent
539 |
540 | 2. **MISSING_TOOL_DESCRIPTION**
541 | - Problem: HTTP Request Tool has no toolDescription
542 | - Fix: Add clear description (15+ characters)
543 |
544 | 3. **STREAMING_WITH_MAIN_OUTPUT**
545 | - Problem: AI Agent in streaming mode has outgoing main connections
546 | - Fix: Remove main connections when using streaming
547 |
548 | 4. **FALLBACK_MISSING_SECOND_MODEL**
549 | - Problem: needsFallback=true but only 1 language model
550 | - Fix: Add second language model or disable needsFallback
551 |
552 | ### Best Practices Checklist
553 |
554 | ✅ **Before Creating AI Agent**:
555 | - [ ] Language model is connected first
556 | - [ ] At least one tool is prepared (or will be added)
557 | - [ ] System message is thoughtful and specific
558 |
559 | ✅ **For Each Tool**:
560 | - [ ] Has toolDescription/description (15+ characters)
561 | - [ ] toolDescription explains WHEN to use the tool
562 | - [ ] All required parameters are configured
563 | - [ ] Credentials are set up if needed
564 |
565 | ✅ **For Production**:
566 | - [ ] Workflow validated with n8n_validate_workflow
567 | - [ ] Tested with real user queries
568 | - [ ] Fallback model configured for reliability
569 | - [ ] Error handling in place
570 | - [ ] maxIterations set appropriately (default 10, max 50)
571 |
572 | ---
573 |
574 | ## 7. Troubleshooting {#troubleshooting}
575 |
576 | ### Problem: "AI Agent has no language model"
577 |
578 | **Cause**: Connection created AFTER AI Agent or using wrong sourceOutput
579 |
580 | **Solution**:
581 | \`\`\`typescript
582 | n8n_update_partial_workflow({
583 | operations: [
584 | {
585 | type: "addConnection",
586 | source: "OpenAI Chat Model",
587 | target: "AI Agent",
588 | sourceOutput: "ai_languageModel" // ← CRITICAL
589 | }
590 | ]
591 | })
592 | \`\`\`
593 |
594 | ### Problem: "Tool has no description"
595 |
596 | **Cause**: HTTP Request Tool or Code Tool missing toolDescription/description
597 |
598 | **Solution**:
599 | \`\`\`typescript
600 | {
601 | type: "updateNode",
602 | nodeName: "HTTP Request Tool",
603 | updates: {
604 | "parameters.toolDescription": "Call weather API to get current conditions for a city"
605 | }
606 | }
607 | \`\`\`
608 |
609 | ### Problem: "Streaming mode not working"
610 |
611 | **Causes**:
612 | 1. Chat Trigger not set to streaming
613 | 2. AI Agent has main output connections
614 | 3. Target of Chat Trigger is not AI Agent
615 |
616 | **Solution**:
617 | \`\`\`typescript
618 | // 1. Set Chat Trigger to streaming
619 | {
620 | type: "updateNode",
621 | nodeName: "Chat Trigger",
622 | updates: {
623 | "parameters.options.responseMode": "streaming"
624 | }
625 | }
626 |
627 | // 2. Remove AI Agent main outputs
628 | {
629 | type: "removeConnection",
630 | source: "AI Agent",
631 | target: "Any Output Node"
632 | }
633 | \`\`\`
634 |
635 | ### Problem: "Agent keeps looping"
636 |
637 | **Cause**: Tool not returning proper response or agent stuck in reasoning loop
638 |
639 | **Solutions**:
640 | 1. Set maxIterations lower: \`"parameters.maxIterations": 5\`
641 | 2. Improve tool descriptions to be more specific
642 | 3. Add system message guidance: "Use tools efficiently, don't repeat actions"
643 |
644 | ---
645 |
646 | ## Quick Reference
647 |
648 | ### Essential Tools
649 |
650 | | Tool | Purpose | Key Parameters |
651 | |------|---------|----------------|
652 | | HTTP Request Tool | API calls | toolDescription, url, placeholders |
653 | | Code Tool | Custom logic | name, description, code, inputSchema |
654 | | Vector Store Tool | Knowledge search | description, topK |
655 | | AI Agent Tool | Sub-agents | name, description, systemMessage |
656 | | MCP Client Tool | MCP protocol | description, mcpServer, tool |
657 |
658 | ### Connection Quick Codes
659 |
660 | \`\`\`typescript
661 | // Language Model → AI Agent
662 | sourceOutput: "ai_languageModel"
663 |
664 | // Tool → AI Agent
665 | sourceOutput: "ai_tool"
666 |
667 | // Memory → AI Agent
668 | sourceOutput: "ai_memory"
669 |
670 | // Parser → AI Agent
671 | sourceOutput: "ai_outputParser"
672 |
673 | // Embeddings → Vector Store
674 | sourceOutput: "ai_embedding"
675 |
676 | // Vector Store → Vector Store Tool
677 | sourceOutput: "ai_vectorStore"
678 | \`\`\`
679 |
680 | ### Validation Command
681 |
682 | \`\`\`typescript
683 | n8n_validate_workflow({id: "workflow_id"})
684 | \`\`\`
685 |
686 | ---
687 |
688 | ## Related Resources
689 |
690 | - **FINAL_AI_VALIDATION_SPEC.md**: Complete validation rules
691 | - **n8n_update_partial_workflow**: Workflow modification tool
692 | - **search_nodes({query: "AI", includeExamples: true})**: Find AI nodes with examples
693 | - **get_node_essentials({nodeType: "...", includeExamples: true})**: Node details with examples
694 |
695 | ---
696 |
697 | *This guide is part of the n8n-mcp documentation system. For questions or issues, refer to the validation spec or use tools_documentation() for specific topics.*`,
698 | parameters: {},
699 | returns: 'Complete AI Agents guide with architecture, patterns, validation, and troubleshooting',
700 | examples: [
701 | 'tools_documentation({topic: "ai_agents_guide"}) - Full guide',
702 | 'tools_documentation({topic: "ai_agents_guide", depth: "essentials"}) - Quick reference',
703 | 'When user asks about AI Agents, Chat Trigger, or building AI workflows → Point to this guide'
704 | ],
705 | useCases: [
706 | 'Learning AI Agent architecture in n8n',
707 | 'Understanding AI connection types and patterns',
708 | 'Building first AI Agent workflow step-by-step',
709 | 'Implementing advanced patterns (streaming, fallback, RAG, multi-agent)',
710 | 'Troubleshooting AI workflow issues',
711 | 'Validating AI workflows before deployment',
712 | 'Quick reference for connection types and tools'
713 | ],
714 | performance: 'N/A - Static documentation',
715 | bestPractices: [
716 | 'Reference this guide when users ask about AI Agents',
717 | 'Point to specific sections based on user needs',
718 | 'Combine with search_nodes(includeExamples=true) for working examples',
719 | 'Validate workflows after following guide instructions',
720 | 'Use FINAL_AI_VALIDATION_SPEC.md for detailed requirements'
721 | ],
722 | pitfalls: [
723 | 'This is a guide, not an executable tool',
724 | 'Always validate workflows after making changes',
725 | 'AI connections require sourceOutput parameter',
726 | 'Streaming mode has specific constraints',
727 | 'Some features require specific AI Agent versions (v2.1+ for fallback)'
728 | ],
729 | relatedTools: [
730 | 'n8n_create_workflow',
731 | 'n8n_update_partial_workflow',
732 | 'n8n_validate_workflow',
733 | 'search_nodes',
734 | 'get_node_essentials',
735 | 'list_ai_tools'
736 | ]
737 | }
738 | };
739 |
```
--------------------------------------------------------------------------------
/tests/unit/database/node-repository-operations.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
2 | import { NodeRepository } from '@/database/node-repository';
3 | import { DatabaseAdapter, PreparedStatement, RunResult } from '@/database/database-adapter';
4 |
5 | // Mock DatabaseAdapter for testing the new operation methods
6 | class MockDatabaseAdapter implements DatabaseAdapter {
7 | private statements = new Map<string, MockPreparedStatement>();
8 | private mockNodes = new Map<string, any>();
9 |
10 | prepare = vi.fn((sql: string) => {
11 | if (!this.statements.has(sql)) {
12 | this.statements.set(sql, new MockPreparedStatement(sql, this.mockNodes));
13 | }
14 | return this.statements.get(sql)!;
15 | });
16 |
17 | exec = vi.fn();
18 | close = vi.fn();
19 | pragma = vi.fn();
20 | transaction = vi.fn((fn: () => any) => fn());
21 | checkFTS5Support = vi.fn(() => true);
22 | inTransaction = false;
23 |
24 | // Test helper to set mock data
25 | _setMockNode(nodeType: string, value: any) {
26 | this.mockNodes.set(nodeType, value);
27 | }
28 | }
29 |
30 | class MockPreparedStatement implements PreparedStatement {
31 | run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 }));
32 | get = vi.fn();
33 | all = vi.fn(() => []);
34 | iterate = vi.fn();
35 | pluck = vi.fn(() => this);
36 | expand = vi.fn(() => this);
37 | raw = vi.fn(() => this);
38 | columns = vi.fn(() => []);
39 | bind = vi.fn(() => this);
40 |
41 | constructor(private sql: string, private mockNodes: Map<string, any>) {
42 | // Configure get() to return node data
43 | if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
44 | this.get = vi.fn((nodeType: string) => this.mockNodes.get(nodeType));
45 | }
46 |
47 | // Configure all() for getAllNodes
48 | if (sql.includes('SELECT * FROM nodes ORDER BY display_name')) {
49 | this.all = vi.fn(() => Array.from(this.mockNodes.values()));
50 | }
51 | }
52 | }
53 |
54 | describe('NodeRepository - Operations and Resources', () => {
55 | let repository: NodeRepository;
56 | let mockAdapter: MockDatabaseAdapter;
57 |
58 | beforeEach(() => {
59 | mockAdapter = new MockDatabaseAdapter();
60 | repository = new NodeRepository(mockAdapter);
61 | });
62 |
63 | describe('getNodeOperations', () => {
64 | it('should extract operations from array format', () => {
65 | const mockNode = {
66 | node_type: 'nodes-base.httpRequest',
67 | display_name: 'HTTP Request',
68 | operations: JSON.stringify([
69 | { name: 'get', displayName: 'GET' },
70 | { name: 'post', displayName: 'POST' }
71 | ]),
72 | properties_schema: '[]',
73 | credentials_required: '[]'
74 | };
75 |
76 | mockAdapter._setMockNode('nodes-base.httpRequest', mockNode);
77 |
78 | const operations = repository.getNodeOperations('nodes-base.httpRequest');
79 |
80 | expect(operations).toEqual([
81 | { name: 'get', displayName: 'GET' },
82 | { name: 'post', displayName: 'POST' }
83 | ]);
84 | });
85 |
86 | it('should extract operations from object format grouped by resource', () => {
87 | const mockNode = {
88 | node_type: 'nodes-base.slack',
89 | display_name: 'Slack',
90 | operations: JSON.stringify({
91 | message: [
92 | { name: 'send', displayName: 'Send Message' },
93 | { name: 'update', displayName: 'Update Message' }
94 | ],
95 | channel: [
96 | { name: 'create', displayName: 'Create Channel' },
97 | { name: 'archive', displayName: 'Archive Channel' }
98 | ]
99 | }),
100 | properties_schema: '[]',
101 | credentials_required: '[]'
102 | };
103 |
104 | mockAdapter._setMockNode('nodes-base.slack', mockNode);
105 |
106 | const allOperations = repository.getNodeOperations('nodes-base.slack');
107 | const messageOperations = repository.getNodeOperations('nodes-base.slack', 'message');
108 |
109 | expect(allOperations).toHaveLength(4);
110 | expect(messageOperations).toEqual([
111 | { name: 'send', displayName: 'Send Message' },
112 | { name: 'update', displayName: 'Update Message' }
113 | ]);
114 | });
115 |
116 | it('should extract operations from properties with operation field', () => {
117 | const mockNode = {
118 | node_type: 'nodes-base.googleSheets',
119 | display_name: 'Google Sheets',
120 | operations: '[]',
121 | properties_schema: JSON.stringify([
122 | {
123 | name: 'resource',
124 | type: 'options',
125 | options: [{ name: 'sheet', displayName: 'Sheet' }]
126 | },
127 | {
128 | name: 'operation',
129 | type: 'options',
130 | displayOptions: {
131 | show: {
132 | resource: ['sheet']
133 | }
134 | },
135 | options: [
136 | { name: 'append', displayName: 'Append Row' },
137 | { name: 'read', displayName: 'Read Rows' }
138 | ]
139 | }
140 | ]),
141 | credentials_required: '[]'
142 | };
143 |
144 | mockAdapter._setMockNode('nodes-base.googleSheets', mockNode);
145 |
146 | const operations = repository.getNodeOperations('nodes-base.googleSheets');
147 |
148 | expect(operations).toEqual([
149 | { name: 'append', displayName: 'Append Row' },
150 | { name: 'read', displayName: 'Read Rows' }
151 | ]);
152 | });
153 |
154 | it('should filter operations by resource when specified', () => {
155 | const mockNode = {
156 | node_type: 'nodes-base.googleSheets',
157 | display_name: 'Google Sheets',
158 | operations: '[]',
159 | properties_schema: JSON.stringify([
160 | {
161 | name: 'operation',
162 | type: 'options',
163 | displayOptions: {
164 | show: {
165 | resource: ['sheet']
166 | }
167 | },
168 | options: [
169 | { name: 'append', displayName: 'Append Row' }
170 | ]
171 | },
172 | {
173 | name: 'operation',
174 | type: 'options',
175 | displayOptions: {
176 | show: {
177 | resource: ['cell']
178 | }
179 | },
180 | options: [
181 | { name: 'update', displayName: 'Update Cell' }
182 | ]
183 | }
184 | ]),
185 | credentials_required: '[]'
186 | };
187 |
188 | mockAdapter._setMockNode('nodes-base.googleSheets', mockNode);
189 |
190 | const sheetOperations = repository.getNodeOperations('nodes-base.googleSheets', 'sheet');
191 | const cellOperations = repository.getNodeOperations('nodes-base.googleSheets', 'cell');
192 |
193 | expect(sheetOperations).toEqual([{ name: 'append', displayName: 'Append Row' }]);
194 | expect(cellOperations).toEqual([{ name: 'update', displayName: 'Update Cell' }]);
195 | });
196 |
197 | it('should return empty array for non-existent node', () => {
198 | const operations = repository.getNodeOperations('nodes-base.nonexistent');
199 | expect(operations).toEqual([]);
200 | });
201 |
202 | it('should handle nodes without operations', () => {
203 | const mockNode = {
204 | node_type: 'nodes-base.simple',
205 | display_name: 'Simple Node',
206 | operations: '[]',
207 | properties_schema: '[]',
208 | credentials_required: '[]'
209 | };
210 |
211 | mockAdapter._setMockNode('nodes-base.simple', mockNode);
212 |
213 | const operations = repository.getNodeOperations('nodes-base.simple');
214 | expect(operations).toEqual([]);
215 | });
216 |
217 | it('should handle malformed operations JSON gracefully', () => {
218 | const mockNode = {
219 | node_type: 'nodes-base.broken',
220 | display_name: 'Broken Node',
221 | operations: '{invalid json}',
222 | properties_schema: '[]',
223 | credentials_required: '[]'
224 | };
225 |
226 | mockAdapter._setMockNode('nodes-base.broken', mockNode);
227 |
228 | const operations = repository.getNodeOperations('nodes-base.broken');
229 | expect(operations).toEqual([]);
230 | });
231 | });
232 |
233 | describe('getNodeResources', () => {
234 | it('should extract resources from properties', () => {
235 | const mockNode = {
236 | node_type: 'nodes-base.slack',
237 | display_name: 'Slack',
238 | operations: '[]',
239 | properties_schema: JSON.stringify([
240 | {
241 | name: 'resource',
242 | type: 'options',
243 | options: [
244 | { name: 'message', displayName: 'Message' },
245 | { name: 'channel', displayName: 'Channel' },
246 | { name: 'user', displayName: 'User' }
247 | ]
248 | }
249 | ]),
250 | credentials_required: '[]'
251 | };
252 |
253 | mockAdapter._setMockNode('nodes-base.slack', mockNode);
254 |
255 | const resources = repository.getNodeResources('nodes-base.slack');
256 |
257 | expect(resources).toEqual([
258 | { name: 'message', displayName: 'Message' },
259 | { name: 'channel', displayName: 'Channel' },
260 | { name: 'user', displayName: 'User' }
261 | ]);
262 | });
263 |
264 | it('should return empty array for node without resources', () => {
265 | const mockNode = {
266 | node_type: 'nodes-base.simple',
267 | display_name: 'Simple Node',
268 | operations: '[]',
269 | properties_schema: JSON.stringify([
270 | { name: 'url', type: 'string' }
271 | ]),
272 | credentials_required: '[]'
273 | };
274 |
275 | mockAdapter._setMockNode('nodes-base.simple', mockNode);
276 |
277 | const resources = repository.getNodeResources('nodes-base.simple');
278 | expect(resources).toEqual([]);
279 | });
280 |
281 | it('should return empty array for non-existent node', () => {
282 | const resources = repository.getNodeResources('nodes-base.nonexistent');
283 | expect(resources).toEqual([]);
284 | });
285 |
286 | it('should handle multiple resource properties', () => {
287 | const mockNode = {
288 | node_type: 'nodes-base.multi',
289 | display_name: 'Multi Resource Node',
290 | operations: '[]',
291 | properties_schema: JSON.stringify([
292 | {
293 | name: 'resource',
294 | type: 'options',
295 | options: [{ name: 'type1', displayName: 'Type 1' }]
296 | },
297 | {
298 | name: 'resource',
299 | type: 'options',
300 | options: [{ name: 'type2', displayName: 'Type 2' }]
301 | }
302 | ]),
303 | credentials_required: '[]'
304 | };
305 |
306 | mockAdapter._setMockNode('nodes-base.multi', mockNode);
307 |
308 | const resources = repository.getNodeResources('nodes-base.multi');
309 |
310 | expect(resources).toEqual([
311 | { name: 'type1', displayName: 'Type 1' },
312 | { name: 'type2', displayName: 'Type 2' }
313 | ]);
314 | });
315 | });
316 |
317 | describe('getOperationsForResource', () => {
318 | it('should return operations for specific resource', () => {
319 | const mockNode = {
320 | node_type: 'nodes-base.slack',
321 | display_name: 'Slack',
322 | operations: '[]',
323 | properties_schema: JSON.stringify([
324 | {
325 | name: 'operation',
326 | type: 'options',
327 | displayOptions: {
328 | show: {
329 | resource: ['message']
330 | }
331 | },
332 | options: [
333 | { name: 'send', displayName: 'Send Message' },
334 | { name: 'update', displayName: 'Update Message' }
335 | ]
336 | },
337 | {
338 | name: 'operation',
339 | type: 'options',
340 | displayOptions: {
341 | show: {
342 | resource: ['channel']
343 | }
344 | },
345 | options: [
346 | { name: 'create', displayName: 'Create Channel' }
347 | ]
348 | }
349 | ]),
350 | credentials_required: '[]'
351 | };
352 |
353 | mockAdapter._setMockNode('nodes-base.slack', mockNode);
354 |
355 | const messageOps = repository.getOperationsForResource('nodes-base.slack', 'message');
356 | const channelOps = repository.getOperationsForResource('nodes-base.slack', 'channel');
357 | const nonExistentOps = repository.getOperationsForResource('nodes-base.slack', 'nonexistent');
358 |
359 | expect(messageOps).toEqual([
360 | { name: 'send', displayName: 'Send Message' },
361 | { name: 'update', displayName: 'Update Message' }
362 | ]);
363 | expect(channelOps).toEqual([
364 | { name: 'create', displayName: 'Create Channel' }
365 | ]);
366 | expect(nonExistentOps).toEqual([]);
367 | });
368 |
369 | it('should handle array format for resource display options', () => {
370 | const mockNode = {
371 | node_type: 'nodes-base.multi',
372 | display_name: 'Multi Node',
373 | operations: '[]',
374 | properties_schema: JSON.stringify([
375 | {
376 | name: 'operation',
377 | type: 'options',
378 | displayOptions: {
379 | show: {
380 | resource: ['message', 'channel'] // Array format
381 | }
382 | },
383 | options: [
384 | { name: 'list', displayName: 'List Items' }
385 | ]
386 | }
387 | ]),
388 | credentials_required: '[]'
389 | };
390 |
391 | mockAdapter._setMockNode('nodes-base.multi', mockNode);
392 |
393 | const messageOps = repository.getOperationsForResource('nodes-base.multi', 'message');
394 | const channelOps = repository.getOperationsForResource('nodes-base.multi', 'channel');
395 | const otherOps = repository.getOperationsForResource('nodes-base.multi', 'other');
396 |
397 | expect(messageOps).toEqual([{ name: 'list', displayName: 'List Items' }]);
398 | expect(channelOps).toEqual([{ name: 'list', displayName: 'List Items' }]);
399 | expect(otherOps).toEqual([]);
400 | });
401 |
402 | it('should return empty array for non-existent node', () => {
403 | const operations = repository.getOperationsForResource('nodes-base.nonexistent', 'message');
404 | expect(operations).toEqual([]);
405 | });
406 |
407 | it('should handle string format for single resource', () => {
408 | const mockNode = {
409 | node_type: 'nodes-base.single',
410 | display_name: 'Single Node',
411 | operations: '[]',
412 | properties_schema: JSON.stringify([
413 | {
414 | name: 'operation',
415 | type: 'options',
416 | displayOptions: {
417 | show: {
418 | resource: 'document' // String format
419 | }
420 | },
421 | options: [
422 | { name: 'create', displayName: 'Create Document' }
423 | ]
424 | }
425 | ]),
426 | credentials_required: '[]'
427 | };
428 |
429 | mockAdapter._setMockNode('nodes-base.single', mockNode);
430 |
431 | const operations = repository.getOperationsForResource('nodes-base.single', 'document');
432 | expect(operations).toEqual([{ name: 'create', displayName: 'Create Document' }]);
433 | });
434 | });
435 |
436 | describe('getAllOperations', () => {
437 | it('should collect operations from all nodes', () => {
438 | const mockNodes = [
439 | {
440 | node_type: 'nodes-base.httpRequest',
441 | display_name: 'HTTP Request',
442 | operations: JSON.stringify([{ name: 'execute' }]),
443 | properties_schema: '[]',
444 | credentials_required: '[]'
445 | },
446 | {
447 | node_type: 'nodes-base.slack',
448 | display_name: 'Slack',
449 | operations: JSON.stringify([{ name: 'send' }]),
450 | properties_schema: '[]',
451 | credentials_required: '[]'
452 | },
453 | {
454 | node_type: 'nodes-base.empty',
455 | display_name: 'Empty Node',
456 | operations: '[]',
457 | properties_schema: '[]',
458 | credentials_required: '[]'
459 | }
460 | ];
461 |
462 | mockNodes.forEach(node => {
463 | mockAdapter._setMockNode(node.node_type, node);
464 | });
465 |
466 | const allOperations = repository.getAllOperations();
467 |
468 | expect(allOperations.size).toBe(2); // Only nodes with operations
469 | expect(allOperations.get('nodes-base.httpRequest')).toEqual([{ name: 'execute' }]);
470 | expect(allOperations.get('nodes-base.slack')).toEqual([{ name: 'send' }]);
471 | expect(allOperations.has('nodes-base.empty')).toBe(false);
472 | });
473 |
474 | it('should handle empty node list', () => {
475 | const allOperations = repository.getAllOperations();
476 | expect(allOperations.size).toBe(0);
477 | });
478 | });
479 |
480 | describe('getAllResources', () => {
481 | it('should collect resources from all nodes', () => {
482 | const mockNodes = [
483 | {
484 | node_type: 'nodes-base.slack',
485 | display_name: 'Slack',
486 | operations: '[]',
487 | properties_schema: JSON.stringify([
488 | {
489 | name: 'resource',
490 | options: [{ name: 'message' }, { name: 'channel' }]
491 | }
492 | ]),
493 | credentials_required: '[]'
494 | },
495 | {
496 | node_type: 'nodes-base.sheets',
497 | display_name: 'Google Sheets',
498 | operations: '[]',
499 | properties_schema: JSON.stringify([
500 | {
501 | name: 'resource',
502 | options: [{ name: 'sheet' }]
503 | }
504 | ]),
505 | credentials_required: '[]'
506 | },
507 | {
508 | node_type: 'nodes-base.simple',
509 | display_name: 'Simple Node',
510 | operations: '[]',
511 | properties_schema: '[]', // No resources
512 | credentials_required: '[]'
513 | }
514 | ];
515 |
516 | mockNodes.forEach(node => {
517 | mockAdapter._setMockNode(node.node_type, node);
518 | });
519 |
520 | const allResources = repository.getAllResources();
521 |
522 | expect(allResources.size).toBe(2); // Only nodes with resources
523 | expect(allResources.get('nodes-base.slack')).toEqual([
524 | { name: 'message' },
525 | { name: 'channel' }
526 | ]);
527 | expect(allResources.get('nodes-base.sheets')).toEqual([{ name: 'sheet' }]);
528 | expect(allResources.has('nodes-base.simple')).toBe(false);
529 | });
530 |
531 | it('should handle empty node list', () => {
532 | const allResources = repository.getAllResources();
533 | expect(allResources.size).toBe(0);
534 | });
535 | });
536 |
537 | describe('edge cases and error handling', () => {
538 | it('should handle null or undefined properties gracefully', () => {
539 | const mockNode = {
540 | node_type: 'nodes-base.null',
541 | display_name: 'Null Node',
542 | operations: null,
543 | properties_schema: null,
544 | credentials_required: null
545 | };
546 |
547 | mockAdapter._setMockNode('nodes-base.null', mockNode);
548 |
549 | const operations = repository.getNodeOperations('nodes-base.null');
550 | const resources = repository.getNodeResources('nodes-base.null');
551 |
552 | expect(operations).toEqual([]);
553 | expect(resources).toEqual([]);
554 | });
555 |
556 | it('should handle complex nested operation properties', () => {
557 | const mockNode = {
558 | node_type: 'nodes-base.complex',
559 | display_name: 'Complex Node',
560 | operations: '[]',
561 | properties_schema: JSON.stringify([
562 | {
563 | name: 'operation',
564 | type: 'options',
565 | displayOptions: {
566 | show: {
567 | resource: ['message'],
568 | mode: ['advanced']
569 | }
570 | },
571 | options: [
572 | { name: 'complexOperation', displayName: 'Complex Operation' }
573 | ]
574 | }
575 | ]),
576 | credentials_required: '[]'
577 | };
578 |
579 | mockAdapter._setMockNode('nodes-base.complex', mockNode);
580 |
581 | const operations = repository.getNodeOperations('nodes-base.complex');
582 | expect(operations).toEqual([{ name: 'complexOperation', displayName: 'Complex Operation' }]);
583 | });
584 |
585 | it('should handle operations with mixed data types', () => {
586 | const mockNode = {
587 | node_type: 'nodes-base.mixed',
588 | display_name: 'Mixed Node',
589 | operations: JSON.stringify({
590 | string_operation: 'invalid', // Should be array
591 | valid_operations: [{ name: 'valid' }],
592 | nested_object: { inner: [{ name: 'nested' }] }
593 | }),
594 | properties_schema: '[]',
595 | credentials_required: '[]'
596 | };
597 |
598 | mockAdapter._setMockNode('nodes-base.mixed', mockNode);
599 |
600 | const operations = repository.getNodeOperations('nodes-base.mixed');
601 | expect(operations).toEqual([{ name: 'valid' }]); // Only valid array operations
602 | });
603 |
604 | it('should handle very deeply nested properties', () => {
605 | const deepProperties = [
606 | {
607 | name: 'resource',
608 | options: [{ name: 'deep', displayName: 'Deep Resource' }],
609 | nested: {
610 | level1: {
611 | level2: {
612 | operations: [{ name: 'deep_operation' }]
613 | }
614 | }
615 | }
616 | }
617 | ];
618 |
619 | const mockNode = {
620 | node_type: 'nodes-base.deep',
621 | display_name: 'Deep Node',
622 | operations: '[]',
623 | properties_schema: JSON.stringify(deepProperties),
624 | credentials_required: '[]'
625 | };
626 |
627 | mockAdapter._setMockNode('nodes-base.deep', mockNode);
628 |
629 | const resources = repository.getNodeResources('nodes-base.deep');
630 | expect(resources).toEqual([{ name: 'deep', displayName: 'Deep Resource' }]);
631 | });
632 | });
633 | });
```
--------------------------------------------------------------------------------
/tests/unit/templates/template-repository-security.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi } from 'vitest';
2 | import { TemplateRepository } from '../../../src/templates/template-repository';
3 | import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
4 |
5 | // Mock logger
6 | vi.mock('../../../src/utils/logger', () => ({
7 | logger: {
8 | info: vi.fn(),
9 | warn: vi.fn(),
10 | error: vi.fn(),
11 | debug: vi.fn()
12 | }
13 | }));
14 |
15 | // Mock template sanitizer
16 | vi.mock('../../../src/utils/template-sanitizer', () => {
17 | class MockTemplateSanitizer {
18 | sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
19 | detectTokens = vi.fn(() => []);
20 | }
21 |
22 | return {
23 | TemplateSanitizer: MockTemplateSanitizer
24 | };
25 | });
26 |
27 | // Create mock database adapter
28 | class MockDatabaseAdapter implements DatabaseAdapter {
29 | private statements = new Map<string, MockPreparedStatement>();
30 | private execCalls: string[] = [];
31 | private _fts5Support = true;
32 |
33 | prepare = vi.fn((sql: string) => {
34 | if (!this.statements.has(sql)) {
35 | this.statements.set(sql, new MockPreparedStatement(sql));
36 | }
37 | return this.statements.get(sql)!;
38 | });
39 |
40 | exec = vi.fn((sql: string) => {
41 | this.execCalls.push(sql);
42 | });
43 | close = vi.fn();
44 | pragma = vi.fn();
45 | transaction = vi.fn((fn: () => any) => fn());
46 | checkFTS5Support = vi.fn(() => this._fts5Support);
47 | inTransaction = false;
48 |
49 | // Test helpers
50 | _setFTS5Support(supported: boolean) {
51 | this._fts5Support = supported;
52 | }
53 |
54 | _getStatement(sql: string) {
55 | return this.statements.get(sql);
56 | }
57 |
58 | _getExecCalls() {
59 | return this.execCalls;
60 | }
61 |
62 | _clearExecCalls() {
63 | this.execCalls = [];
64 | }
65 | }
66 |
67 | class MockPreparedStatement implements PreparedStatement {
68 | public mockResults: any[] = [];
69 | public capturedParams: any[][] = [];
70 |
71 | run = vi.fn((...params: any[]): RunResult => {
72 | this.capturedParams.push(params);
73 | return { changes: 1, lastInsertRowid: 1 };
74 | });
75 |
76 | get = vi.fn((...params: any[]) => {
77 | this.capturedParams.push(params);
78 | return this.mockResults[0] || null;
79 | });
80 |
81 | all = vi.fn((...params: any[]) => {
82 | this.capturedParams.push(params);
83 | return this.mockResults;
84 | });
85 |
86 | iterate = vi.fn();
87 | pluck = vi.fn(() => this);
88 | expand = vi.fn(() => this);
89 | raw = vi.fn(() => this);
90 | columns = vi.fn(() => []);
91 | bind = vi.fn(() => this);
92 |
93 | constructor(private sql: string) {}
94 |
95 | // Test helpers
96 | _setMockResults(results: any[]) {
97 | this.mockResults = results;
98 | }
99 |
100 | _getCapturedParams() {
101 | return this.capturedParams;
102 | }
103 | }
104 |
105 | describe('TemplateRepository - Security Tests', () => {
106 | let repository: TemplateRepository;
107 | let mockAdapter: MockDatabaseAdapter;
108 |
109 | beforeEach(() => {
110 | vi.clearAllMocks();
111 | mockAdapter = new MockDatabaseAdapter();
112 | repository = new TemplateRepository(mockAdapter);
113 | });
114 |
115 | describe('SQL Injection Prevention', () => {
116 | describe('searchTemplatesByMetadata', () => {
117 | it('should prevent SQL injection in category parameter', () => {
118 | const maliciousCategory = "'; DROP TABLE templates; --";
119 |
120 | const stmt = new MockPreparedStatement('');
121 | stmt._setMockResults([]);
122 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
123 |
124 | repository.searchTemplatesByMetadata({
125 | category: maliciousCategory}, 10, 0);
126 |
127 | // Should use parameterized queries, not inject SQL
128 | const capturedParams = stmt._getCapturedParams();
129 | expect(capturedParams.length).toBeGreaterThan(0);
130 | // The parameter should be the sanitized version (JSON.stringify then slice to remove quotes)
131 | const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1);
132 | // capturedParams[0] is the first call's parameters array
133 | expect(capturedParams[0][0]).toBe(expectedParam);
134 |
135 | // Verify the SQL doesn't contain the malicious content directly
136 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
137 | expect(prepareCall).not.toContain('DROP TABLE');
138 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
139 | });
140 |
141 | it('should prevent SQL injection in requiredService parameter', () => {
142 | const maliciousService = "'; UNION SELECT * FROM sqlite_master; --";
143 |
144 | const stmt = new MockPreparedStatement('');
145 | stmt._setMockResults([]);
146 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
147 |
148 | repository.searchTemplatesByMetadata({
149 | requiredService: maliciousService}, 10, 0);
150 |
151 | const capturedParams = stmt._getCapturedParams();
152 | const expectedParam = JSON.stringify(maliciousService).slice(1, -1);
153 | // capturedParams[0] is the first call's parameters array
154 | expect(capturedParams[0][0]).toBe(expectedParam);
155 |
156 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
157 | expect(prepareCall).not.toContain('UNION SELECT');
158 | expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
159 | });
160 |
161 | it('should prevent SQL injection in targetAudience parameter', () => {
162 | const maliciousAudience = "administrators'; DELETE FROM templates WHERE '1'='1";
163 |
164 | const stmt = new MockPreparedStatement('');
165 | stmt._setMockResults([]);
166 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
167 |
168 | repository.searchTemplatesByMetadata({
169 | targetAudience: maliciousAudience}, 10, 0);
170 |
171 | const capturedParams = stmt._getCapturedParams();
172 | const expectedParam = JSON.stringify(maliciousAudience).slice(1, -1);
173 | // capturedParams[0] is the first call's parameters array
174 | expect(capturedParams[0][0]).toBe(expectedParam);
175 |
176 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
177 | expect(prepareCall).not.toContain('DELETE FROM');
178 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
179 | });
180 |
181 | it('should safely handle special characters in parameters', () => {
182 | const specialChars = "test'with\"quotes\\and%wildcards_and[brackets]";
183 |
184 | const stmt = new MockPreparedStatement('');
185 | stmt._setMockResults([]);
186 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
187 |
188 | repository.searchTemplatesByMetadata({
189 | category: specialChars}, 10, 0);
190 |
191 | const capturedParams = stmt._getCapturedParams();
192 | const expectedParam = JSON.stringify(specialChars).slice(1, -1);
193 | // capturedParams[0] is the first call's parameters array
194 | expect(capturedParams[0][0]).toBe(expectedParam);
195 |
196 | // Should use parameterized query
197 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
198 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
199 | });
200 |
201 | it('should prevent injection through numeric parameters', () => {
202 | const stmt = new MockPreparedStatement('');
203 | stmt._setMockResults([]);
204 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
205 |
206 | // Try to inject through numeric parameters
207 | repository.searchTemplatesByMetadata({maxSetupMinutes: 999999999, // Large number
208 | minSetupMinutes: -999999999 // Negative number
209 | }, 10, 0);
210 |
211 | const capturedParams = stmt._getCapturedParams();
212 | // capturedParams[0] is the first call's parameters array
213 | expect(capturedParams[0]).toContain(999999999);
214 | expect(capturedParams[0]).toContain(-999999999);
215 |
216 | // Should use CAST and parameterized queries
217 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
218 | expect(prepareCall).toContain('CAST(json_extract(metadata_json, \'$.estimated_setup_minutes\') AS INTEGER)');
219 | });
220 | });
221 |
222 | describe('getMetadataSearchCount', () => {
223 | it('should use parameterized queries for count operations', () => {
224 | const maliciousCategory = "'; DROP TABLE templates; SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%";
225 |
226 | const stmt = new MockPreparedStatement('');
227 | stmt._setMockResults([{ count: 0 }]);
228 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
229 |
230 | repository.getMetadataSearchCount({
231 | category: maliciousCategory
232 | });
233 |
234 | const capturedParams = stmt._getCapturedParams();
235 | const expectedParam = JSON.stringify(maliciousCategory).slice(1, -1);
236 | // capturedParams[0] is the first call's parameters array
237 | expect(capturedParams[0][0]).toBe(expectedParam);
238 |
239 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
240 | expect(prepareCall).not.toContain('DROP TABLE');
241 | expect(prepareCall).toContain('SELECT COUNT(*) as count FROM templates');
242 | });
243 | });
244 |
245 | describe('updateTemplateMetadata', () => {
246 | it('should safely handle metadata with special characters', () => {
247 | const maliciousMetadata = {
248 | categories: ["automation'; DROP TABLE templates; --"],
249 | complexity: "simple",
250 | use_cases: ['SQL injection"test'],
251 | estimated_setup_minutes: 30,
252 | required_services: ['api"with\\"quotes'],
253 | key_features: ["feature's test"],
254 | target_audience: ['developers\\administrators']
255 | };
256 |
257 | const stmt = new MockPreparedStatement('');
258 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
259 |
260 | repository.updateTemplateMetadata(123, maliciousMetadata);
261 |
262 | const capturedParams = stmt._getCapturedParams();
263 | expect(capturedParams[0][0]).toBe(JSON.stringify(maliciousMetadata));
264 | expect(capturedParams[0][1]).toBe(123);
265 |
266 | // Should use parameterized UPDATE
267 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
268 | expect(prepareCall).toContain('UPDATE templates');
269 | expect(prepareCall).toContain('metadata_json = ?');
270 | expect(prepareCall).toContain('WHERE id = ?');
271 | expect(prepareCall).not.toContain('DROP TABLE');
272 | });
273 | });
274 |
275 | describe('batchUpdateMetadata', () => {
276 | it('should safely handle batch updates with malicious data', () => {
277 | const maliciousData = new Map();
278 | maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] });
279 | maliciousData.set(2, { categories: ["normal category"] });
280 |
281 | const stmt = new MockPreparedStatement('');
282 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
283 |
284 | repository.batchUpdateMetadata(maliciousData);
285 |
286 | const capturedParams = stmt._getCapturedParams();
287 | expect(capturedParams).toHaveLength(2);
288 |
289 | // Both calls should be parameterized
290 | const firstJson = capturedParams[0][0];
291 | const secondJson = capturedParams[1][0];
292 | expect(firstJson).toContain("'; DROP TABLE templates; --"); // Should be JSON-encoded
293 | expect(capturedParams[0][1]).toBe(1);
294 | expect(secondJson).toContain('normal category');
295 | expect(capturedParams[1][1]).toBe(2);
296 | });
297 | });
298 | });
299 |
300 | describe('JSON Extraction Security', () => {
301 | it('should safely extract categories from JSON', () => {
302 | const stmt = new MockPreparedStatement('');
303 | stmt._setMockResults([]);
304 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
305 |
306 | repository.getUniqueCategories();
307 |
308 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
309 | expect(prepareCall).toContain('json_each(metadata_json, \'$.categories\')');
310 | expect(prepareCall).not.toContain('eval(');
311 | expect(prepareCall).not.toContain('exec(');
312 | });
313 |
314 | it('should safely extract target audiences from JSON', () => {
315 | const stmt = new MockPreparedStatement('');
316 | stmt._setMockResults([]);
317 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
318 |
319 | repository.getUniqueTargetAudiences();
320 |
321 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
322 | expect(prepareCall).toContain('json_each(metadata_json, \'$.target_audience\')');
323 | expect(prepareCall).not.toContain('eval(');
324 | });
325 |
326 | it('should safely handle complex JSON structures', () => {
327 | const stmt = new MockPreparedStatement('');
328 | stmt._setMockResults([]);
329 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
330 |
331 | repository.getTemplatesByCategory('test');
332 |
333 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
334 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
335 |
336 | const capturedParams = stmt._getCapturedParams();
337 | // Check if parameters were captured
338 | expect(capturedParams.length).toBeGreaterThan(0);
339 | // Find the parameter that contains 'test'
340 | const testParam = capturedParams[0].find((p: any) => typeof p === 'string' && p.includes('test'));
341 | expect(testParam).toBe('test');
342 | });
343 | });
344 |
345 | describe('Input Validation and Sanitization', () => {
346 | it('should handle null and undefined parameters safely', () => {
347 | const stmt = new MockPreparedStatement('');
348 | stmt._setMockResults([]);
349 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
350 |
351 | repository.searchTemplatesByMetadata({
352 | category: undefined as any,
353 | complexity: null as any}, 10, 0);
354 |
355 | // Should not break and should exclude undefined/null filters
356 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
357 | expect(prepareCall).toContain('metadata_json IS NOT NULL');
358 | expect(prepareCall).not.toContain('undefined');
359 | expect(prepareCall).not.toContain('null');
360 | });
361 |
362 | it('should handle empty string parameters', () => {
363 | const stmt = new MockPreparedStatement('');
364 | stmt._setMockResults([]);
365 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
366 |
367 | repository.searchTemplatesByMetadata({
368 | category: '',
369 | requiredService: '',
370 | targetAudience: ''}, 10, 0);
371 |
372 | // Empty strings should still be processed (might be valid searches)
373 | const capturedParams = stmt._getCapturedParams();
374 | const expectedParam = JSON.stringify("").slice(1, -1); // Results in empty string
375 | // Check if parameters were captured
376 | expect(capturedParams.length).toBeGreaterThan(0);
377 | // Check if empty string parameters are present
378 | const hasEmptyString = capturedParams[0].includes(expectedParam);
379 | expect(hasEmptyString).toBe(true);
380 | });
381 |
382 | it('should validate numeric ranges', () => {
383 | const stmt = new MockPreparedStatement('');
384 | stmt._setMockResults([]);
385 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
386 |
387 | repository.searchTemplatesByMetadata({
388 | maxSetupMinutes: Number.MAX_SAFE_INTEGER,
389 | minSetupMinutes: Number.MIN_SAFE_INTEGER}, 10, 0);
390 |
391 | // Should handle extreme values without breaking
392 | const capturedParams = stmt._getCapturedParams();
393 | expect(capturedParams[0]).toContain(Number.MAX_SAFE_INTEGER);
394 | expect(capturedParams[0]).toContain(Number.MIN_SAFE_INTEGER);
395 | });
396 |
397 | it('should handle Unicode and international characters', () => {
398 | const unicodeCategory = '自動化'; // Japanese for "automation"
399 | const emojiAudience = '👩💻 developers';
400 |
401 | const stmt = new MockPreparedStatement('');
402 | stmt._setMockResults([]);
403 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
404 |
405 | repository.searchTemplatesByMetadata({
406 | category: unicodeCategory,
407 | targetAudience: emojiAudience}, 10, 0);
408 |
409 | const capturedParams = stmt._getCapturedParams();
410 | const expectedCategoryParam = JSON.stringify(unicodeCategory).slice(1, -1);
411 | const expectedAudienceParam = JSON.stringify(emojiAudience).slice(1, -1);
412 | // capturedParams[0] is the first call's parameters array
413 | expect(capturedParams[0][0]).toBe(expectedCategoryParam);
414 | expect(capturedParams[0][1]).toBe(expectedAudienceParam);
415 | });
416 | });
417 |
418 | describe('Database Schema Security', () => {
419 | it('should use proper column names without injection', () => {
420 | const stmt = new MockPreparedStatement('');
421 | stmt._setMockResults([]);
422 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
423 |
424 | repository.searchTemplatesByMetadata({
425 | category: 'test'}, 10, 0);
426 |
427 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
428 |
429 | // Should reference proper column names
430 | expect(prepareCall).toContain('metadata_json');
431 | expect(prepareCall).toContain('templates');
432 |
433 | // Should not contain dynamic column names that could be injected
434 | expect(prepareCall).not.toMatch(/SELECT \* FROM \w+;/);
435 | expect(prepareCall).not.toContain('information_schema');
436 | expect(prepareCall).not.toContain('sqlite_master');
437 | });
438 |
439 | it('should use proper JSON path syntax', () => {
440 | const stmt = new MockPreparedStatement('');
441 | stmt._setMockResults([]);
442 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
443 |
444 | repository.getUniqueCategories();
445 |
446 | const prepareCall = mockAdapter.prepare.mock.calls[0][0];
447 |
448 | // Should use safe JSON path syntax
449 | expect(prepareCall).toContain('$.categories');
450 | expect(prepareCall).not.toContain('$[');
451 | expect(prepareCall).not.toContain('eval(');
452 | });
453 | });
454 |
455 | describe('Transaction Safety', () => {
456 | it('should handle transaction rollback on metadata update errors', () => {
457 | const stmt = new MockPreparedStatement('');
458 | stmt.run = vi.fn().mockImplementation(() => {
459 | throw new Error('Database error');
460 | });
461 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
462 |
463 | const maliciousData = new Map();
464 | maliciousData.set(1, { categories: ["'; DROP TABLE templates; --"] });
465 |
466 | expect(() => {
467 | repository.batchUpdateMetadata(maliciousData);
468 | }).toThrow('Database error');
469 |
470 | // The error is thrown when running the statement, not during transaction setup
471 | // So we just verify that the error was thrown correctly
472 | });
473 | });
474 |
475 | describe('Error Message Security', () => {
476 | it('should not expose sensitive information in error messages', () => {
477 | const stmt = new MockPreparedStatement('');
478 | stmt.get = vi.fn().mockImplementation(() => {
479 | throw new Error('SQLITE_ERROR: syntax error near "DROP TABLE"');
480 | });
481 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
482 |
483 | expect(() => {
484 | repository.getMetadataSearchCount({
485 | category: "'; DROP TABLE templates; --"
486 | });
487 | }).toThrow(); // Should throw, but not expose SQL details
488 | });
489 | });
490 |
491 | describe('Performance and DoS Protection', () => {
492 | it('should handle large limit values safely', () => {
493 | const stmt = new MockPreparedStatement('');
494 | stmt._setMockResults([]);
495 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
496 |
497 | repository.searchTemplatesByMetadata({}, 999999999, 0); // Very large limit
498 |
499 | const capturedParams = stmt._getCapturedParams();
500 | // Check if parameters were captured
501 | expect(capturedParams.length).toBeGreaterThan(0);
502 | // Check if the large limit value is present (might be capped)
503 | const hasLargeLimit = capturedParams[0].includes(999999999) || capturedParams[0].includes(20);
504 | expect(hasLargeLimit).toBe(true);
505 |
506 | // Should still work but might be limited by database constraints
507 | expect(mockAdapter.prepare).toHaveBeenCalled();
508 | });
509 |
510 | it('should handle very long string parameters', () => {
511 | const veryLongString = 'a'.repeat(100000); // 100KB string
512 |
513 | const stmt = new MockPreparedStatement('');
514 | stmt._setMockResults([]);
515 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
516 |
517 | repository.searchTemplatesByMetadata({
518 | category: veryLongString}, 10, 0);
519 |
520 | const capturedParams = stmt._getCapturedParams();
521 | expect(capturedParams[0][0]).toContain(veryLongString);
522 |
523 | // Should handle without breaking
524 | expect(mockAdapter.prepare).toHaveBeenCalled();
525 | });
526 | });
527 | });
```
--------------------------------------------------------------------------------
/tests/integration/n8n-api/workflows/create-workflow.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Integration Tests: handleCreateWorkflow
3 | *
4 | * Tests workflow creation against a real n8n instance.
5 | * Verifies the P0 bug fix (FULL vs SHORT node type formats)
6 | * and covers all major workflow creation scenarios.
7 | */
8 |
9 | import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
10 | import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
11 | import { getTestN8nClient } from '../utils/n8n-client';
12 | import { N8nApiClient } from '../../../../src/services/n8n-api-client';
13 | import { Workflow } from '../../../../src/types/n8n-api';
14 | import {
15 | SIMPLE_WEBHOOK_WORKFLOW,
16 | SIMPLE_HTTP_WORKFLOW,
17 | MULTI_NODE_WORKFLOW,
18 | ERROR_HANDLING_WORKFLOW,
19 | AI_AGENT_WORKFLOW,
20 | EXPRESSION_WORKFLOW,
21 | getFixture
22 | } from '../utils/fixtures';
23 | import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
24 | import { createMcpContext } from '../utils/mcp-context';
25 | import { InstanceContext } from '../../../../src/types/instance-context';
26 | import { handleCreateWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
27 |
28 | describe('Integration: handleCreateWorkflow', () => {
29 | let context: TestContext;
30 | let client: N8nApiClient;
31 | let mcpContext: InstanceContext;
32 |
33 | beforeEach(() => {
34 | context = createTestContext();
35 | client = getTestN8nClient();
36 | mcpContext = createMcpContext();
37 | });
38 |
39 | afterEach(async () => {
40 | await context.cleanup();
41 | });
42 |
43 | // Global cleanup after all tests to catch any orphaned workflows
44 | // (e.g., from test retries or failures)
45 | // IMPORTANT: Skip cleanup in CI to preserve shared n8n instance workflows
46 | afterAll(async () => {
47 | if (!process.env.CI) {
48 | await cleanupOrphanedWorkflows();
49 | }
50 | });
51 |
52 | // ======================================================================
53 | // P0: Critical Bug Verification
54 | // ======================================================================
55 |
56 | describe('P0: Node Type Format Bug Fix', () => {
57 | it('should create workflow with webhook node using FULL node type format', async () => {
58 | // This test verifies the P0 bug fix where SHORT node type format
59 | // (e.g., "webhook") was incorrectly normalized to FULL format
60 | // causing workflow creation failures.
61 | //
62 | // The fix ensures FULL format (e.g., "n8n-nodes-base.webhook")
63 | // is preserved and passed to n8n API correctly.
64 |
65 | const workflowName = createTestWorkflowName('P0 Bug Verification - Webhook Node');
66 | const workflow = {
67 | name: workflowName,
68 | ...getFixture('simple-webhook')
69 | };
70 |
71 | // Create workflow using MCP handler
72 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
73 | expect(response.success).toBe(true);
74 | const result = response.data as Workflow;
75 |
76 | // Verify workflow created successfully
77 | expect(result).toBeDefined();
78 | expect(result.id).toBeTruthy();
79 | if (!result.id) throw new Error('Workflow ID is missing');
80 | context.trackWorkflow(result.id);
81 | expect(result.name).toBe(workflowName);
82 | expect(result.nodes).toHaveLength(1);
83 |
84 | // Critical: Verify FULL node type format is preserved
85 | expect(result.nodes[0].type).toBe('n8n-nodes-base.webhook');
86 | expect(result.nodes[0].name).toBe('Webhook');
87 | expect(result.nodes[0].parameters).toBeDefined();
88 | });
89 | });
90 |
91 | // ======================================================================
92 | // P1: Base Nodes (High Priority)
93 | // ======================================================================
94 |
95 | describe('P1: Base n8n Nodes', () => {
96 | it('should create workflow with HTTP Request node', async () => {
97 | const workflowName = createTestWorkflowName('HTTP Request Node');
98 | const workflow = {
99 | name: workflowName,
100 | ...getFixture('simple-http')
101 | };
102 |
103 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
104 | expect(response.success).toBe(true);
105 | const result = response.data as Workflow;
106 |
107 | expect(result).toBeDefined();
108 | expect(result.id).toBeTruthy();
109 | if (!result.id) throw new Error('Workflow ID is missing');
110 | context.trackWorkflow(result.id);
111 | expect(result.name).toBe(workflowName);
112 | expect(result.nodes).toHaveLength(2);
113 |
114 | // Verify both nodes created with FULL type format
115 | const webhookNode = result.nodes.find((n: any) => n.name === 'Webhook');
116 | const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request');
117 |
118 | expect(webhookNode).toBeDefined();
119 | expect(webhookNode!.type).toBe('n8n-nodes-base.webhook');
120 |
121 | expect(httpNode).toBeDefined();
122 | expect(httpNode!.type).toBe('n8n-nodes-base.httpRequest');
123 |
124 | // Verify connections
125 | expect(result.connections).toBeDefined();
126 | expect(result.connections.Webhook).toBeDefined();
127 | });
128 |
129 | it('should create workflow with langchain agent node', async () => {
130 | const workflowName = createTestWorkflowName('Langchain Agent Node');
131 | const workflow = {
132 | name: workflowName,
133 | ...getFixture('ai-agent')
134 | };
135 |
136 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
137 | expect(response.success).toBe(true);
138 | const result = response.data as Workflow;
139 |
140 | expect(result).toBeDefined();
141 | expect(result.id).toBeTruthy();
142 | if (!result.id) throw new Error('Workflow ID is missing');
143 | context.trackWorkflow(result.id);
144 | expect(result.name).toBe(workflowName);
145 | expect(result.nodes).toHaveLength(2);
146 |
147 | // Verify langchain node type format
148 | const agentNode = result.nodes.find((n: any) => n.name === 'AI Agent');
149 | expect(agentNode).toBeDefined();
150 | expect(agentNode!.type).toBe('@n8n/n8n-nodes-langchain.agent');
151 | });
152 |
153 | it('should create complex multi-node workflow', async () => {
154 | const workflowName = createTestWorkflowName('Multi-Node Workflow');
155 | const workflow = {
156 | name: workflowName,
157 | ...getFixture('multi-node')
158 | };
159 |
160 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
161 | expect(response.success).toBe(true);
162 | const result = response.data as Workflow;
163 |
164 | expect(result).toBeDefined();
165 | expect(result.id).toBeTruthy();
166 | if (!result.id) throw new Error('Workflow ID is missing');
167 | context.trackWorkflow(result.id);
168 | expect(result.name).toBe(workflowName);
169 | expect(result.nodes).toHaveLength(4);
170 |
171 | // Verify all node types preserved
172 | const nodeTypes = result.nodes.map((n: any) => n.type);
173 | expect(nodeTypes).toContain('n8n-nodes-base.webhook');
174 | expect(nodeTypes).toContain('n8n-nodes-base.set');
175 | expect(nodeTypes).toContain('n8n-nodes-base.merge');
176 |
177 | // Verify complex connections
178 | expect(result.connections.Webhook.main[0]).toHaveLength(2); // Branches to 2 nodes
179 | });
180 | });
181 |
182 | // ======================================================================
183 | // P2: Advanced Features (Medium Priority)
184 | // ======================================================================
185 |
186 | describe('P2: Advanced Workflow Features', () => {
187 | it('should create workflow with complex connections and branching', async () => {
188 | const workflowName = createTestWorkflowName('Complex Connections');
189 | const workflow = {
190 | name: workflowName,
191 | ...getFixture('multi-node')
192 | };
193 |
194 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
195 | expect(response.success).toBe(true);
196 | const result = response.data as Workflow;
197 |
198 | expect(result).toBeDefined();
199 | expect(result.id).toBeTruthy();
200 | if (!result.id) throw new Error('Workflow ID is missing');
201 | context.trackWorkflow(result.id);
202 | expect(result.connections).toBeDefined();
203 |
204 | // Verify branching: Webhook -> Set 1 and Set 2
205 | const webhookConnections = result.connections.Webhook.main[0];
206 | expect(webhookConnections).toHaveLength(2);
207 |
208 | // Verify merging: Set 1 -> Merge (port 0), Set 2 -> Merge (port 1)
209 | const set1Connections = result.connections['Set 1'].main[0];
210 | const set2Connections = result.connections['Set 2'].main[0];
211 |
212 | expect(set1Connections[0].node).toBe('Merge');
213 | expect(set1Connections[0].index).toBe(0);
214 |
215 | expect(set2Connections[0].node).toBe('Merge');
216 | expect(set2Connections[0].index).toBe(1);
217 | });
218 |
219 | it('should create workflow with custom settings', async () => {
220 | const workflowName = createTestWorkflowName('Custom Settings');
221 | const workflow = {
222 | name: workflowName,
223 | ...getFixture('error-handling'),
224 | settings: {
225 | executionOrder: 'v1' as const,
226 | timezone: 'America/New_York',
227 | saveDataErrorExecution: 'all' as const,
228 | saveDataSuccessExecution: 'all' as const,
229 | saveExecutionProgress: true
230 | }
231 | };
232 |
233 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
234 | expect(response.success).toBe(true);
235 | const result = response.data as Workflow;
236 |
237 | expect(result).toBeDefined();
238 | expect(result.id).toBeTruthy();
239 | if (!result.id) throw new Error('Workflow ID is missing');
240 | context.trackWorkflow(result.id);
241 | expect(result.settings).toBeDefined();
242 | expect(result.settings!.executionOrder).toBe('v1');
243 | });
244 |
245 | it('should create workflow with n8n expressions', async () => {
246 | const workflowName = createTestWorkflowName('n8n Expressions');
247 | const workflow = {
248 | name: workflowName,
249 | ...getFixture('expression')
250 | };
251 |
252 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
253 | expect(response.success).toBe(true);
254 | const result = response.data as Workflow;
255 |
256 | expect(result).toBeDefined();
257 | expect(result.id).toBeTruthy();
258 | if (!result.id) throw new Error('Workflow ID is missing');
259 | context.trackWorkflow(result.id);
260 | expect(result.nodes).toHaveLength(2);
261 |
262 | // Verify Set node with expressions
263 | const setNode = result.nodes.find((n: any) => n.name === 'Set Variables');
264 | expect(setNode).toBeDefined();
265 | expect(setNode!.parameters.assignments).toBeDefined();
266 |
267 | // Verify expressions are preserved
268 | const assignmentsData = setNode!.parameters.assignments as { assignments: Array<{ value: string }> };
269 | expect(assignmentsData.assignments).toHaveLength(3);
270 | expect(assignmentsData.assignments[0].value).toContain('$now');
271 | expect(assignmentsData.assignments[1].value).toContain('$json');
272 | expect(assignmentsData.assignments[2].value).toContain('$node');
273 | });
274 |
275 | it('should create workflow with error handling configuration', async () => {
276 | const workflowName = createTestWorkflowName('Error Handling');
277 | const workflow = {
278 | name: workflowName,
279 | ...getFixture('error-handling')
280 | };
281 |
282 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
283 | expect(response.success).toBe(true);
284 | const result = response.data as Workflow;
285 |
286 | expect(result).toBeDefined();
287 | expect(result.id).toBeTruthy();
288 | if (!result.id) throw new Error('Workflow ID is missing');
289 | context.trackWorkflow(result.id);
290 | expect(result.nodes).toHaveLength(3);
291 |
292 | // Verify HTTP node with error handling
293 | const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request');
294 | expect(httpNode).toBeDefined();
295 | expect(httpNode!.continueOnFail).toBe(true);
296 | expect(httpNode!.onError).toBe('continueErrorOutput');
297 |
298 | // Verify error connection
299 | expect(result.connections['HTTP Request'].error).toBeDefined();
300 | expect(result.connections['HTTP Request'].error[0][0].node).toBe('Handle Error');
301 | });
302 | });
303 |
304 | // ======================================================================
305 | // Error Scenarios (P1 Priority)
306 | // ======================================================================
307 |
308 | describe('Error Scenarios', () => {
309 | it('should reject workflow with invalid node type (MCP validation)', async () => {
310 | // MCP handler correctly validates workflows before sending to n8n API.
311 | // Invalid node types are caught during MCP validation.
312 | //
313 | // Note: Raw n8n API would accept this and only fail at execution time,
314 | // but MCP handler does proper pre-validation (correct behavior).
315 |
316 | const workflowName = createTestWorkflowName('Invalid Node Type');
317 | const workflow = {
318 | name: workflowName,
319 | nodes: [
320 | {
321 | id: 'invalid-1',
322 | name: 'Invalid Node',
323 | type: 'n8n-nodes-base.nonexistentnode',
324 | typeVersion: 1,
325 | position: [250, 300] as [number, number],
326 | parameters: {}
327 | }
328 | ],
329 | connections: {},
330 | settings: { executionOrder: 'v1' as const }
331 | };
332 |
333 | // MCP handler rejects invalid workflows (correct behavior)
334 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
335 | expect(response.success).toBe(false);
336 | expect(response.error).toBeDefined();
337 | expect(response.error).toContain('validation');
338 | });
339 |
340 | it('should reject workflow with missing required node parameters (MCP validation)', async () => {
341 | // MCP handler validates required parameters before sending to n8n API.
342 | //
343 | // Note: Raw n8n API would accept this and only fail at execution time,
344 | // but MCP handler does proper pre-validation (correct behavior).
345 |
346 | const workflowName = createTestWorkflowName('Missing Parameters');
347 | const workflow = {
348 | name: workflowName,
349 | nodes: [
350 | {
351 | id: 'http-1',
352 | name: 'HTTP Request',
353 | type: 'n8n-nodes-base.httpRequest',
354 | typeVersion: 4.2,
355 | position: [250, 300] as [number, number],
356 | parameters: {
357 | // Missing required 'url' parameter
358 | method: 'GET'
359 | }
360 | }
361 | ],
362 | connections: {},
363 | settings: { executionOrder: 'v1' as const }
364 | };
365 |
366 | // MCP handler rejects workflows with validation errors (correct behavior)
367 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
368 | expect(response.success).toBe(false);
369 | expect(response.error).toBeDefined();
370 | });
371 |
372 | it('should reject workflow with duplicate node names (MCP validation)', async () => {
373 | // MCP handler validates that node names are unique.
374 | //
375 | // Note: Raw n8n API might auto-rename duplicates, but MCP handler
376 | // enforces unique names upfront (correct behavior).
377 |
378 | const workflowName = createTestWorkflowName('Duplicate Node Names');
379 | const workflow = {
380 | name: workflowName,
381 | nodes: [
382 | {
383 | id: 'set-1',
384 | name: 'Set',
385 | type: 'n8n-nodes-base.set',
386 | typeVersion: 3.4,
387 | position: [250, 300] as [number, number],
388 | parameters: {
389 | assignments: { assignments: [] },
390 | options: {}
391 | }
392 | },
393 | {
394 | id: 'set-2',
395 | name: 'Set', // Duplicate name
396 | type: 'n8n-nodes-base.set',
397 | typeVersion: 3.4,
398 | position: [450, 300] as [number, number],
399 | parameters: {
400 | assignments: { assignments: [] },
401 | options: {}
402 | }
403 | }
404 | ],
405 | connections: {},
406 | settings: { executionOrder: 'v1' as const }
407 | };
408 |
409 | // MCP handler rejects workflows with validation errors (correct behavior)
410 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
411 | expect(response.success).toBe(false);
412 | expect(response.error).toBeDefined();
413 | });
414 |
415 | it('should reject workflow with invalid connection references (MCP validation)', async () => {
416 | // MCP handler validates that connection references point to existing nodes.
417 | //
418 | // Note: Raw n8n API would accept this and only fail at execution time,
419 | // but MCP handler does proper connection validation (correct behavior).
420 |
421 | const workflowName = createTestWorkflowName('Invalid Connections');
422 | const workflow = {
423 | name: workflowName,
424 | nodes: [
425 | {
426 | id: 'webhook-1',
427 | name: 'Webhook',
428 | type: 'n8n-nodes-base.webhook',
429 | typeVersion: 2,
430 | position: [250, 300] as [number, number],
431 | parameters: {
432 | httpMethod: 'GET',
433 | path: 'test'
434 | }
435 | }
436 | ],
437 | connections: {
438 | // Connection references non-existent node
439 | Webhook: {
440 | main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
441 | }
442 | },
443 | settings: { executionOrder: 'v1' as const }
444 | };
445 |
446 | // MCP handler rejects workflows with invalid connections (correct behavior)
447 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
448 | expect(response.success).toBe(false);
449 | expect(response.error).toBeDefined();
450 | expect(response.error).toContain('validation');
451 | });
452 | });
453 |
454 | // ======================================================================
455 | // Additional Edge Cases
456 | // ======================================================================
457 |
458 | describe('Edge Cases', () => {
459 | it('should reject single-node non-webhook workflow (MCP validation)', async () => {
460 | // MCP handler enforces that single-node workflows are only valid for webhooks.
461 | // This is a best practice validation.
462 |
463 | const workflowName = createTestWorkflowName('Minimal Single Node');
464 | const workflow = {
465 | name: workflowName,
466 | nodes: [
467 | {
468 | id: 'manual-1',
469 | name: 'Manual Trigger',
470 | type: 'n8n-nodes-base.manualTrigger',
471 | typeVersion: 1,
472 | position: [250, 300] as [number, number],
473 | parameters: {}
474 | }
475 | ],
476 | connections: {},
477 | settings: { executionOrder: 'v1' as const }
478 | };
479 |
480 | // MCP handler rejects single-node non-webhook workflows (correct behavior)
481 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
482 | expect(response.success).toBe(false);
483 | expect(response.error).toBeDefined();
484 | expect(response.error).toContain('validation');
485 | });
486 |
487 | it('should reject single-node non-trigger workflow (MCP validation)', async () => {
488 | // MCP handler enforces workflow best practices.
489 | // Single isolated nodes without connections are rejected.
490 |
491 | const workflowName = createTestWorkflowName('Empty Connections');
492 | const workflow = {
493 | name: workflowName,
494 | nodes: [
495 | {
496 | id: 'set-1',
497 | name: 'Set',
498 | type: 'n8n-nodes-base.set',
499 | typeVersion: 3.4,
500 | position: [250, 300] as [number, number],
501 | parameters: {
502 | assignments: { assignments: [] },
503 | options: {}
504 | }
505 | }
506 | ],
507 | connections: {}, // Explicitly empty
508 | settings: { executionOrder: 'v1' as const }
509 | };
510 |
511 | // MCP handler rejects single-node workflows (correct behavior)
512 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
513 | expect(response.success).toBe(false);
514 | expect(response.error).toBeDefined();
515 | });
516 |
517 | it('should reject single-node workflow without settings (MCP validation)', async () => {
518 | // MCP handler enforces workflow best practices.
519 | // Single-node non-webhook workflows are rejected.
520 |
521 | const workflowName = createTestWorkflowName('No Settings');
522 | const workflow = {
523 | name: workflowName,
524 | nodes: [
525 | {
526 | id: 'manual-1',
527 | name: 'Manual Trigger',
528 | type: 'n8n-nodes-base.manualTrigger',
529 | typeVersion: 1,
530 | position: [250, 300] as [number, number],
531 | parameters: {}
532 | }
533 | ],
534 | connections: {}
535 | // No settings property
536 | };
537 |
538 | // MCP handler rejects single-node workflows (correct behavior)
539 | const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
540 | expect(response.success).toBe(false);
541 | expect(response.error).toBeDefined();
542 | });
543 | });
544 | });
545 |
```
--------------------------------------------------------------------------------
/tests/integration/mcp-protocol/tool-invocation.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2 | import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4 | import { TestableN8NMCPServer } from './test-helpers';
5 |
6 | describe('MCP Tool Invocation', () => {
7 | let mcpServer: TestableN8NMCPServer;
8 | let client: Client;
9 |
10 | beforeEach(async () => {
11 | mcpServer = new TestableN8NMCPServer();
12 | await mcpServer.initialize();
13 |
14 | const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
15 | await mcpServer.connectToTransport(serverTransport);
16 |
17 | client = new Client({
18 | name: 'test-client',
19 | version: '1.0.0'
20 | }, {
21 | capabilities: {}
22 | });
23 |
24 | await client.connect(clientTransport);
25 | });
26 |
27 | afterEach(async () => {
28 | await client.close();
29 | await mcpServer.close();
30 | });
31 |
32 | describe('Node Discovery Tools', () => {
33 | describe('list_nodes', () => {
34 | it('should list nodes with default parameters', async () => {
35 | const response = await client.callTool({ name: 'list_nodes', arguments: {} });
36 |
37 | expect((response as any).content).toHaveLength(1);
38 | expect((response as any).content[0].type).toBe('text');
39 |
40 | const result = JSON.parse(((response as any).content[0]).text);
41 | // The result is an object with nodes array and totalCount
42 | expect(result).toHaveProperty('nodes');
43 | expect(result).toHaveProperty('totalCount');
44 |
45 | const nodes = result.nodes;
46 | expect(Array.isArray(nodes)).toBe(true);
47 | expect(nodes.length).toBeGreaterThan(0);
48 |
49 | // Check node structure
50 | const firstNode = nodes[0];
51 | expect(firstNode).toHaveProperty('nodeType');
52 | expect(firstNode).toHaveProperty('displayName');
53 | expect(firstNode).toHaveProperty('category');
54 | });
55 |
56 | it('should filter nodes by category', async () => {
57 | const response = await client.callTool({ name: 'list_nodes', arguments: {
58 | category: 'trigger'
59 | }});
60 |
61 | const result = JSON.parse(((response as any).content[0]).text);
62 | const nodes = result.nodes;
63 | expect(nodes.length).toBeGreaterThan(0);
64 | nodes.forEach((node: any) => {
65 | expect(node.category).toBe('trigger');
66 | });
67 | });
68 |
69 | it('should limit results', async () => {
70 | const response = await client.callTool({ name: 'list_nodes', arguments: {
71 | limit: 5
72 | }});
73 |
74 | const result = JSON.parse(((response as any).content[0]).text);
75 | const nodes = result.nodes;
76 | expect(nodes).toHaveLength(5);
77 | });
78 |
79 | it('should filter by package', async () => {
80 | const response = await client.callTool({ name: 'list_nodes', arguments: {
81 | package: 'n8n-nodes-base'
82 | }});
83 |
84 | const result = JSON.parse(((response as any).content[0]).text);
85 | const nodes = result.nodes;
86 | expect(nodes.length).toBeGreaterThan(0);
87 | nodes.forEach((node: any) => {
88 | expect(node.package).toBe('n8n-nodes-base');
89 | });
90 | });
91 | });
92 |
93 | describe('search_nodes', () => {
94 | it('should search nodes by keyword', async () => {
95 | const response = await client.callTool({ name: 'search_nodes', arguments: {
96 | query: 'webhook'
97 | }});
98 |
99 | const result = JSON.parse(((response as any).content[0]).text);
100 | const nodes = result.results;
101 | expect(nodes.length).toBeGreaterThan(0);
102 |
103 | // Should find webhook node
104 | const webhookNode = nodes.find((n: any) => n.displayName.toLowerCase().includes('webhook'));
105 | expect(webhookNode).toBeDefined();
106 | });
107 |
108 | it('should support different search modes', async () => {
109 | // OR mode
110 | const orResponse = await client.callTool({ name: 'search_nodes', arguments: {
111 | query: 'http request',
112 | mode: 'OR'
113 | }});
114 | const orResult = JSON.parse(((orResponse as any).content[0]).text);
115 | const orNodes = orResult.results;
116 | expect(orNodes.length).toBeGreaterThan(0);
117 |
118 | // AND mode
119 | const andResponse = await client.callTool({ name: 'search_nodes', arguments: {
120 | query: 'http request',
121 | mode: 'AND'
122 | }});
123 | const andResult = JSON.parse(((andResponse as any).content[0]).text);
124 | const andNodes = andResult.results;
125 | expect(andNodes.length).toBeLessThanOrEqual(orNodes.length);
126 |
127 | // FUZZY mode - use less typo-heavy search
128 | const fuzzyResponse = await client.callTool({ name: 'search_nodes', arguments: {
129 | query: 'http req', // Partial match should work
130 | mode: 'FUZZY'
131 | }});
132 | const fuzzyResult = JSON.parse(((fuzzyResponse as any).content[0]).text);
133 | const fuzzyNodes = fuzzyResult.results;
134 | expect(fuzzyNodes.length).toBeGreaterThan(0);
135 | });
136 |
137 | it('should respect result limit', async () => {
138 | const response = await client.callTool({ name: 'search_nodes', arguments: {
139 | query: 'node',
140 | limit: 3
141 | }});
142 |
143 | const result = JSON.parse(((response as any).content[0]).text);
144 | const nodes = result.results;
145 | expect(nodes).toHaveLength(3);
146 | });
147 | });
148 |
149 | describe('get_node_info', () => {
150 | it('should get complete node information', async () => {
151 | const response = await client.callTool({ name: 'get_node_info', arguments: {
152 | nodeType: 'nodes-base.httpRequest'
153 | }});
154 |
155 | expect(((response as any).content[0]).type).toBe('text');
156 | const nodeInfo = JSON.parse(((response as any).content[0]).text);
157 |
158 | expect(nodeInfo).toHaveProperty('nodeType', 'nodes-base.httpRequest');
159 | expect(nodeInfo).toHaveProperty('displayName');
160 | expect(nodeInfo).toHaveProperty('properties');
161 | expect(Array.isArray(nodeInfo.properties)).toBe(true);
162 | });
163 |
164 | it('should handle non-existent nodes', async () => {
165 | try {
166 | await client.callTool({ name: 'get_node_info', arguments: {
167 | nodeType: 'nodes-base.nonExistent'
168 | }});
169 | expect.fail('Should have thrown an error');
170 | } catch (error: any) {
171 | expect(error.message).toContain('not found');
172 | }
173 | });
174 |
175 | it('should handle invalid node type format', async () => {
176 | try {
177 | await client.callTool({ name: 'get_node_info', arguments: {
178 | nodeType: 'invalidFormat'
179 | }});
180 | expect.fail('Should have thrown an error');
181 | } catch (error: any) {
182 | expect(error.message).toContain('not found');
183 | }
184 | });
185 | });
186 |
187 | describe('get_node_essentials', () => {
188 | it('should return condensed node information', async () => {
189 | const response = await client.callTool({ name: 'get_node_essentials', arguments: {
190 | nodeType: 'nodes-base.httpRequest'
191 | }});
192 |
193 | const essentials = JSON.parse(((response as any).content[0]).text);
194 |
195 | expect(essentials).toHaveProperty('nodeType');
196 | expect(essentials).toHaveProperty('displayName');
197 | expect(essentials).toHaveProperty('commonProperties');
198 | expect(essentials).toHaveProperty('requiredProperties');
199 |
200 | // Should be smaller than full info
201 | const fullResponse = await client.callTool({ name: 'get_node_info', arguments: {
202 | nodeType: 'nodes-base.httpRequest'
203 | }});
204 |
205 | expect(((response as any).content[0]).text.length).toBeLessThan(((fullResponse as any).content[0]).text.length);
206 | });
207 | });
208 | });
209 |
210 | describe('Validation Tools', () => {
211 | describe('validate_node_operation', () => {
212 | it('should validate valid node configuration', async () => {
213 | const response = await client.callTool({ name: 'validate_node_operation', arguments: {
214 | nodeType: 'nodes-base.httpRequest',
215 | config: {
216 | method: 'GET',
217 | url: 'https://api.example.com/data'
218 | }
219 | }});
220 |
221 | const validation = JSON.parse(((response as any).content[0]).text);
222 | expect(validation).toHaveProperty('valid');
223 | expect(validation).toHaveProperty('errors');
224 | expect(validation).toHaveProperty('warnings');
225 | });
226 |
227 | it('should detect missing required fields', async () => {
228 | const response = await client.callTool({ name: 'validate_node_operation', arguments: {
229 | nodeType: 'nodes-base.httpRequest',
230 | config: {
231 | method: 'GET'
232 | // Missing required 'url' field
233 | }
234 | }});
235 |
236 | const validation = JSON.parse(((response as any).content[0]).text);
237 | expect(validation.valid).toBe(false);
238 | expect(validation.errors.length).toBeGreaterThan(0);
239 | expect(validation.errors[0].message.toLowerCase()).toContain('url');
240 | });
241 |
242 | it('should support different validation profiles', async () => {
243 | const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'];
244 |
245 | for (const profile of profiles) {
246 | const response = await client.callTool({ name: 'validate_node_operation', arguments: {
247 | nodeType: 'nodes-base.httpRequest',
248 | config: { method: 'GET', url: 'https://api.example.com' },
249 | profile
250 | }});
251 |
252 | const validation = JSON.parse(((response as any).content[0]).text);
253 | expect(validation).toHaveProperty('profile', profile);
254 | }
255 | });
256 | });
257 |
258 | describe('validate_workflow', () => {
259 | it('should validate complete workflow', async () => {
260 | const workflow = {
261 | nodes: [
262 | {
263 | id: '1',
264 | name: 'Start',
265 | type: 'nodes-base.manualTrigger',
266 | typeVersion: 1,
267 | position: [0, 0],
268 | parameters: {}
269 | },
270 | {
271 | id: '2',
272 | name: 'HTTP Request',
273 | type: 'nodes-base.httpRequest',
274 | typeVersion: 3,
275 | position: [250, 0],
276 | parameters: {
277 | method: 'GET',
278 | url: 'https://api.example.com/data'
279 | }
280 | }
281 | ],
282 | connections: {
283 | 'Start': {
284 | 'main': [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
285 | }
286 | }
287 | };
288 |
289 | const response = await client.callTool({ name: 'validate_workflow', arguments: {
290 | workflow
291 | }});
292 |
293 | const validation = JSON.parse(((response as any).content[0]).text);
294 | expect(validation).toHaveProperty('valid');
295 | expect(validation).toHaveProperty('errors');
296 | expect(validation).toHaveProperty('warnings');
297 | });
298 |
299 | it('should detect connection errors', async () => {
300 | const workflow = {
301 | nodes: [
302 | {
303 | id: '1',
304 | name: 'Start',
305 | type: 'nodes-base.manualTrigger',
306 | typeVersion: 1,
307 | position: [0, 0],
308 | parameters: {}
309 | }
310 | ],
311 | connections: {
312 | 'Start': {
313 | 'main': [[{ node: 'NonExistent', type: 'main', index: 0 }]]
314 | }
315 | }
316 | };
317 |
318 | const response = await client.callTool({ name: 'validate_workflow', arguments: {
319 | workflow
320 | }});
321 |
322 | const validation = JSON.parse(((response as any).content[0]).text);
323 | expect(validation.valid).toBe(false);
324 | expect(validation.errors.length).toBeGreaterThan(0);
325 | });
326 |
327 | it('should validate expressions', async () => {
328 | const workflow = {
329 | nodes: [
330 | {
331 | id: '1',
332 | name: 'Start',
333 | type: 'n8n-nodes-base.manualTrigger',
334 | typeVersion: 1,
335 | position: [0, 0],
336 | parameters: {}
337 | },
338 | {
339 | id: '2',
340 | name: 'Set',
341 | type: 'n8n-nodes-base.set',
342 | typeVersion: 3.4,
343 | position: [250, 0],
344 | parameters: {
345 | mode: 'manual',
346 | duplicateItem: false,
347 | values: {
348 | string: [
349 | {
350 | name: 'test',
351 | value: '={{ $json.invalidExpression }}'
352 | }
353 | ]
354 | }
355 | }
356 | }
357 | ],
358 | connections: {
359 | 'Start': {
360 | 'main': [[{ node: 'Set', type: 'main', index: 0 }]]
361 | }
362 | }
363 | };
364 |
365 | const response = await client.callTool({ name: 'validate_workflow', arguments: {
366 | workflow,
367 | options: {
368 | validateExpressions: true
369 | }
370 | }});
371 |
372 | const validation = JSON.parse(((response as any).content[0]).text);
373 | expect(validation).toHaveProperty('valid');
374 |
375 | // The workflow should have either errors or warnings about the expression
376 | if (validation.errors && validation.errors.length > 0) {
377 | expect(validation.errors.some((e: any) =>
378 | e.message.includes('expression') || e.message.includes('$json')
379 | )).toBe(true);
380 | } else if (validation.warnings) {
381 | expect(validation.warnings.length).toBeGreaterThan(0);
382 | expect(validation.warnings.some((w: any) =>
383 | w.message.includes('expression') || w.message.includes('$json')
384 | )).toBe(true);
385 | }
386 | });
387 | });
388 | });
389 |
390 | describe('Documentation Tools', () => {
391 | describe('tools_documentation', () => {
392 | it('should get quick start guide', async () => {
393 | const response = await client.callTool({ name: 'tools_documentation', arguments: {} });
394 |
395 | expect(((response as any).content[0]).type).toBe('text');
396 | expect(((response as any).content[0]).text).toContain('n8n MCP Tools');
397 | });
398 |
399 | it('should get specific tool documentation', async () => {
400 | const response = await client.callTool({ name: 'tools_documentation', arguments: {
401 | topic: 'search_nodes'
402 | }});
403 |
404 | expect(((response as any).content[0]).text).toContain('search_nodes');
405 | expect(((response as any).content[0]).text).toContain('Text search');
406 | });
407 |
408 | it('should get comprehensive documentation', async () => {
409 | const response = await client.callTool({ name: 'tools_documentation', arguments: {
410 | depth: 'full'
411 | }});
412 |
413 | expect(((response as any).content[0]).text.length).toBeGreaterThan(5000);
414 | expect(((response as any).content[0]).text).toBeDefined();
415 | });
416 |
417 | it('should handle invalid topics gracefully', async () => {
418 | const response = await client.callTool({ name: 'tools_documentation', arguments: {
419 | topic: 'nonexistent_tool'
420 | }});
421 |
422 | expect(((response as any).content[0]).text).toContain('not found');
423 | });
424 | });
425 | });
426 |
427 | describe('AI Tools', () => {
428 | describe('list_ai_tools', () => {
429 | it('should list AI-capable nodes', async () => {
430 | const response = await client.callTool({ name: 'list_ai_tools', arguments: {} });
431 |
432 | const result = JSON.parse(((response as any).content[0]).text);
433 | expect(result).toHaveProperty('tools');
434 | const aiTools = result.tools;
435 | expect(Array.isArray(aiTools)).toBe(true);
436 | expect(aiTools.length).toBeGreaterThan(0);
437 |
438 | // All should have nodeType and displayName
439 | aiTools.forEach((tool: any) => {
440 | expect(tool).toHaveProperty('nodeType');
441 | expect(tool).toHaveProperty('displayName');
442 | });
443 | });
444 | });
445 |
446 | describe('get_node_as_tool_info', () => {
447 | it('should provide AI tool usage information', async () => {
448 | const response = await client.callTool({ name: 'get_node_as_tool_info', arguments: {
449 | nodeType: 'nodes-base.slack'
450 | }});
451 |
452 | const info = JSON.parse(((response as any).content[0]).text);
453 | expect(info).toHaveProperty('nodeType');
454 | expect(info).toHaveProperty('isMarkedAsAITool');
455 | expect(info).toHaveProperty('aiToolCapabilities');
456 | expect(info.aiToolCapabilities).toHaveProperty('commonUseCases');
457 | });
458 | });
459 | });
460 |
461 | describe('Task Templates', () => {
462 | // get_node_for_task was removed in v2.15.0
463 | // Use search_nodes({ includeExamples: true }) instead for real-world examples
464 |
465 | describe('list_tasks', () => {
466 | it('should list all available tasks', async () => {
467 | const response = await client.callTool({ name: 'list_tasks', arguments: {} });
468 |
469 | const result = JSON.parse(((response as any).content[0]).text);
470 | expect(result).toHaveProperty('totalTasks');
471 | expect(result).toHaveProperty('categories');
472 | expect(result.totalTasks).toBeGreaterThan(0);
473 |
474 | // Check categories structure
475 | const categories = result.categories;
476 | expect(typeof categories).toBe('object');
477 |
478 | // Check at least one category has tasks
479 | const hasTasksInCategories = Object.values(categories).some((tasks: any) =>
480 | Array.isArray(tasks) && tasks.length > 0
481 | );
482 | expect(hasTasksInCategories).toBe(true);
483 | });
484 |
485 | it('should filter by category', async () => {
486 | const response = await client.callTool({ name: 'list_tasks', arguments: {
487 | category: 'HTTP/API'
488 | }});
489 |
490 | const result = JSON.parse(((response as any).content[0]).text);
491 | expect(result).toHaveProperty('category', 'HTTP/API');
492 | expect(result).toHaveProperty('tasks');
493 |
494 | const httpTasks = result.tasks;
495 | expect(Array.isArray(httpTasks)).toBe(true);
496 | expect(httpTasks.length).toBeGreaterThan(0);
497 |
498 | httpTasks.forEach((task: any) => {
499 | expect(task).toHaveProperty('task');
500 | expect(task).toHaveProperty('description');
501 | expect(task).toHaveProperty('nodeType');
502 | });
503 | });
504 | });
505 | });
506 |
507 | describe('Complex Tool Interactions', () => {
508 | it('should handle tool chaining', async () => {
509 | // Search for nodes
510 | const searchResponse = await client.callTool({ name: 'search_nodes', arguments: {
511 | query: 'slack'
512 | }});
513 | const searchResult = JSON.parse(((searchResponse as any).content[0]).text);
514 | const nodes = searchResult.results;
515 |
516 | // Get info for first result
517 | const firstNode = nodes[0];
518 | const infoResponse = await client.callTool({ name: 'get_node_info', arguments: {
519 | nodeType: firstNode.nodeType
520 | }});
521 |
522 | expect(((infoResponse as any).content[0]).text).toContain(firstNode.displayName);
523 | });
524 |
525 | it('should handle parallel tool calls', async () => {
526 | const tools = [
527 | 'list_nodes',
528 | 'get_database_statistics',
529 | 'list_ai_tools',
530 | 'list_tasks'
531 | ];
532 |
533 | const promises = tools.map(tool =>
534 | client.callTool({ name: tool as any, arguments: {} })
535 | );
536 |
537 | const responses = await Promise.all(promises);
538 |
539 | expect(responses).toHaveLength(tools.length);
540 | responses.forEach(response => {
541 | expect(response.content).toHaveLength(1);
542 | expect(((response as any).content[0]).type).toBe('text');
543 | });
544 | });
545 |
546 | it('should maintain consistency across related tools', async () => {
547 | // Get node via different methods
548 | const nodeType = 'nodes-base.httpRequest';
549 |
550 | const [fullInfo, essentials, searchResult] = await Promise.all([
551 | client.callTool({ name: 'get_node_info', arguments: { nodeType } }),
552 | client.callTool({ name: 'get_node_essentials', arguments: { nodeType } }),
553 | client.callTool({ name: 'search_nodes', arguments: { query: 'httpRequest' } })
554 | ]);
555 |
556 | const full = JSON.parse(((fullInfo as any).content[0]).text);
557 | const essential = JSON.parse(((essentials as any).content[0]).text);
558 | const searchData = JSON.parse(((searchResult as any).content[0]).text);
559 | const search = searchData.results;
560 |
561 | // Should all reference the same node
562 | expect(full.nodeType).toBe('nodes-base.httpRequest');
563 | expect(essential.displayName).toBe(full.displayName);
564 | expect(search.find((n: any) => n.nodeType === 'nodes-base.httpRequest')).toBeDefined();
565 | });
566 | });
567 | });
568 |
```
--------------------------------------------------------------------------------
/tests/integration/database/performance.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2 | import Database from 'better-sqlite3';
3 | import { NodeRepository } from '../../../src/database/node-repository';
4 | import { TemplateRepository } from '../../../src/templates/template-repository';
5 | import { DatabaseAdapter } from '../../../src/database/database-adapter';
6 | import { TestDatabase, TestDataGenerator, PerformanceMonitor, createTestDatabaseAdapter } from './test-utils';
7 | import { ParsedNode } from '../../../src/parsers/node-parser';
8 | import { TemplateWorkflow, TemplateDetail } from '../../../src/templates/template-fetcher';
9 |
10 | describe('Database Performance Tests', () => {
11 | let testDb: TestDatabase;
12 | let db: Database.Database;
13 | let nodeRepo: NodeRepository;
14 | let templateRepo: TemplateRepository;
15 | let adapter: DatabaseAdapter;
16 | let monitor: PerformanceMonitor;
17 |
18 | beforeEach(async () => {
19 | testDb = new TestDatabase({ mode: 'file', name: 'performance-test.db', enableFTS5: true });
20 | db = await testDb.initialize();
21 | adapter = createTestDatabaseAdapter(db);
22 | nodeRepo = new NodeRepository(adapter);
23 | templateRepo = new TemplateRepository(adapter);
24 | monitor = new PerformanceMonitor();
25 | });
26 |
27 | afterEach(async () => {
28 | monitor.clear();
29 | await testDb.cleanup();
30 | });
31 |
32 | describe('Node Repository Performance', () => {
33 | it('should handle bulk inserts efficiently', () => {
34 | const nodeCounts = [100, 1000, 5000];
35 |
36 | nodeCounts.forEach(count => {
37 | const nodes = generateNodes(count);
38 |
39 | const stop = monitor.start(`insert_${count}_nodes`);
40 | const transaction = db.transaction((nodes: ParsedNode[]) => {
41 | nodes.forEach(node => nodeRepo.saveNode(node));
42 | });
43 | transaction(nodes);
44 | stop();
45 | });
46 |
47 | // Check performance metrics
48 | const stats100 = monitor.getStats('insert_100_nodes');
49 | const stats1000 = monitor.getStats('insert_1000_nodes');
50 | const stats5000 = monitor.getStats('insert_5000_nodes');
51 |
52 | // Environment-aware thresholds
53 | const threshold100 = process.env.CI ? 200 : 100;
54 | const threshold1000 = process.env.CI ? 1000 : 500;
55 | const threshold5000 = process.env.CI ? 4000 : 2000;
56 |
57 | expect(stats100!.average).toBeLessThan(threshold100);
58 | expect(stats1000!.average).toBeLessThan(threshold1000);
59 | expect(stats5000!.average).toBeLessThan(threshold5000);
60 |
61 | // Performance should scale sub-linearly
62 | const ratio1000to100 = stats1000!.average / stats100!.average;
63 | const ratio5000to1000 = stats5000!.average / stats1000!.average;
64 |
65 | // Adjusted based on actual CI performance measurements + type safety overhead
66 | // CI environments show ratios of ~7-10 for 1000:100 and ~6-7 for 5000:1000
67 | expect(ratio1000to100).toBeLessThan(12); // Allow for CI variability (was 10)
68 | expect(ratio5000to1000).toBeLessThan(11); // Allow for type safety overhead (was 8)
69 | });
70 |
71 | it('should search nodes quickly with indexes', () => {
72 | // Insert test data with search-friendly content
73 | const searchableNodes = generateSearchableNodes(10000);
74 | const transaction = db.transaction((nodes: ParsedNode[]) => {
75 | nodes.forEach(node => nodeRepo.saveNode(node));
76 | });
77 | transaction(searchableNodes);
78 |
79 | // Test different search scenarios
80 | const searchTests = [
81 | { query: 'webhook', mode: 'OR' as const },
82 | { query: 'http request', mode: 'AND' as const },
83 | { query: 'automation data', mode: 'OR' as const },
84 | { query: 'HTT', mode: 'FUZZY' as const }
85 | ];
86 |
87 | searchTests.forEach(test => {
88 | const stop = monitor.start(`search_${test.query}_${test.mode}`);
89 | const results = nodeRepo.searchNodes(test.query, test.mode, 100);
90 | stop();
91 |
92 | expect(results.length).toBeGreaterThan(0);
93 | });
94 |
95 | // All searches should be fast
96 | searchTests.forEach(test => {
97 | const stats = monitor.getStats(`search_${test.query}_${test.mode}`);
98 | const threshold = process.env.CI ? 100 : 50;
99 | expect(stats!.average).toBeLessThan(threshold);
100 | });
101 | });
102 |
103 | it('should handle concurrent reads efficiently', () => {
104 | // Insert initial data
105 | const nodes = generateNodes(1000);
106 | const transaction = db.transaction((nodes: ParsedNode[]) => {
107 | nodes.forEach(node => nodeRepo.saveNode(node));
108 | });
109 | transaction(nodes);
110 |
111 | // Simulate concurrent reads
112 | const readOperations = 100;
113 | const promises: Promise<any>[] = [];
114 |
115 | const stop = monitor.start('concurrent_reads');
116 |
117 | for (let i = 0; i < readOperations; i++) {
118 | promises.push(
119 | Promise.resolve(nodeRepo.getNode(`n8n-nodes-base.node${i % 1000}`))
120 | );
121 | }
122 |
123 | Promise.all(promises);
124 | stop();
125 |
126 | const stats = monitor.getStats('concurrent_reads');
127 | const threshold = process.env.CI ? 200 : 100;
128 | expect(stats!.average).toBeLessThan(threshold);
129 |
130 | // Average per read should be very low
131 | const avgPerRead = stats!.average / readOperations;
132 | const perReadThreshold = process.env.CI ? 2 : 1;
133 | expect(avgPerRead).toBeLessThan(perReadThreshold);
134 | });
135 | });
136 |
137 | describe('Template Repository Performance with FTS5', () => {
138 | it('should perform FTS5 searches efficiently', () => {
139 | // Insert templates with varied content
140 | const templates = Array.from({ length: 10000 }, (_, i) => {
141 | const workflow: TemplateWorkflow = {
142 | id: i + 1,
143 | name: `${['Webhook', 'HTTP', 'Automation', 'Data Processing'][i % 4]} Workflow ${i}`,
144 | description: generateDescription(i),
145 | totalViews: Math.floor(Math.random() * 1000),
146 | createdAt: new Date().toISOString(),
147 | user: {
148 | id: 1,
149 | name: 'Test User',
150 | username: 'user',
151 | verified: false
152 | },
153 | nodes: [
154 | {
155 | id: i * 10 + 1,
156 | name: 'Start',
157 | icon: 'webhook'
158 | }
159 | ]
160 | };
161 |
162 | const detail: TemplateDetail = {
163 | id: i + 1,
164 | name: workflow.name,
165 | description: workflow.description || '',
166 | views: workflow.totalViews,
167 | createdAt: workflow.createdAt,
168 | workflow: {
169 | nodes: workflow.nodes,
170 | connections: {},
171 | settings: {}
172 | }
173 | };
174 |
175 | return { workflow, detail };
176 | });
177 |
178 | const stop1 = monitor.start('insert_templates_with_fts');
179 | const transaction = db.transaction((items: any[]) => {
180 | items.forEach(({ workflow, detail }) => {
181 | templateRepo.saveTemplate(workflow, detail);
182 | });
183 | });
184 | transaction(templates);
185 | stop1();
186 |
187 | // Ensure FTS index is built
188 | db.prepare('INSERT INTO templates_fts(templates_fts) VALUES(\'rebuild\')').run();
189 |
190 | // Test various FTS5 searches - use lowercase queries since FTS5 with quotes is case-sensitive
191 | const searchTests = [
192 | 'webhook',
193 | 'data',
194 | 'automation',
195 | 'http',
196 | 'workflow',
197 | 'processing'
198 | ];
199 |
200 | searchTests.forEach(query => {
201 | const stop = monitor.start(`fts5_search_${query}`);
202 | const results = templateRepo.searchTemplates(query, 100);
203 | stop();
204 |
205 | // Debug output
206 | if (results.length === 0) {
207 | console.log(`No results for query: ${query}`);
208 | // Try to understand what's in the database
209 | const count = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
210 | console.log(`Total templates in DB: ${count.count}`);
211 | }
212 |
213 | expect(results.length).toBeGreaterThan(0);
214 | });
215 |
216 | // All FTS5 searches should be very fast
217 | searchTests.forEach(query => {
218 | const stats = monitor.getStats(`fts5_search_${query}`);
219 | const threshold = process.env.CI ? 50 : 30;
220 | expect(stats!.average).toBeLessThan(threshold);
221 | });
222 | });
223 |
224 | it('should handle complex node type searches efficiently', () => {
225 | // Insert templates with various node combinations
226 | const nodeTypes = [
227 | 'n8n-nodes-base.webhook',
228 | 'n8n-nodes-base.httpRequest',
229 | 'n8n-nodes-base.slack',
230 | 'n8n-nodes-base.googleSheets',
231 | 'n8n-nodes-base.mongodb'
232 | ];
233 |
234 | const templates = Array.from({ length: 5000 }, (_, i) => {
235 | const workflow: TemplateWorkflow = {
236 | id: i + 1,
237 | name: `Template ${i}`,
238 | description: `Template description ${i}`,
239 | totalViews: 100,
240 | createdAt: new Date().toISOString(),
241 | user: {
242 | id: 1,
243 | name: 'Test User',
244 | username: 'user',
245 | verified: false
246 | },
247 | nodes: []
248 | };
249 |
250 | const detail: TemplateDetail = {
251 | id: i + 1,
252 | name: `Template ${i}`,
253 | description: `Template description ${i}`,
254 | views: 100,
255 | createdAt: new Date().toISOString(),
256 | workflow: {
257 | nodes: Array.from({ length: 3 }, (_, j) => ({
258 | id: `node${j}`,
259 | name: `Node ${j}`,
260 | type: nodeTypes[(i + j) % nodeTypes.length],
261 | typeVersion: 1,
262 | position: [100 * j, 100],
263 | parameters: {}
264 | })),
265 | connections: {},
266 | settings: {}
267 | }
268 | };
269 |
270 | return { workflow, detail };
271 | });
272 |
273 | const insertTransaction = db.transaction((items: any[]) => {
274 | items.forEach(({ workflow, detail }) => templateRepo.saveTemplate(workflow, detail));
275 | });
276 | insertTransaction(templates);
277 |
278 | // Test searching by node types
279 | const stop = monitor.start('search_by_node_types');
280 | const results = templateRepo.getTemplatesByNodes([
281 | 'n8n-nodes-base.webhook',
282 | 'n8n-nodes-base.slack'
283 | ], 100);
284 | stop();
285 |
286 | expect(results.length).toBeGreaterThan(0);
287 |
288 | const stats = monitor.getStats('search_by_node_types');
289 | const threshold = process.env.CI ? 100 : 50;
290 | expect(stats!.average).toBeLessThan(threshold);
291 | });
292 | });
293 |
294 | describe('Database Optimization', () => {
295 | it('should benefit from proper indexing', () => {
296 | // Insert more data to make index benefits more apparent
297 | const nodes = generateNodes(10000);
298 | const transaction = db.transaction((nodes: ParsedNode[]) => {
299 | nodes.forEach(node => nodeRepo.saveNode(node));
300 | });
301 | transaction(nodes);
302 |
303 | // Verify indexes exist
304 | const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='nodes'").all() as { name: string }[];
305 | const indexNames = indexes.map(idx => idx.name);
306 | expect(indexNames).toContain('idx_package');
307 | expect(indexNames).toContain('idx_category');
308 | expect(indexNames).toContain('idx_ai_tool');
309 |
310 | // Test queries that use indexes
311 | const indexedQueries = [
312 | {
313 | name: 'package_query',
314 | query: () => nodeRepo.getNodesByPackage('n8n-nodes-base'),
315 | column: 'package_name'
316 | },
317 | {
318 | name: 'category_query',
319 | query: () => nodeRepo.getNodesByCategory('trigger'),
320 | column: 'category'
321 | },
322 | {
323 | name: 'ai_tools_query',
324 | query: () => nodeRepo.getAITools(),
325 | column: 'is_ai_tool'
326 | }
327 | ];
328 |
329 | // Test indexed queries
330 | indexedQueries.forEach(({ name, query, column }) => {
331 | // Verify query plan uses index
332 | const plan = db.prepare(`EXPLAIN QUERY PLAN SELECT * FROM nodes WHERE ${column} = ?`).all('test') as any[];
333 | const usesIndex = plan.some(row =>
334 | row.detail && (row.detail.includes('USING INDEX') || row.detail.includes('USING COVERING INDEX'))
335 | );
336 |
337 | // For simple queries on small datasets, SQLite might choose full table scan
338 | // This is expected behavior and doesn't indicate a problem
339 | if (!usesIndex && process.env.CI) {
340 | console.log(`Note: Query on ${column} may not use index with small dataset (SQLite optimizer decision)`);
341 | }
342 |
343 | const stop = monitor.start(name);
344 | const results = query();
345 | stop();
346 |
347 | expect(Array.isArray(results)).toBe(true);
348 | });
349 |
350 | // All queries should be fast regardless of index usage
351 | // SQLite's query optimizer makes intelligent decisions
352 | indexedQueries.forEach(({ name }) => {
353 | const stats = monitor.getStats(name);
354 | // Environment-aware thresholds - CI is slower
355 | const threshold = process.env.CI ? 100 : 50;
356 | expect(stats!.average).toBeLessThan(threshold);
357 | });
358 |
359 | // Test a non-indexed query for comparison (description column has no index)
360 | const stop = monitor.start('non_indexed_query');
361 | const nonIndexedResults = db.prepare("SELECT * FROM nodes WHERE description LIKE ?").all('%webhook%') as any[];
362 | stop();
363 |
364 | const nonIndexedStats = monitor.getStats('non_indexed_query');
365 |
366 | // Non-indexed queries should still complete reasonably fast with 10k rows
367 | const nonIndexedThreshold = process.env.CI ? 200 : 100;
368 | expect(nonIndexedStats!.average).toBeLessThan(nonIndexedThreshold);
369 | });
370 |
371 | it('should handle VACUUM operation efficiently', () => {
372 | // Insert and delete data to create fragmentation
373 | const nodes = generateNodes(1000);
374 |
375 | // Insert
376 | const insertTx = db.transaction((nodes: ParsedNode[]) => {
377 | nodes.forEach(node => nodeRepo.saveNode(node));
378 | });
379 | insertTx(nodes);
380 |
381 | // Delete half
382 | db.prepare('DELETE FROM nodes WHERE ROWID % 2 = 0').run();
383 |
384 | // Measure VACUUM performance
385 | const stop = monitor.start('vacuum');
386 | db.exec('VACUUM');
387 | stop();
388 |
389 | const stats = monitor.getStats('vacuum');
390 | const threshold = process.env.CI ? 2000 : 1000;
391 | expect(stats!.average).toBeLessThan(threshold);
392 |
393 | // Verify database still works
394 | const remaining = nodeRepo.getAllNodes();
395 | expect(remaining.length).toBeGreaterThan(0);
396 | });
397 |
398 | it('should maintain performance with WAL mode', () => {
399 | // Verify WAL mode is enabled
400 | const mode = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
401 | expect(mode.journal_mode).toBe('wal');
402 |
403 | // Perform mixed read/write operations
404 | const operations = 1000;
405 |
406 | const stop = monitor.start('wal_mixed_operations');
407 |
408 | for (let i = 0; i < operations; i++) {
409 | if (i % 10 === 0) {
410 | // Write operation
411 | const node = generateNodes(1)[0];
412 | nodeRepo.saveNode(node);
413 | } else {
414 | // Read operation
415 | nodeRepo.getAllNodes(10);
416 | }
417 | }
418 |
419 | stop();
420 |
421 | const stats = monitor.getStats('wal_mixed_operations');
422 | const threshold = process.env.CI ? 1000 : 500;
423 | expect(stats!.average).toBeLessThan(threshold);
424 | });
425 | });
426 |
427 | describe('Memory Usage', () => {
428 | it('should handle large result sets without excessive memory', () => {
429 | // Insert large dataset
430 | const nodes = generateNodes(10000);
431 | const transaction = db.transaction((nodes: ParsedNode[]) => {
432 | nodes.forEach(node => nodeRepo.saveNode(node));
433 | });
434 | transaction(nodes);
435 |
436 | // Measure memory before
437 | const memBefore = process.memoryUsage().heapUsed;
438 |
439 | // Fetch large result set
440 | const stop = monitor.start('large_result_set');
441 | const results = nodeRepo.getAllNodes();
442 | stop();
443 |
444 | // Measure memory after
445 | const memAfter = process.memoryUsage().heapUsed;
446 | const memIncrease = (memAfter - memBefore) / 1024 / 1024; // MB
447 |
448 | expect(results).toHaveLength(10000);
449 | expect(memIncrease).toBeLessThan(100); // Less than 100MB increase
450 |
451 | const stats = monitor.getStats('large_result_set');
452 | const threshold = process.env.CI ? 400 : 200;
453 | expect(stats!.average).toBeLessThan(threshold);
454 | });
455 | });
456 |
457 | describe('Concurrent Write Performance', () => {
458 | it('should handle concurrent writes with transactions', () => {
459 | const writeBatches = 10;
460 | const nodesPerBatch = 100;
461 |
462 | const stop = monitor.start('concurrent_writes');
463 |
464 | // Simulate concurrent write batches
465 | const promises = Array.from({ length: writeBatches }, (_, i) => {
466 | return new Promise<void>((resolve) => {
467 | const nodes = generateNodes(nodesPerBatch, i * nodesPerBatch);
468 | const transaction = db.transaction((nodes: ParsedNode[]) => {
469 | nodes.forEach(node => nodeRepo.saveNode(node));
470 | });
471 | transaction(nodes);
472 | resolve();
473 | });
474 | });
475 |
476 | Promise.all(promises);
477 | stop();
478 |
479 | const stats = monitor.getStats('concurrent_writes');
480 | const threshold = process.env.CI ? 1000 : 500;
481 | expect(stats!.average).toBeLessThan(threshold);
482 |
483 | // Verify all nodes were written
484 | const count = nodeRepo.getNodeCount();
485 | expect(count).toBe(writeBatches * nodesPerBatch);
486 | });
487 | });
488 | });
489 |
490 | // Helper functions
491 | function generateNodes(count: number, startId: number = 0): ParsedNode[] {
492 | const categories = ['trigger', 'automation', 'transform', 'output'];
493 | const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'];
494 |
495 | return Array.from({ length: count }, (_, i) => ({
496 | nodeType: `n8n-nodes-base.node${startId + i}`,
497 | packageName: packages[i % packages.length],
498 | displayName: `Node ${startId + i}`,
499 | description: `Description for node ${startId + i} with ${['webhook', 'http', 'automation', 'data'][i % 4]} functionality`,
500 | category: categories[i % categories.length],
501 | style: 'programmatic' as const,
502 | isAITool: i % 10 === 0,
503 | isTrigger: categories[i % categories.length] === 'trigger',
504 | isWebhook: i % 5 === 0,
505 | isVersioned: true,
506 | version: '1',
507 | documentation: i % 3 === 0 ? `Documentation for node ${i}` : undefined,
508 | properties: Array.from({ length: 5 }, (_, j) => ({
509 | displayName: `Property ${j}`,
510 | name: `prop${j}`,
511 | type: 'string',
512 | default: ''
513 | })),
514 | operations: [],
515 | credentials: i % 4 === 0 ? [{ name: 'httpAuth', required: true }] : [],
516 | // Add fullNodeType for search compatibility
517 | fullNodeType: `n8n-nodes-base.node${startId + i}`
518 | }));
519 | }
520 |
521 | function generateDescription(index: number): string {
522 | const descriptions = [
523 | 'Automate your workflow with powerful webhook integrations',
524 | 'Process http requests and transform data efficiently',
525 | 'Connect to external APIs and sync data seamlessly',
526 | 'Build complex automation workflows with ease',
527 | 'Transform and filter data with advanced processing operations'
528 | ];
529 | return descriptions[index % descriptions.length] + ` - Version ${index}`;
530 | }
531 |
532 | // Generate nodes with searchable content for search tests
533 | function generateSearchableNodes(count: number): ParsedNode[] {
534 | const searchTerms = ['webhook', 'http', 'request', 'automation', 'data', 'HTTP'];
535 | const categories = ['trigger', 'automation', 'transform', 'output'];
536 | const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'];
537 |
538 | return Array.from({ length: count }, (_, i) => {
539 | // Ensure some nodes match our search terms
540 | const termIndex = i % searchTerms.length;
541 | const searchTerm = searchTerms[termIndex];
542 |
543 | return {
544 | nodeType: `n8n-nodes-base.${searchTerm}Node${i}`,
545 | packageName: packages[i % packages.length],
546 | displayName: `${searchTerm} Node ${i}`,
547 | description: `${searchTerm} functionality for ${searchTerms[(i + 1) % searchTerms.length]} operations`,
548 | category: categories[i % categories.length],
549 | style: 'programmatic' as const,
550 | isAITool: i % 10 === 0,
551 | isTrigger: categories[i % categories.length] === 'trigger',
552 | isWebhook: searchTerm === 'webhook' || i % 5 === 0,
553 | isVersioned: true,
554 | version: '1',
555 | documentation: i % 3 === 0 ? `Documentation for ${searchTerm} node ${i}` : undefined,
556 | properties: Array.from({ length: 5 }, (_, j) => ({
557 | displayName: `Property ${j}`,
558 | name: `prop${j}`,
559 | type: 'string',
560 | default: ''
561 | })),
562 | operations: [],
563 | credentials: i % 4 === 0 ? [{ name: 'httpAuth', required: true }] : []
564 | };
565 | });
566 | }
```
--------------------------------------------------------------------------------
/tests/unit/services/enhanced-config-validator-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2 | import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
3 | import { ResourceSimilarityService } from '@/services/resource-similarity-service';
4 | import { OperationSimilarityService } from '@/services/operation-similarity-service';
5 | import { NodeRepository } from '@/database/node-repository';
6 |
7 | // Mock similarity services
8 | vi.mock('@/services/resource-similarity-service');
9 | vi.mock('@/services/operation-similarity-service');
10 |
11 | describe('EnhancedConfigValidator - Integration Tests', () => {
12 | let mockResourceService: any;
13 | let mockOperationService: any;
14 | let mockRepository: any;
15 |
16 | beforeEach(() => {
17 | mockRepository = {
18 | getNode: vi.fn(),
19 | getNodeOperations: vi.fn().mockReturnValue([]),
20 | getNodeResources: vi.fn().mockReturnValue([]),
21 | getOperationsForResource: vi.fn().mockReturnValue([]),
22 | getDefaultOperationForResource: vi.fn().mockReturnValue(undefined),
23 | getNodePropertyDefaults: vi.fn().mockReturnValue({})
24 | };
25 |
26 | mockResourceService = {
27 | findSimilarResources: vi.fn().mockReturnValue([])
28 | };
29 |
30 | mockOperationService = {
31 | findSimilarOperations: vi.fn().mockReturnValue([])
32 | };
33 |
34 | // Mock the constructors to return our mock services
35 | vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService);
36 | vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService);
37 |
38 | // Initialize the similarity services (this will create the service instances)
39 | EnhancedConfigValidator.initializeSimilarityServices(mockRepository);
40 | });
41 |
42 | afterEach(() => {
43 | vi.clearAllMocks();
44 | });
45 |
46 | describe('similarity service integration', () => {
47 | it('should initialize similarity services when initializeSimilarityServices is called', () => {
48 | // Services should be created when initializeSimilarityServices was called in beforeEach
49 | expect(ResourceSimilarityService).toHaveBeenCalled();
50 | expect(OperationSimilarityService).toHaveBeenCalled();
51 | });
52 |
53 | it('should use resource similarity service for invalid resource errors', () => {
54 | const config = {
55 | resource: 'invalidResource',
56 | operation: 'send'
57 | };
58 |
59 | const properties = [
60 | {
61 | name: 'resource',
62 | type: 'options',
63 | required: true,
64 | options: [
65 | { value: 'message', name: 'Message' },
66 | { value: 'channel', name: 'Channel' }
67 | ]
68 | },
69 | {
70 | name: 'operation',
71 | type: 'options',
72 | required: true,
73 | displayOptions: {
74 | show: {
75 | resource: ['message']
76 | }
77 | },
78 | options: [
79 | { value: 'send', name: 'Send Message' }
80 | ]
81 | }
82 | ];
83 |
84 | // Mock resource similarity suggestions
85 | mockResourceService.findSimilarResources.mockReturnValue([
86 | {
87 | value: 'message',
88 | confidence: 0.8,
89 | reason: 'Similar resource name',
90 | availableOperations: ['send', 'update']
91 | }
92 | ]);
93 |
94 | const result = EnhancedConfigValidator.validateWithMode(
95 | 'nodes-base.slack',
96 | config,
97 | properties,
98 | 'operation',
99 | 'ai-friendly'
100 | );
101 |
102 | expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith(
103 | 'nodes-base.slack',
104 | 'invalidResource',
105 | expect.any(Number)
106 | );
107 |
108 | // Should have suggestions in the result
109 | expect(result.suggestions).toBeDefined();
110 | expect(result.suggestions.length).toBeGreaterThan(0);
111 | });
112 |
113 | it('should use operation similarity service for invalid operation errors', () => {
114 | const config = {
115 | resource: 'message',
116 | operation: 'invalidOperation'
117 | };
118 |
119 | const properties = [
120 | {
121 | name: 'resource',
122 | type: 'options',
123 | required: true,
124 | options: [
125 | { value: 'message', name: 'Message' }
126 | ]
127 | },
128 | {
129 | name: 'operation',
130 | type: 'options',
131 | required: true,
132 | displayOptions: {
133 | show: {
134 | resource: ['message']
135 | }
136 | },
137 | options: [
138 | { value: 'send', name: 'Send Message' },
139 | { value: 'update', name: 'Update Message' }
140 | ]
141 | }
142 | ];
143 |
144 | // Mock operation similarity suggestions
145 | mockOperationService.findSimilarOperations.mockReturnValue([
146 | {
147 | value: 'send',
148 | confidence: 0.9,
149 | reason: 'Very similar - likely a typo',
150 | resource: 'message'
151 | }
152 | ]);
153 |
154 | const result = EnhancedConfigValidator.validateWithMode(
155 | 'nodes-base.slack',
156 | config,
157 | properties,
158 | 'operation',
159 | 'ai-friendly'
160 | );
161 |
162 | expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith(
163 | 'nodes-base.slack',
164 | 'invalidOperation',
165 | 'message',
166 | expect.any(Number)
167 | );
168 |
169 | // Should have suggestions in the result
170 | expect(result.suggestions).toBeDefined();
171 | expect(result.suggestions.length).toBeGreaterThan(0);
172 | });
173 |
174 | it('should handle similarity service errors gracefully', () => {
175 | const config = {
176 | resource: 'invalidResource',
177 | operation: 'send'
178 | };
179 |
180 | const properties = [
181 | {
182 | name: 'resource',
183 | type: 'options',
184 | required: true,
185 | options: [
186 | { value: 'message', name: 'Message' }
187 | ]
188 | }
189 | ];
190 |
191 | // Mock service to throw error
192 | mockResourceService.findSimilarResources.mockImplementation(() => {
193 | throw new Error('Service error');
194 | });
195 |
196 | const result = EnhancedConfigValidator.validateWithMode(
197 | 'nodes-base.slack',
198 | config,
199 | properties,
200 | 'operation',
201 | 'ai-friendly'
202 | );
203 |
204 | // Should not crash and still provide basic validation
205 | expect(result).toBeDefined();
206 | expect(result.valid).toBe(false);
207 | expect(result.errors.length).toBeGreaterThan(0);
208 | });
209 |
210 | it('should not call similarity services for valid configurations', () => {
211 | // Mock repository to return valid resources for this test
212 | mockRepository.getNodeResources.mockReturnValue([
213 | { value: 'message', name: 'Message' },
214 | { value: 'channel', name: 'Channel' }
215 | ]);
216 | // Mock getNodeOperations to return valid operations
217 | mockRepository.getNodeOperations.mockReturnValue([
218 | { value: 'send', name: 'Send Message' }
219 | ]);
220 |
221 | const config = {
222 | resource: 'message',
223 | operation: 'send',
224 | channel: '#general', // Add required field for Slack send
225 | text: 'Test message' // Add required field for Slack send
226 | };
227 |
228 | const properties = [
229 | {
230 | name: 'resource',
231 | type: 'options',
232 | required: true,
233 | options: [
234 | { value: 'message', name: 'Message' }
235 | ]
236 | },
237 | {
238 | name: 'operation',
239 | type: 'options',
240 | required: true,
241 | displayOptions: {
242 | show: {
243 | resource: ['message']
244 | }
245 | },
246 | options: [
247 | { value: 'send', name: 'Send Message' }
248 | ]
249 | }
250 | ];
251 |
252 | const result = EnhancedConfigValidator.validateWithMode(
253 | 'nodes-base.slack',
254 | config,
255 | properties,
256 | 'operation',
257 | 'ai-friendly'
258 | );
259 |
260 | // Should not call similarity services for valid config
261 | expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled();
262 | expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled();
263 | expect(result.valid).toBe(true);
264 | });
265 |
266 | it('should limit suggestion count when calling similarity services', () => {
267 | const config = {
268 | resource: 'invalidResource'
269 | };
270 |
271 | const properties = [
272 | {
273 | name: 'resource',
274 | type: 'options',
275 | required: true,
276 | options: [
277 | { value: 'message', name: 'Message' }
278 | ]
279 | }
280 | ];
281 |
282 | EnhancedConfigValidator.validateWithMode(
283 | 'nodes-base.slack',
284 | config,
285 | properties,
286 | 'operation',
287 | 'ai-friendly'
288 | );
289 |
290 | expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith(
291 | 'nodes-base.slack',
292 | 'invalidResource',
293 | 3 // Should limit to 3 suggestions
294 | );
295 | });
296 | });
297 |
298 | describe('error enhancement with suggestions', () => {
299 | it('should enhance resource validation errors with suggestions', () => {
300 | const config = {
301 | resource: 'msgs' // Typo for 'message'
302 | };
303 |
304 | const properties = [
305 | {
306 | name: 'resource',
307 | type: 'options',
308 | required: true,
309 | options: [
310 | { value: 'message', name: 'Message' },
311 | { value: 'channel', name: 'Channel' }
312 | ]
313 | }
314 | ];
315 |
316 | // Mock high-confidence suggestion
317 | mockResourceService.findSimilarResources.mockReturnValue([
318 | {
319 | value: 'message',
320 | confidence: 0.85,
321 | reason: 'Very similar - likely a typo',
322 | availableOperations: ['send', 'update', 'delete']
323 | }
324 | ]);
325 |
326 | const result = EnhancedConfigValidator.validateWithMode(
327 | 'nodes-base.slack',
328 | config,
329 | properties,
330 | 'operation',
331 | 'ai-friendly'
332 | );
333 |
334 | // Should have enhanced error with suggestion
335 | const resourceError = result.errors.find(e => e.property === 'resource');
336 | expect(resourceError).toBeDefined();
337 | expect(resourceError!.suggestion).toBeDefined();
338 | expect(resourceError!.suggestion).toContain('message');
339 | });
340 |
341 | it('should enhance operation validation errors with suggestions', () => {
342 | const config = {
343 | resource: 'message',
344 | operation: 'sned' // Typo for 'send'
345 | };
346 |
347 | const properties = [
348 | {
349 | name: 'resource',
350 | type: 'options',
351 | required: true,
352 | options: [
353 | { value: 'message', name: 'Message' }
354 | ]
355 | },
356 | {
357 | name: 'operation',
358 | type: 'options',
359 | required: true,
360 | displayOptions: {
361 | show: {
362 | resource: ['message']
363 | }
364 | },
365 | options: [
366 | { value: 'send', name: 'Send Message' },
367 | { value: 'update', name: 'Update Message' }
368 | ]
369 | }
370 | ];
371 |
372 | // Mock high-confidence suggestion
373 | mockOperationService.findSimilarOperations.mockReturnValue([
374 | {
375 | value: 'send',
376 | confidence: 0.9,
377 | reason: 'Almost exact match - likely a typo',
378 | resource: 'message',
379 | description: 'Send Message'
380 | }
381 | ]);
382 |
383 | const result = EnhancedConfigValidator.validateWithMode(
384 | 'nodes-base.slack',
385 | config,
386 | properties,
387 | 'operation',
388 | 'ai-friendly'
389 | );
390 |
391 | // Should have enhanced error with suggestion
392 | const operationError = result.errors.find(e => e.property === 'operation');
393 | expect(operationError).toBeDefined();
394 | expect(operationError!.suggestion).toBeDefined();
395 | expect(operationError!.suggestion).toContain('send');
396 | });
397 |
398 | it('should not enhance errors when no good suggestions are available', () => {
399 | const config = {
400 | resource: 'completelyWrongValue'
401 | };
402 |
403 | const properties = [
404 | {
405 | name: 'resource',
406 | type: 'options',
407 | required: true,
408 | options: [
409 | { value: 'message', name: 'Message' }
410 | ]
411 | }
412 | ];
413 |
414 | // Mock low-confidence suggestions
415 | mockResourceService.findSimilarResources.mockReturnValue([
416 | {
417 | value: 'message',
418 | confidence: 0.2, // Too low confidence
419 | reason: 'Possibly related resource'
420 | }
421 | ]);
422 |
423 | const result = EnhancedConfigValidator.validateWithMode(
424 | 'nodes-base.slack',
425 | config,
426 | properties,
427 | 'operation',
428 | 'ai-friendly'
429 | );
430 |
431 | // Should not enhance error due to low confidence
432 | const resourceError = result.errors.find(e => e.property === 'resource');
433 | expect(resourceError).toBeDefined();
434 | expect(resourceError!.suggestion).toBeUndefined();
435 | });
436 |
437 | it('should provide multiple operation suggestions when resource is known', () => {
438 | const config = {
439 | resource: 'message',
440 | operation: 'invalidOp'
441 | };
442 |
443 | const properties = [
444 | {
445 | name: 'resource',
446 | type: 'options',
447 | required: true,
448 | options: [
449 | { value: 'message', name: 'Message' }
450 | ]
451 | },
452 | {
453 | name: 'operation',
454 | type: 'options',
455 | required: true,
456 | displayOptions: {
457 | show: {
458 | resource: ['message']
459 | }
460 | },
461 | options: [
462 | { value: 'send', name: 'Send Message' },
463 | { value: 'update', name: 'Update Message' },
464 | { value: 'delete', name: 'Delete Message' }
465 | ]
466 | }
467 | ];
468 |
469 | // Mock multiple suggestions
470 | mockOperationService.findSimilarOperations.mockReturnValue([
471 | { value: 'send', confidence: 0.7, reason: 'Similar operation' },
472 | { value: 'update', confidence: 0.6, reason: 'Similar operation' },
473 | { value: 'delete', confidence: 0.5, reason: 'Similar operation' }
474 | ]);
475 |
476 | const result = EnhancedConfigValidator.validateWithMode(
477 | 'nodes-base.slack',
478 | config,
479 | properties,
480 | 'operation',
481 | 'ai-friendly'
482 | );
483 |
484 | // Should include multiple suggestions in the result
485 | expect(result.suggestions.length).toBeGreaterThan(2);
486 | const operationSuggestions = result.suggestions.filter(s =>
487 | s.includes('send') || s.includes('update') || s.includes('delete')
488 | );
489 | expect(operationSuggestions.length).toBeGreaterThan(0);
490 | });
491 | });
492 |
493 | describe('confidence thresholds and filtering', () => {
494 | it('should only use high confidence resource suggestions', () => {
495 | const config = {
496 | resource: 'invalidResource'
497 | };
498 |
499 | const properties = [
500 | {
501 | name: 'resource',
502 | type: 'options',
503 | required: true,
504 | options: [
505 | { value: 'message', name: 'Message' }
506 | ]
507 | }
508 | ];
509 |
510 | // Mock mixed confidence suggestions
511 | mockResourceService.findSimilarResources.mockReturnValue([
512 | { value: 'message1', confidence: 0.9, reason: 'High confidence' },
513 | { value: 'message2', confidence: 0.4, reason: 'Low confidence' },
514 | { value: 'message3', confidence: 0.7, reason: 'Medium confidence' }
515 | ]);
516 |
517 | const result = EnhancedConfigValidator.validateWithMode(
518 | 'nodes-base.slack',
519 | config,
520 | properties,
521 | 'operation',
522 | 'ai-friendly'
523 | );
524 |
525 | // Should only use suggestions above threshold
526 | const resourceError = result.errors.find(e => e.property === 'resource');
527 | expect(resourceError?.suggestion).toBeDefined();
528 | // Should prefer high confidence suggestion
529 | expect(resourceError!.suggestion).toContain('message1');
530 | });
531 |
532 | it('should only use high confidence operation suggestions', () => {
533 | const config = {
534 | resource: 'message',
535 | operation: 'invalidOperation'
536 | };
537 |
538 | const properties = [
539 | {
540 | name: 'resource',
541 | type: 'options',
542 | required: true,
543 | options: [
544 | { value: 'message', name: 'Message' }
545 | ]
546 | },
547 | {
548 | name: 'operation',
549 | type: 'options',
550 | required: true,
551 | displayOptions: {
552 | show: {
553 | resource: ['message']
554 | }
555 | },
556 | options: [
557 | { value: 'send', name: 'Send Message' }
558 | ]
559 | }
560 | ];
561 |
562 | // Mock mixed confidence suggestions
563 | mockOperationService.findSimilarOperations.mockReturnValue([
564 | { value: 'send', confidence: 0.95, reason: 'Very high confidence' },
565 | { value: 'post', confidence: 0.3, reason: 'Low confidence' }
566 | ]);
567 |
568 | const result = EnhancedConfigValidator.validateWithMode(
569 | 'nodes-base.slack',
570 | config,
571 | properties,
572 | 'operation',
573 | 'ai-friendly'
574 | );
575 |
576 | // Should only use high confidence suggestion
577 | const operationError = result.errors.find(e => e.property === 'operation');
578 | expect(operationError?.suggestion).toBeDefined();
579 | expect(operationError!.suggestion).toContain('send');
580 | expect(operationError!.suggestion).not.toContain('post');
581 | });
582 | });
583 |
584 | describe('integration with existing validation logic', () => {
585 | it('should work with minimal validation mode', () => {
586 | // Mock repository to return empty resources
587 | mockRepository.getNodeResources.mockReturnValue([]);
588 |
589 | const config = {
590 | resource: 'invalidResource'
591 | };
592 |
593 | const properties = [
594 | {
595 | name: 'resource',
596 | type: 'options',
597 | required: true,
598 | options: [
599 | { value: 'message', name: 'Message' }
600 | ]
601 | }
602 | ];
603 |
604 | mockResourceService.findSimilarResources.mockReturnValue([
605 | { value: 'message', confidence: 0.8, reason: 'Similar' }
606 | ]);
607 |
608 | const result = EnhancedConfigValidator.validateWithMode(
609 | 'nodes-base.slack',
610 | config,
611 | properties,
612 | 'minimal',
613 | 'ai-friendly'
614 | );
615 |
616 | // Should still enhance errors in minimal mode
617 | expect(mockResourceService.findSimilarResources).toHaveBeenCalled();
618 | expect(result.errors.length).toBeGreaterThan(0);
619 | });
620 |
621 | it('should work with strict validation profile', () => {
622 | // Mock repository to return valid resource but no operations
623 | mockRepository.getNodeResources.mockReturnValue([
624 | { value: 'message', name: 'Message' }
625 | ]);
626 | mockRepository.getOperationsForResource.mockReturnValue([]);
627 |
628 | const config = {
629 | resource: 'message',
630 | operation: 'invalidOp'
631 | };
632 |
633 | const properties = [
634 | {
635 | name: 'resource',
636 | type: 'options',
637 | required: true,
638 | options: [
639 | { value: 'message', name: 'Message' }
640 | ]
641 | },
642 | {
643 | name: 'operation',
644 | type: 'options',
645 | required: true,
646 | displayOptions: {
647 | show: {
648 | resource: ['message']
649 | }
650 | },
651 | options: [
652 | { value: 'send', name: 'Send Message' }
653 | ]
654 | }
655 | ];
656 |
657 | mockOperationService.findSimilarOperations.mockReturnValue([
658 | { value: 'send', confidence: 0.8, reason: 'Similar' }
659 | ]);
660 |
661 | const result = EnhancedConfigValidator.validateWithMode(
662 | 'nodes-base.slack',
663 | config,
664 | properties,
665 | 'operation',
666 | 'strict'
667 | );
668 |
669 | // Should enhance errors regardless of profile
670 | expect(mockOperationService.findSimilarOperations).toHaveBeenCalled();
671 | const operationError = result.errors.find(e => e.property === 'operation');
672 | expect(operationError?.suggestion).toBeDefined();
673 | });
674 |
675 | it('should preserve original error properties when enhancing', () => {
676 | const config = {
677 | resource: 'invalidResource'
678 | };
679 |
680 | const properties = [
681 | {
682 | name: 'resource',
683 | type: 'options',
684 | required: true,
685 | options: [
686 | { value: 'message', name: 'Message' }
687 | ]
688 | }
689 | ];
690 |
691 | mockResourceService.findSimilarResources.mockReturnValue([
692 | { value: 'message', confidence: 0.8, reason: 'Similar' }
693 | ]);
694 |
695 | const result = EnhancedConfigValidator.validateWithMode(
696 | 'nodes-base.slack',
697 | config,
698 | properties,
699 | 'operation',
700 | 'ai-friendly'
701 | );
702 |
703 | const resourceError = result.errors.find(e => e.property === 'resource');
704 |
705 | // Should preserve original error properties
706 | expect(resourceError?.type).toBeDefined();
707 | expect(resourceError?.property).toBe('resource');
708 | expect(resourceError?.message).toBeDefined();
709 |
710 | // Should add suggestion without overriding other properties
711 | expect(resourceError?.suggestion).toBeDefined();
712 | });
713 | });
714 | });
```