This is page 11 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-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 │ │ │ ├── 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-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 -------------------------------------------------------------------------------- /scripts/test-expression-format-validation.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Test script for expression format validation 5 | * Tests the validation of expression prefixes and resource locator formats 6 | */ 7 | 8 | const { WorkflowValidator } = require('../dist/services/workflow-validator.js'); 9 | const { NodeRepository } = require('../dist/database/node-repository.js'); 10 | const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js'); 11 | const { createDatabaseAdapter } = require('../dist/database/database-adapter.js'); 12 | const path = require('path'); 13 | 14 | async function runTests() { 15 | // Initialize database 16 | const dbPath = path.join(__dirname, '..', 'data', 'nodes.db'); 17 | const adapter = await createDatabaseAdapter(dbPath); 18 | const db = adapter; 19 | 20 | const nodeRepository = new NodeRepository(db); 21 | const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator); 22 | 23 | console.log('\n🧪 Testing Expression Format Validation\n'); 24 | console.log('=' .repeat(60)); 25 | 26 | // Test 1: Email node with missing = prefix 27 | console.log('\n📝 Test 1: Email Send node - Missing = prefix'); 28 | console.log('-'.repeat(40)); 29 | 30 | const emailWorkflowIncorrect = { 31 | nodes: [ 32 | { 33 | id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', 34 | name: 'Error Handler', 35 | type: 'n8n-nodes-base.emailSend', 36 | typeVersion: 2.1, 37 | position: [-128, 400], 38 | parameters: { 39 | fromEmail: '{{ $env.ADMIN_EMAIL }}', // INCORRECT - missing = 40 | toEmail: '[email protected]', 41 | subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', 42 | options: {} 43 | }, 44 | credentials: { 45 | smtp: { 46 | id: '7AQ08VMFHubmfvzR', 47 | name: '[email protected]' 48 | } 49 | } 50 | } 51 | ], 52 | connections: {} 53 | }; 54 | 55 | const result1 = await validator.validateWorkflow(emailWorkflowIncorrect); 56 | 57 | if (result1.errors.some(e => e.message.includes('Expression format'))) { 58 | console.log('✅ ERROR DETECTED (correct behavior):'); 59 | const formatError = result1.errors.find(e => e.message.includes('Expression format')); 60 | console.log('\n' + formatError.message); 61 | } else { 62 | console.log('❌ No expression format error detected (should have detected missing prefix)'); 63 | } 64 | 65 | // Test 2: Email node with correct = prefix 66 | console.log('\n📝 Test 2: Email Send node - Correct = prefix'); 67 | console.log('-'.repeat(40)); 68 | 69 | const emailWorkflowCorrect = { 70 | nodes: [ 71 | { 72 | id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', 73 | name: 'Error Handler', 74 | type: 'n8n-nodes-base.emailSend', 75 | typeVersion: 2.1, 76 | position: [-128, 400], 77 | parameters: { 78 | fromEmail: '={{ $env.ADMIN_EMAIL }}', // CORRECT - has = 79 | toEmail: '[email protected]', 80 | subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', 81 | options: {} 82 | } 83 | } 84 | ], 85 | connections: {} 86 | }; 87 | 88 | const result2 = await validator.validateWorkflow(emailWorkflowCorrect); 89 | 90 | if (result2.errors.some(e => e.message.includes('Expression format'))) { 91 | console.log('❌ Unexpected expression format error (should accept = prefix)'); 92 | } else { 93 | console.log('✅ No expression format errors (correct!)'); 94 | } 95 | 96 | // Test 3: GitHub node without resource locator format 97 | console.log('\n📝 Test 3: GitHub node - Missing resource locator format'); 98 | console.log('-'.repeat(40)); 99 | 100 | const githubWorkflowIncorrect = { 101 | nodes: [ 102 | { 103 | id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', 104 | name: 'Send Welcome Comment', 105 | type: 'n8n-nodes-base.github', 106 | typeVersion: 1.1, 107 | position: [-240, 96], 108 | parameters: { 109 | operation: 'createComment', 110 | owner: '{{ $vars.GITHUB_OWNER }}', // INCORRECT - needs RL format 111 | repository: '{{ $vars.GITHUB_REPO }}', // INCORRECT - needs RL format 112 | issueNumber: null, 113 | body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // INCORRECT - missing = 114 | }, 115 | credentials: { 116 | githubApi: { 117 | id: 'edgpwh6ldYN07MXx', 118 | name: 'GitHub account' 119 | } 120 | } 121 | } 122 | ], 123 | connections: {} 124 | }; 125 | 126 | const result3 = await validator.validateWorkflow(githubWorkflowIncorrect); 127 | 128 | const formatErrors = result3.errors.filter(e => e.message.includes('Expression format')); 129 | console.log(`\nFound ${formatErrors.length} expression format errors:`); 130 | 131 | if (formatErrors.length >= 3) { 132 | console.log('✅ All format issues detected:'); 133 | formatErrors.forEach((error, index) => { 134 | const field = error.message.match(/Field '([^']+)'/)?.[1] || 'unknown'; 135 | console.log(` ${index + 1}. Field '${field}' - ${error.message.includes('resource locator') ? 'Needs RL format' : 'Missing = prefix'}`); 136 | }); 137 | } else { 138 | console.log('❌ Not all format issues detected'); 139 | } 140 | 141 | // Test 4: GitHub node with correct resource locator format 142 | console.log('\n📝 Test 4: GitHub node - Correct resource locator format'); 143 | console.log('-'.repeat(40)); 144 | 145 | const githubWorkflowCorrect = { 146 | nodes: [ 147 | { 148 | id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', 149 | name: 'Send Welcome Comment', 150 | type: 'n8n-nodes-base.github', 151 | typeVersion: 1.1, 152 | position: [-240, 96], 153 | parameters: { 154 | operation: 'createComment', 155 | owner: { 156 | __rl: true, 157 | value: '={{ $vars.GITHUB_OWNER }}', // CORRECT - RL format with = 158 | mode: 'expression' 159 | }, 160 | repository: { 161 | __rl: true, 162 | value: '={{ $vars.GITHUB_REPO }}', // CORRECT - RL format with = 163 | mode: 'expression' 164 | }, 165 | issueNumber: 123, 166 | body: '=👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // CORRECT - has = 167 | } 168 | } 169 | ], 170 | connections: {} 171 | }; 172 | 173 | const result4 = await validator.validateWorkflow(githubWorkflowCorrect); 174 | 175 | const formatErrors4 = result4.errors.filter(e => e.message.includes('Expression format')); 176 | if (formatErrors4.length === 0) { 177 | console.log('✅ No expression format errors (correct!)'); 178 | } else { 179 | console.log(`❌ Unexpected expression format errors: ${formatErrors4.length}`); 180 | formatErrors4.forEach(e => console.log(' - ' + e.message.split('\n')[0])); 181 | } 182 | 183 | // Test 5: Mixed content expressions 184 | console.log('\n📝 Test 5: Mixed content with expressions'); 185 | console.log('-'.repeat(40)); 186 | 187 | const mixedContentWorkflow = { 188 | nodes: [ 189 | { 190 | id: '1', 191 | name: 'HTTP Request', 192 | type: 'n8n-nodes-base.httpRequest', 193 | typeVersion: 4, 194 | position: [0, 0], 195 | parameters: { 196 | url: 'https://api.example.com/users/{{ $json.userId }}', // INCORRECT 197 | headers: { 198 | 'Authorization': '=Bearer {{ $env.API_TOKEN }}' // CORRECT 199 | } 200 | } 201 | } 202 | ], 203 | connections: {} 204 | }; 205 | 206 | const result5 = await validator.validateWorkflow(mixedContentWorkflow); 207 | 208 | const urlError = result5.errors.find(e => e.message.includes('url') && e.message.includes('Expression format')); 209 | if (urlError) { 210 | console.log('✅ Mixed content error detected for URL field'); 211 | console.log(' Should be: "=https://api.example.com/users/{{ $json.userId }}"'); 212 | } else { 213 | console.log('❌ Mixed content error not detected'); 214 | } 215 | 216 | console.log('\n' + '='.repeat(60)); 217 | console.log('\n✨ Expression Format Validation Summary:'); 218 | console.log(' - Detects missing = prefix in expressions'); 219 | console.log(' - Identifies fields needing resource locator format'); 220 | console.log(' - Provides clear correction examples'); 221 | console.log(' - Handles mixed literal and expression content'); 222 | 223 | // Close database 224 | db.close(); 225 | } 226 | 227 | runTests().catch(error => { 228 | console.error('Test failed:', error); 229 | process.exit(1); 230 | }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/workflows/get-workflow-details.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Tests: handleGetWorkflowDetails 3 | * 4 | * Tests workflow details retrieval against a real n8n instance. 5 | * Covers basic workflows, metadata, version history, and execution stats. 6 | */ 7 | 8 | import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; 9 | import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context'; 10 | import { getTestN8nClient } from '../utils/n8n-client'; 11 | import { N8nApiClient } from '../../../../src/services/n8n-api-client'; 12 | import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures'; 13 | import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers'; 14 | import { createMcpContext } from '../utils/mcp-context'; 15 | import { InstanceContext } from '../../../../src/types/instance-context'; 16 | import { handleGetWorkflowDetails } from '../../../../src/mcp/handlers-n8n-manager'; 17 | 18 | describe('Integration: handleGetWorkflowDetails', () => { 19 | let context: TestContext; 20 | let client: N8nApiClient; 21 | let mcpContext: InstanceContext; 22 | 23 | beforeEach(() => { 24 | context = createTestContext(); 25 | client = getTestN8nClient(); 26 | mcpContext = createMcpContext(); 27 | }); 28 | 29 | afterEach(async () => { 30 | await context.cleanup(); 31 | }); 32 | 33 | afterAll(async () => { 34 | if (!process.env.CI) { 35 | await cleanupOrphanedWorkflows(); 36 | } 37 | }); 38 | 39 | // ====================================================================== 40 | // Basic Workflow Details 41 | // ====================================================================== 42 | 43 | describe('Basic Workflow', () => { 44 | it('should retrieve workflow with basic details', async () => { 45 | // Create a simple workflow 46 | const workflow = { 47 | ...SIMPLE_WEBHOOK_WORKFLOW, 48 | name: createTestWorkflowName('Get Details - Basic'), 49 | tags: ['mcp-integration-test'] 50 | }; 51 | 52 | const created = await client.createWorkflow(workflow); 53 | expect(created).toBeDefined(); 54 | expect(created.id).toBeTruthy(); 55 | 56 | if (!created.id) throw new Error('Workflow ID is missing'); 57 | context.trackWorkflow(created.id); 58 | 59 | // Retrieve detailed workflow information using MCP handler 60 | const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); 61 | 62 | // Verify MCP response structure 63 | expect(response.success).toBe(true); 64 | expect(response.data).toBeDefined(); 65 | 66 | // handleGetWorkflowDetails returns { workflow, executionStats, hasWebhookTrigger, webhookPath } 67 | const details = (response.data as any).workflow; 68 | 69 | // Verify basic details 70 | expect(details).toBeDefined(); 71 | expect(details.id).toBe(created.id); 72 | expect(details.name).toBe(workflow.name); 73 | expect(details.createdAt).toBeDefined(); 74 | expect(details.updatedAt).toBeDefined(); 75 | expect(details.active).toBeDefined(); 76 | 77 | // Verify metadata fields 78 | expect(details.versionId).toBeDefined(); 79 | }); 80 | }); 81 | 82 | // ====================================================================== 83 | // Workflow with Metadata 84 | // ====================================================================== 85 | 86 | describe('Workflow with Metadata', () => { 87 | it('should retrieve workflow with tags and settings metadata', async () => { 88 | // Create workflow with rich metadata 89 | const workflow = { 90 | ...SIMPLE_WEBHOOK_WORKFLOW, 91 | name: createTestWorkflowName('Get Details - With Metadata'), 92 | tags: [ 93 | 'mcp-integration-test', 94 | 'test-category', 95 | 'integration' 96 | ], 97 | settings: { 98 | executionOrder: 'v1' as const, 99 | timezone: 'America/New_York' 100 | } 101 | }; 102 | 103 | const created = await client.createWorkflow(workflow); 104 | expect(created).toBeDefined(); 105 | expect(created.id).toBeTruthy(); 106 | 107 | if (!created.id) throw new Error('Workflow ID is missing'); 108 | context.trackWorkflow(created.id); 109 | 110 | // Retrieve workflow details using MCP handler 111 | const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); 112 | expect(response.success).toBe(true); 113 | const details = (response.data as any).workflow; 114 | 115 | // Verify metadata is present (tags may be undefined in API response) 116 | // Note: n8n API behavior for tags varies - they may not be returned 117 | // in GET requests even if set during creation 118 | if (details.tags) { 119 | expect(details.tags.length).toBeGreaterThanOrEqual(0); 120 | } 121 | 122 | // Verify settings 123 | expect(details.settings).toBeDefined(); 124 | expect(details.settings!.executionOrder).toBe('v1'); 125 | expect(details.settings!.timezone).toBe('America/New_York'); 126 | }); 127 | }); 128 | 129 | // ====================================================================== 130 | // Version History 131 | // ====================================================================== 132 | 133 | describe('Version History', () => { 134 | it('should track version changes after updates', async () => { 135 | // Create initial workflow 136 | const workflow = { 137 | ...SIMPLE_WEBHOOK_WORKFLOW, 138 | name: createTestWorkflowName('Get Details - Version History'), 139 | tags: ['mcp-integration-test'] 140 | }; 141 | 142 | const created = await client.createWorkflow(workflow); 143 | expect(created).toBeDefined(); 144 | expect(created.id).toBeTruthy(); 145 | 146 | if (!created.id) throw new Error('Workflow ID is missing'); 147 | context.trackWorkflow(created.id); 148 | 149 | // Get initial version using MCP handler 150 | const initialResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext); 151 | expect(initialResponse.success).toBe(true); 152 | const initialDetails = (initialResponse.data as any).workflow; 153 | const initialVersionId = initialDetails.versionId; 154 | const initialUpdatedAt = initialDetails.updatedAt; 155 | 156 | // Update the workflow 157 | await client.updateWorkflow(created.id, { 158 | name: createTestWorkflowName('Get Details - Version History (Updated)'), 159 | nodes: workflow.nodes, 160 | connections: workflow.connections 161 | }); 162 | 163 | // Get updated details using MCP handler 164 | const updatedResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext); 165 | expect(updatedResponse.success).toBe(true); 166 | const updatedDetails = (updatedResponse.data as any).workflow; 167 | 168 | // Verify version changed 169 | expect(updatedDetails.versionId).toBeDefined(); 170 | expect(updatedDetails.updatedAt).not.toBe(initialUpdatedAt); 171 | 172 | // Version ID should have changed after update 173 | expect(updatedDetails.versionId).not.toBe(initialVersionId); 174 | }); 175 | }); 176 | 177 | // ====================================================================== 178 | // Execution Statistics 179 | // ====================================================================== 180 | 181 | describe('Execution Statistics', () => { 182 | it('should include execution-related fields in details', async () => { 183 | // Create workflow 184 | const workflow = { 185 | ...SIMPLE_WEBHOOK_WORKFLOW, 186 | name: createTestWorkflowName('Get Details - Execution Stats'), 187 | tags: ['mcp-integration-test'] 188 | }; 189 | 190 | const created = await client.createWorkflow(workflow); 191 | expect(created).toBeDefined(); 192 | expect(created.id).toBeTruthy(); 193 | 194 | if (!created.id) throw new Error('Workflow ID is missing'); 195 | context.trackWorkflow(created.id); 196 | 197 | // Retrieve workflow details using MCP handler 198 | const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext); 199 | expect(response.success).toBe(true); 200 | const details = (response.data as any).workflow; 201 | 202 | // Verify execution-related fields exist 203 | // Note: New workflows won't have executions, but fields should be present 204 | expect(details).toHaveProperty('active'); 205 | 206 | // The workflow should start inactive 207 | expect(details.active).toBe(false); 208 | }); 209 | }); 210 | }); 211 | ``` -------------------------------------------------------------------------------- /tests/unit/test-env-example.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Example test demonstrating test environment configuration usage 3 | */ 4 | 5 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 6 | import { 7 | getTestConfig, 8 | getTestTimeout, 9 | isFeatureEnabled, 10 | isTestMode, 11 | loadTestEnvironment 12 | } from '@tests/setup/test-env'; 13 | import { 14 | withEnvOverrides, 15 | createTestDatabasePath, 16 | getMockApiUrl, 17 | measurePerformance, 18 | createTestLogger, 19 | waitForCondition 20 | } from '@tests/helpers/env-helpers'; 21 | 22 | describe('Test Environment Configuration Example', () => { 23 | let config: ReturnType<typeof getTestConfig>; 24 | let logger: ReturnType<typeof createTestLogger>; 25 | 26 | beforeAll(() => { 27 | // Initialize config inside beforeAll to ensure environment is loaded 28 | config = getTestConfig(); 29 | logger = createTestLogger('test-env-example'); 30 | 31 | logger.info('Test suite starting with configuration:', { 32 | environment: config.nodeEnv, 33 | database: config.database.path, 34 | apiUrl: config.api.url 35 | }); 36 | }); 37 | 38 | afterAll(() => { 39 | logger.info('Test suite completed'); 40 | }); 41 | 42 | it('should be in test mode', () => { 43 | const testConfig = getTestConfig(); 44 | expect(isTestMode()).toBe(true); 45 | expect(testConfig.nodeEnv).toBe('test'); 46 | expect(testConfig.isTest).toBe(true); 47 | }); 48 | 49 | it('should have proper database configuration', () => { 50 | const testConfig = getTestConfig(); 51 | expect(testConfig.database.path).toBeDefined(); 52 | expect(testConfig.database.rebuildOnStart).toBe(false); 53 | expect(testConfig.database.seedData).toBe(true); 54 | }); 55 | 56 | it.skip('should have mock API configuration', () => { 57 | const testConfig = getTestConfig(); 58 | // Add debug logging for CI 59 | if (process.env.CI) { 60 | console.log('CI Environment Debug:', { 61 | NODE_ENV: process.env.NODE_ENV, 62 | N8N_API_URL: process.env.N8N_API_URL, 63 | N8N_API_KEY: process.env.N8N_API_KEY, 64 | configUrl: testConfig.api.url, 65 | configKey: testConfig.api.key 66 | }); 67 | } 68 | expect(testConfig.api.url).toMatch(/mock-api/); 69 | expect(testConfig.api.key).toBe('test-api-key-12345'); 70 | }); 71 | 72 | it('should respect test timeouts', { timeout: getTestTimeout('unit') }, async () => { 73 | const timeout = getTestTimeout('unit'); 74 | expect(timeout).toBe(5000); 75 | 76 | // Simulate async operation 77 | await new Promise(resolve => setTimeout(resolve, 100)); 78 | }); 79 | 80 | it('should support environment overrides', () => { 81 | const testConfig = getTestConfig(); 82 | const originalLogLevel = testConfig.logging.level; 83 | 84 | const result = withEnvOverrides({ 85 | LOG_LEVEL: 'debug', 86 | DEBUG: 'true' 87 | }, () => { 88 | const newConfig = getTestConfig(); 89 | expect(newConfig.logging.level).toBe('debug'); 90 | expect(newConfig.logging.debug).toBe(true); 91 | return 'success'; 92 | }); 93 | 94 | expect(result).toBe('success'); 95 | const configAfter = getTestConfig(); 96 | expect(configAfter.logging.level).toBe(originalLogLevel); 97 | }); 98 | 99 | it('should generate unique test database paths', () => { 100 | const path1 = createTestDatabasePath('feature1'); 101 | const path2 = createTestDatabasePath('feature1'); 102 | 103 | if (path1 !== ':memory:') { 104 | expect(path1).not.toBe(path2); 105 | expect(path1).toMatch(/test-feature1-\d+-\w+\.db$/); 106 | } 107 | }); 108 | 109 | it('should construct mock API URLs', () => { 110 | const testConfig = getTestConfig(); 111 | const baseUrl = getMockApiUrl(); 112 | const endpointUrl = getMockApiUrl('/nodes'); 113 | 114 | expect(baseUrl).toBe(testConfig.api.url); 115 | expect(endpointUrl).toBe(`${testConfig.api.url}/nodes`); 116 | }); 117 | 118 | it.skipIf(!isFeatureEnabled('mockExternalApis'))('should check feature flags', () => { 119 | const testConfig = getTestConfig(); 120 | expect(testConfig.features.mockExternalApis).toBe(true); 121 | expect(isFeatureEnabled('mockExternalApis')).toBe(true); 122 | }); 123 | 124 | it('should measure performance', () => { 125 | const measure = measurePerformance('test-operation'); 126 | 127 | // Test the performance measurement utility structure and behavior 128 | // rather than relying on timing precision which is unreliable in CI 129 | 130 | // Capture initial state 131 | const startTime = performance.now(); 132 | 133 | // Add some marks 134 | measure.mark('start-processing'); 135 | 136 | // Do some minimal synchronous work 137 | let sum = 0; 138 | for (let i = 0; i < 10000; i++) { 139 | sum += i; 140 | } 141 | 142 | measure.mark('mid-processing'); 143 | 144 | // Do a bit more work 145 | for (let i = 0; i < 10000; i++) { 146 | sum += i * 2; 147 | } 148 | 149 | const results = measure.end(); 150 | const endTime = performance.now(); 151 | 152 | // Test the utility's correctness rather than exact timing 153 | expect(results).toHaveProperty('total'); 154 | expect(results).toHaveProperty('marks'); 155 | expect(typeof results.total).toBe('number'); 156 | expect(results.total).toBeGreaterThan(0); 157 | 158 | // Verify marks structure 159 | expect(results.marks).toHaveProperty('start-processing'); 160 | expect(results.marks).toHaveProperty('mid-processing'); 161 | expect(typeof results.marks['start-processing']).toBe('number'); 162 | expect(typeof results.marks['mid-processing']).toBe('number'); 163 | 164 | // Verify logical order of marks (this should always be true) 165 | expect(results.marks['start-processing']).toBeLessThan(results.marks['mid-processing']); 166 | expect(results.marks['start-processing']).toBeGreaterThanOrEqual(0); 167 | expect(results.marks['mid-processing']).toBeLessThan(results.total); 168 | 169 | // Verify the total time is reasonable (should be between manual measurements) 170 | const manualTotal = endTime - startTime; 171 | expect(results.total).toBeLessThanOrEqual(manualTotal + 1); // Allow 1ms tolerance 172 | 173 | // Verify work was actually done 174 | expect(sum).toBeGreaterThan(0); 175 | }); 176 | 177 | it('should wait for conditions', async () => { 178 | let counter = 0; 179 | const incrementCounter = setInterval(() => counter++, 100); 180 | 181 | try { 182 | await waitForCondition( 183 | () => counter >= 3, 184 | { 185 | timeout: 1000, 186 | interval: 50, 187 | message: 'Counter did not reach 3' 188 | } 189 | ); 190 | 191 | expect(counter).toBeGreaterThanOrEqual(3); 192 | } finally { 193 | clearInterval(incrementCounter); 194 | } 195 | }); 196 | 197 | it('should have proper logging configuration', () => { 198 | const testConfig = getTestConfig(); 199 | expect(testConfig.logging.level).toBe('error'); 200 | expect(testConfig.logging.debug).toBe(false); 201 | expect(testConfig.logging.showStack).toBe(true); 202 | 203 | // Logger should respect configuration 204 | logger.debug('This should not appear in test output'); 205 | logger.error('This should appear in test output'); 206 | }); 207 | 208 | it('should have performance thresholds', () => { 209 | const testConfig = getTestConfig(); 210 | expect(testConfig.performance.thresholds.apiResponse).toBe(100); 211 | expect(testConfig.performance.thresholds.dbQuery).toBe(50); 212 | expect(testConfig.performance.thresholds.nodeParse).toBe(200); 213 | }); 214 | 215 | it('should disable caching and rate limiting in tests', () => { 216 | const testConfig = getTestConfig(); 217 | expect(testConfig.cache.enabled).toBe(false); 218 | expect(testConfig.cache.ttl).toBe(0); 219 | expect(testConfig.rateLimiting.max).toBe(0); 220 | expect(testConfig.rateLimiting.window).toBe(0); 221 | }); 222 | 223 | it('should configure test paths', () => { 224 | const testConfig = getTestConfig(); 225 | expect(testConfig.paths.fixtures).toBe('./tests/fixtures'); 226 | expect(testConfig.paths.data).toBe('./tests/data'); 227 | expect(testConfig.paths.snapshots).toBe('./tests/__snapshots__'); 228 | }); 229 | 230 | it('should support MSW configuration', () => { 231 | // Ensure test environment is loaded 232 | if (!process.env.MSW_ENABLED) { 233 | loadTestEnvironment(); 234 | } 235 | 236 | const testConfig = getTestConfig(); 237 | expect(testConfig.mocking.msw.enabled).toBe(true); 238 | expect(testConfig.mocking.msw.apiDelay).toBe(0); 239 | }); 240 | }); ``` -------------------------------------------------------------------------------- /tests/unit/examples/using-n8n-nodes-base-mock.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { getNodeTypes, mockNodeBehavior, resetAllMocks } from '../__mocks__/n8n-nodes-base'; 3 | 4 | // Example service that uses n8n-nodes-base 5 | class WorkflowService { 6 | async getNodeDescription(nodeName: string) { 7 | const nodeTypes = getNodeTypes(); 8 | const node = nodeTypes.getByName(nodeName); 9 | return node?.description; 10 | } 11 | 12 | async executeNode(nodeName: string, context: any) { 13 | const nodeTypes = getNodeTypes(); 14 | const node = nodeTypes.getByName(nodeName); 15 | 16 | if (!node?.execute) { 17 | throw new Error(`Node ${nodeName} does not have an execute method`); 18 | } 19 | 20 | return node.execute.call(context); 21 | } 22 | 23 | async validateSlackMessage(channel: string, text: string) { 24 | if (!channel || !text) { 25 | throw new Error('Channel and text are required'); 26 | } 27 | 28 | const nodeTypes = getNodeTypes(); 29 | const slackNode = nodeTypes.getByName('slack'); 30 | 31 | if (!slackNode) { 32 | throw new Error('Slack node not found'); 33 | } 34 | 35 | // Check if required properties exist 36 | const channelProp = slackNode.description.properties.find(p => p.name === 'channel'); 37 | const textProp = slackNode.description.properties.find(p => p.name === 'text'); 38 | 39 | return !!(channelProp && textProp); 40 | } 41 | } 42 | 43 | // Mock the module at the top level 44 | vi.mock('n8n-nodes-base', () => { 45 | const { getNodeTypes: mockGetNodeTypes } = require('../__mocks__/n8n-nodes-base'); 46 | return { 47 | getNodeTypes: mockGetNodeTypes 48 | }; 49 | }); 50 | 51 | describe('WorkflowService with n8n-nodes-base mock', () => { 52 | let service: WorkflowService; 53 | 54 | beforeEach(() => { 55 | resetAllMocks(); 56 | service = new WorkflowService(); 57 | }); 58 | 59 | describe('getNodeDescription', () => { 60 | it('should get webhook node description', async () => { 61 | const description = await service.getNodeDescription('webhook'); 62 | 63 | expect(description).toBeDefined(); 64 | expect(description?.name).toBe('webhook'); 65 | expect(description?.group).toContain('trigger'); 66 | expect(description?.webhooks).toBeDefined(); 67 | }); 68 | 69 | it('should get httpRequest node description', async () => { 70 | const description = await service.getNodeDescription('httpRequest'); 71 | 72 | expect(description).toBeDefined(); 73 | expect(description?.name).toBe('httpRequest'); 74 | expect(description?.version).toBe(3); 75 | 76 | const methodProp = description?.properties.find(p => p.name === 'method'); 77 | expect(methodProp).toBeDefined(); 78 | expect(methodProp?.options).toHaveLength(6); 79 | }); 80 | }); 81 | 82 | describe('executeNode', () => { 83 | it('should execute httpRequest node with custom response', async () => { 84 | // Override the httpRequest node behavior for this test 85 | mockNodeBehavior('httpRequest', { 86 | execute: vi.fn(async function(this: any) { 87 | const url = this.getNodeParameter('url', 0); 88 | return [[{ 89 | json: { 90 | statusCode: 200, 91 | url, 92 | customData: 'mocked response' 93 | } 94 | }]]; 95 | }) 96 | }); 97 | 98 | const mockContext = { 99 | getInputData: vi.fn(() => [{ json: { input: 'data' } }]), 100 | getNodeParameter: vi.fn((name: string) => { 101 | if (name === 'url') return 'https://test.com/api'; 102 | return ''; 103 | }) 104 | }; 105 | 106 | const result = await service.executeNode('httpRequest', mockContext); 107 | 108 | expect(result).toBeDefined(); 109 | expect(result[0][0].json).toMatchObject({ 110 | statusCode: 200, 111 | url: 'https://test.com/api', 112 | customData: 'mocked response' 113 | }); 114 | }); 115 | 116 | it('should execute slack node and track calls', async () => { 117 | const mockContext = { 118 | getInputData: vi.fn(() => [{ json: { message: 'test' } }]), 119 | getNodeParameter: vi.fn((name: string, index: number) => { 120 | const params: Record<string, string> = { 121 | resource: 'message', 122 | operation: 'post', 123 | channel: '#general', 124 | text: 'Hello from test!' 125 | }; 126 | return params[name] || ''; 127 | }), 128 | getCredentials: vi.fn(async () => ({ token: 'mock-token' })) 129 | }; 130 | 131 | const result = await service.executeNode('slack', mockContext); 132 | 133 | expect(result).toBeDefined(); 134 | expect(result[0][0].json).toMatchObject({ 135 | ok: true, 136 | channel: '#general', 137 | message: { 138 | text: 'Hello from test!' 139 | } 140 | }); 141 | 142 | // Verify the mock was called 143 | expect(mockContext.getNodeParameter).toHaveBeenCalledWith('channel', 0, ''); 144 | expect(mockContext.getNodeParameter).toHaveBeenCalledWith('text', 0, ''); 145 | }); 146 | 147 | it('should throw error for non-executable node', async () => { 148 | // Create a trigger-only node 149 | mockNodeBehavior('webhook', { 150 | execute: undefined // Remove execute method 151 | }); 152 | 153 | await expect( 154 | service.executeNode('webhook', {}) 155 | ).rejects.toThrow('Node webhook does not have an execute method'); 156 | }); 157 | }); 158 | 159 | describe('validateSlackMessage', () => { 160 | it('should validate slack message parameters', async () => { 161 | const isValid = await service.validateSlackMessage('#general', 'Hello'); 162 | expect(isValid).toBe(true); 163 | }); 164 | 165 | it('should throw error for missing parameters', async () => { 166 | await expect( 167 | service.validateSlackMessage('', 'Hello') 168 | ).rejects.toThrow('Channel and text are required'); 169 | 170 | await expect( 171 | service.validateSlackMessage('#general', '') 172 | ).rejects.toThrow('Channel and text are required'); 173 | }); 174 | 175 | it('should handle missing slack node', async () => { 176 | // Save the original mock implementation 177 | const originalImplementation = vi.mocked(getNodeTypes).getMockImplementation(); 178 | 179 | // Override getNodeTypes to return undefined for slack 180 | vi.mocked(getNodeTypes).mockImplementation(() => ({ 181 | getByName: vi.fn((name: string) => { 182 | if (name === 'slack') return undefined; 183 | // Return the actual mock implementation for other nodes 184 | const actualRegistry = originalImplementation ? originalImplementation() : getNodeTypes(); 185 | return actualRegistry.getByName(name); 186 | }), 187 | getByNameAndVersion: vi.fn() 188 | })); 189 | 190 | await expect( 191 | service.validateSlackMessage('#general', 'Hello') 192 | ).rejects.toThrow('Slack node not found'); 193 | 194 | // Restore the original implementation 195 | if (originalImplementation) { 196 | vi.mocked(getNodeTypes).mockImplementation(originalImplementation); 197 | } 198 | }); 199 | }); 200 | 201 | describe('complex workflow scenarios', () => { 202 | it('should handle if node branching', async () => { 203 | const mockContext = { 204 | getInputData: vi.fn(() => [ 205 | { json: { status: 'active' } }, 206 | { json: { status: 'inactive' } }, 207 | { json: { status: 'active' } }, 208 | ]), 209 | getNodeParameter: vi.fn() 210 | }; 211 | 212 | const result = await service.executeNode('if', mockContext); 213 | 214 | expect(result).toHaveLength(2); // true and false branches 215 | expect(result[0]).toHaveLength(2); // items at index 0 and 2 216 | expect(result[1]).toHaveLength(1); // item at index 1 217 | }); 218 | 219 | it('should handle merge node combining inputs', async () => { 220 | const mockContext = { 221 | getInputData: vi.fn((inputIndex?: number) => { 222 | if (inputIndex === 0) return [{ json: { source: 'input1' } }]; 223 | if (inputIndex === 1) return [{ json: { source: 'input2' } }]; 224 | return [{ json: { source: 'input1' } }]; 225 | }), 226 | getNodeParameter: vi.fn(() => 'append') 227 | }; 228 | 229 | const result = await service.executeNode('merge', mockContext); 230 | 231 | expect(result).toBeDefined(); 232 | expect(result[0]).toHaveLength(1); 233 | }); 234 | }); 235 | }); ``` -------------------------------------------------------------------------------- /tests/unit/services/universal-expression-validator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { UniversalExpressionValidator } from '../../../src/services/universal-expression-validator'; 3 | 4 | describe('UniversalExpressionValidator', () => { 5 | describe('validateExpressionPrefix', () => { 6 | it('should detect missing prefix in pure expression', () => { 7 | const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); 8 | 9 | expect(result.isValid).toBe(false); 10 | expect(result.hasExpression).toBe(true); 11 | expect(result.needsPrefix).toBe(true); 12 | expect(result.isMixedContent).toBe(false); 13 | expect(result.confidence).toBe(1.0); 14 | expect(result.suggestion).toBe('={{ $json.value }}'); 15 | }); 16 | 17 | it('should detect missing prefix in mixed content', () => { 18 | const result = UniversalExpressionValidator.validateExpressionPrefix( 19 | 'Hello {{ $json.name }}' 20 | ); 21 | 22 | expect(result.isValid).toBe(false); 23 | expect(result.hasExpression).toBe(true); 24 | expect(result.needsPrefix).toBe(true); 25 | expect(result.isMixedContent).toBe(true); 26 | expect(result.confidence).toBe(1.0); 27 | expect(result.suggestion).toBe('=Hello {{ $json.name }}'); 28 | }); 29 | 30 | it('should accept properly prefixed expression', () => { 31 | const result = UniversalExpressionValidator.validateExpressionPrefix('={{ $json.value }}'); 32 | 33 | expect(result.isValid).toBe(true); 34 | expect(result.hasExpression).toBe(true); 35 | expect(result.needsPrefix).toBe(false); 36 | expect(result.confidence).toBe(1.0); 37 | }); 38 | 39 | it('should accept properly prefixed mixed content', () => { 40 | const result = UniversalExpressionValidator.validateExpressionPrefix( 41 | '=Hello {{ $json.name }}!' 42 | ); 43 | 44 | expect(result.isValid).toBe(true); 45 | expect(result.hasExpression).toBe(true); 46 | expect(result.isMixedContent).toBe(true); 47 | expect(result.confidence).toBe(1.0); 48 | }); 49 | 50 | it('should ignore non-string values', () => { 51 | const result = UniversalExpressionValidator.validateExpressionPrefix(123); 52 | 53 | expect(result.isValid).toBe(true); 54 | expect(result.hasExpression).toBe(false); 55 | expect(result.confidence).toBe(1.0); 56 | }); 57 | 58 | it('should ignore strings without expressions', () => { 59 | const result = UniversalExpressionValidator.validateExpressionPrefix('plain text'); 60 | 61 | expect(result.isValid).toBe(true); 62 | expect(result.hasExpression).toBe(false); 63 | expect(result.confidence).toBe(1.0); 64 | }); 65 | }); 66 | 67 | describe('validateExpressionSyntax', () => { 68 | it('should detect unclosed brackets', () => { 69 | const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }'); 70 | 71 | expect(result.isValid).toBe(false); 72 | expect(result.explanation).toContain('Unmatched expression brackets'); 73 | }); 74 | 75 | it('should detect empty expressions', () => { 76 | const result = UniversalExpressionValidator.validateExpressionSyntax('={{ }}'); 77 | 78 | expect(result.isValid).toBe(false); 79 | expect(result.explanation).toContain('Empty expression'); 80 | }); 81 | 82 | it('should accept valid syntax', () => { 83 | const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }}'); 84 | 85 | expect(result.isValid).toBe(true); 86 | expect(result.hasExpression).toBe(true); 87 | }); 88 | 89 | it('should handle multiple expressions', () => { 90 | const result = UniversalExpressionValidator.validateExpressionSyntax( 91 | '={{ $json.first }} and {{ $json.second }}' 92 | ); 93 | 94 | expect(result.isValid).toBe(true); 95 | expect(result.hasExpression).toBe(true); 96 | expect(result.isMixedContent).toBe(true); 97 | }); 98 | }); 99 | 100 | describe('validateCommonPatterns', () => { 101 | it('should detect template literal syntax', () => { 102 | const result = UniversalExpressionValidator.validateCommonPatterns('={{ ${json.value} }}'); 103 | 104 | expect(result.isValid).toBe(false); 105 | expect(result.explanation).toContain('Template literal syntax'); 106 | }); 107 | 108 | it('should detect double prefix', () => { 109 | const result = UniversalExpressionValidator.validateCommonPatterns('={{ =$json.value }}'); 110 | 111 | expect(result.isValid).toBe(false); 112 | expect(result.explanation).toContain('Double prefix'); 113 | }); 114 | 115 | it('should detect nested brackets', () => { 116 | const result = UniversalExpressionValidator.validateCommonPatterns( 117 | '={{ $json.items[{{ $json.index }}] }}' 118 | ); 119 | 120 | expect(result.isValid).toBe(false); 121 | expect(result.explanation).toContain('Nested brackets'); 122 | }); 123 | 124 | it('should accept valid patterns', () => { 125 | const result = UniversalExpressionValidator.validateCommonPatterns( 126 | '={{ $json.items[$json.index] }}' 127 | ); 128 | 129 | expect(result.isValid).toBe(true); 130 | }); 131 | }); 132 | 133 | describe('validate (comprehensive)', () => { 134 | it('should return all validation issues', () => { 135 | const results = UniversalExpressionValidator.validate('{{ ${json.value} }}'); 136 | 137 | expect(results.length).toBeGreaterThan(0); 138 | const issues = results.filter(r => !r.isValid); 139 | expect(issues.length).toBeGreaterThan(0); 140 | 141 | // Should detect both missing prefix and template literal syntax 142 | const prefixIssue = issues.find(i => i.needsPrefix); 143 | const patternIssue = issues.find(i => i.explanation.includes('Template literal')); 144 | 145 | expect(prefixIssue).toBeTruthy(); 146 | expect(patternIssue).toBeTruthy(); 147 | }); 148 | 149 | it('should return success for valid expression', () => { 150 | const results = UniversalExpressionValidator.validate('={{ $json.value }}'); 151 | 152 | expect(results).toHaveLength(1); 153 | expect(results[0].isValid).toBe(true); 154 | expect(results[0].confidence).toBe(1.0); 155 | }); 156 | 157 | it('should handle non-expression strings', () => { 158 | const results = UniversalExpressionValidator.validate('plain text'); 159 | 160 | expect(results).toHaveLength(1); 161 | expect(results[0].isValid).toBe(true); 162 | expect(results[0].hasExpression).toBe(false); 163 | }); 164 | }); 165 | 166 | describe('getCorrectedValue', () => { 167 | it('should add prefix to expression', () => { 168 | const corrected = UniversalExpressionValidator.getCorrectedValue('{{ $json.value }}'); 169 | expect(corrected).toBe('={{ $json.value }}'); 170 | }); 171 | 172 | it('should add prefix to mixed content', () => { 173 | const corrected = UniversalExpressionValidator.getCorrectedValue( 174 | 'Hello {{ $json.name }}' 175 | ); 176 | expect(corrected).toBe('=Hello {{ $json.name }}'); 177 | }); 178 | 179 | it('should not modify already prefixed expressions', () => { 180 | const corrected = UniversalExpressionValidator.getCorrectedValue('={{ $json.value }}'); 181 | expect(corrected).toBe('={{ $json.value }}'); 182 | }); 183 | 184 | it('should not modify non-expressions', () => { 185 | const corrected = UniversalExpressionValidator.getCorrectedValue('plain text'); 186 | expect(corrected).toBe('plain text'); 187 | }); 188 | }); 189 | 190 | describe('hasMixedContent', () => { 191 | it('should detect URLs with expressions', () => { 192 | const result = UniversalExpressionValidator.validateExpressionPrefix( 193 | 'https://api.example.com/users/{{ $json.id }}' 194 | ); 195 | expect(result.isMixedContent).toBe(true); 196 | }); 197 | 198 | it('should detect text with expressions', () => { 199 | const result = UniversalExpressionValidator.validateExpressionPrefix( 200 | 'Welcome {{ $json.name }} to our service' 201 | ); 202 | expect(result.isMixedContent).toBe(true); 203 | }); 204 | 205 | it('should identify pure expressions', () => { 206 | const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); 207 | expect(result.isMixedContent).toBe(false); 208 | }); 209 | 210 | it('should identify pure expressions with spaces', () => { 211 | const result = UniversalExpressionValidator.validateExpressionPrefix( 212 | ' {{ $json.value }} ' 213 | ); 214 | expect(result.isMixedContent).toBe(false); 215 | }); 216 | }); 217 | }); ``` -------------------------------------------------------------------------------- /tests/integration/mcp-protocol/test-helpers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | InitializeRequestSchema, 7 | } from '@modelcontextprotocol/sdk/types.js'; 8 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 9 | import { N8NDocumentationMCPServer } from '../../../src/mcp/server'; 10 | 11 | let sharedMcpServer: N8NDocumentationMCPServer | null = null; 12 | 13 | export class TestableN8NMCPServer { 14 | private mcpServer: N8NDocumentationMCPServer; 15 | private server: Server; 16 | private transports = new Set<Transport>(); 17 | private connections = new Set<any>(); 18 | private static instanceCount = 0; 19 | private testDbPath: string; 20 | 21 | constructor() { 22 | // Use a unique test database for each instance to avoid conflicts 23 | // This prevents concurrent test issues with database locking 24 | const instanceId = TestableN8NMCPServer.instanceCount++; 25 | this.testDbPath = `/tmp/n8n-mcp-test-${process.pid}-${instanceId}.db`; 26 | process.env.NODE_DB_PATH = this.testDbPath; 27 | 28 | this.server = new Server({ 29 | name: 'n8n-documentation-mcp', 30 | version: '1.0.0' 31 | }, { 32 | capabilities: { 33 | tools: {} 34 | } 35 | }); 36 | 37 | this.mcpServer = new N8NDocumentationMCPServer(); 38 | this.setupHandlers(); 39 | } 40 | 41 | private setupHandlers() { 42 | // Initialize handler 43 | this.server.setRequestHandler(InitializeRequestSchema, async () => { 44 | return { 45 | protocolVersion: '2024-11-05', 46 | capabilities: { 47 | tools: {} 48 | }, 49 | serverInfo: { 50 | name: 'n8n-documentation-mcp', 51 | version: '1.0.0' 52 | } 53 | }; 54 | }); 55 | 56 | // List tools handler 57 | this.server.setRequestHandler(ListToolsRequestSchema, async () => { 58 | // Import the tools directly from the tools module 59 | const { n8nDocumentationToolsFinal } = await import('../../../src/mcp/tools'); 60 | const { n8nManagementTools } = await import('../../../src/mcp/tools-n8n-manager'); 61 | const { isN8nApiConfigured } = await import('../../../src/config/n8n-api'); 62 | 63 | // Combine documentation tools with management tools if API is configured 64 | const tools = [...n8nDocumentationToolsFinal]; 65 | if (isN8nApiConfigured()) { 66 | tools.push(...n8nManagementTools); 67 | } 68 | 69 | return { tools }; 70 | }); 71 | 72 | // Call tool handler 73 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 74 | try { 75 | // The mcpServer.executeTool returns raw data, we need to wrap it in the MCP response format 76 | const result = await this.mcpServer.executeTool(request.params.name, request.params.arguments || {}); 77 | 78 | return { 79 | content: [ 80 | { 81 | type: 'text' as const, 82 | text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) 83 | } 84 | ] 85 | }; 86 | } catch (error: any) { 87 | // If it's already an MCP error, throw it as is 88 | if (error.code && error.message) { 89 | throw error; 90 | } 91 | // Otherwise, wrap it in an MCP error 92 | throw new McpError( 93 | ErrorCode.InternalError, 94 | error.message || 'Unknown error' 95 | ); 96 | } 97 | }); 98 | } 99 | 100 | async initialize(): Promise<void> { 101 | // Copy production database to test location for realistic testing 102 | try { 103 | const fs = await import('fs'); 104 | const path = await import('path'); 105 | const prodDbPath = path.join(process.cwd(), 'data', 'nodes.db'); 106 | 107 | if (await fs.promises.access(prodDbPath).then(() => true).catch(() => false)) { 108 | await fs.promises.copyFile(prodDbPath, this.testDbPath); 109 | } 110 | } catch (error) { 111 | // Ignore copy errors, database will be created fresh 112 | } 113 | 114 | // The MCP server initializes its database lazily 115 | // We can trigger initialization by calling executeTool 116 | try { 117 | await this.mcpServer.executeTool('get_database_statistics', {}); 118 | } catch (error) { 119 | // Ignore errors, we just want to trigger initialization 120 | } 121 | } 122 | 123 | async connectToTransport(transport: Transport): Promise<void> { 124 | // Ensure transport has required properties before connecting 125 | if (!transport || typeof transport !== 'object') { 126 | throw new Error('Invalid transport provided'); 127 | } 128 | 129 | // Set up any missing transport handlers to prevent "Cannot set properties of undefined" errors 130 | if (transport && typeof transport === 'object') { 131 | const transportAny = transport as any; 132 | if (transportAny.serverTransport && !transportAny.serverTransport.onclose) { 133 | transportAny.serverTransport.onclose = () => {}; 134 | } 135 | } 136 | 137 | // Track this transport for cleanup 138 | this.transports.add(transport); 139 | 140 | const connection = await this.server.connect(transport); 141 | this.connections.add(connection); 142 | } 143 | 144 | async close(): Promise<void> { 145 | // Use a timeout to prevent hanging during cleanup 146 | const closeTimeout = new Promise<void>((resolve) => { 147 | setTimeout(() => { 148 | console.warn('TestableN8NMCPServer close timeout - forcing cleanup'); 149 | resolve(); 150 | }, 3000); 151 | }); 152 | 153 | const performClose = async () => { 154 | // Close all connections first with timeout protection 155 | const connectionPromises = Array.from(this.connections).map(async (connection) => { 156 | const connTimeout = new Promise<void>((resolve) => setTimeout(resolve, 500)); 157 | 158 | try { 159 | if (connection && typeof connection.close === 'function') { 160 | await Promise.race([connection.close(), connTimeout]); 161 | } 162 | } catch (error) { 163 | // Ignore errors during connection cleanup 164 | } 165 | }); 166 | 167 | await Promise.allSettled(connectionPromises); 168 | this.connections.clear(); 169 | 170 | // Close all tracked transports with timeout protection 171 | const transportPromises: Promise<void>[] = []; 172 | 173 | for (const transport of this.transports) { 174 | const transportTimeout = new Promise<void>((resolve) => setTimeout(resolve, 500)); 175 | 176 | try { 177 | // Force close all transports 178 | const transportAny = transport as any; 179 | 180 | // Try different close methods 181 | if (transportAny.close && typeof transportAny.close === 'function') { 182 | transportPromises.push( 183 | Promise.race([transportAny.close(), transportTimeout]) 184 | ); 185 | } 186 | if (transportAny.serverTransport?.close) { 187 | transportPromises.push( 188 | Promise.race([transportAny.serverTransport.close(), transportTimeout]) 189 | ); 190 | } 191 | if (transportAny.clientTransport?.close) { 192 | transportPromises.push( 193 | Promise.race([transportAny.clientTransport.close(), transportTimeout]) 194 | ); 195 | } 196 | } catch (error) { 197 | // Ignore errors during transport cleanup 198 | } 199 | } 200 | 201 | // Wait for all transports to close with timeout 202 | await Promise.allSettled(transportPromises); 203 | 204 | // Clear the transports set 205 | this.transports.clear(); 206 | 207 | // Don't shut down the shared MCP server instance 208 | }; 209 | 210 | // Race between actual close and timeout 211 | await Promise.race([performClose(), closeTimeout]); 212 | 213 | // Clean up test database 214 | if (this.testDbPath) { 215 | try { 216 | const fs = await import('fs'); 217 | await fs.promises.unlink(this.testDbPath).catch(() => {}); 218 | await fs.promises.unlink(`${this.testDbPath}-shm`).catch(() => {}); 219 | await fs.promises.unlink(`${this.testDbPath}-wal`).catch(() => {}); 220 | } catch (error) { 221 | // Ignore cleanup errors 222 | } 223 | } 224 | } 225 | 226 | static async shutdownShared(): Promise<void> { 227 | if (sharedMcpServer) { 228 | await sharedMcpServer.shutdown(); 229 | sharedMcpServer = null; 230 | } 231 | } 232 | } ``` -------------------------------------------------------------------------------- /scripts/test-error-output-validation.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env npx tsx 2 | 3 | /** 4 | * Test script for error output validation improvements 5 | * Tests both incorrect and correct error output configurations 6 | */ 7 | 8 | import { WorkflowValidator } from '../dist/services/workflow-validator.js'; 9 | import { NodeRepository } from '../dist/database/node-repository.js'; 10 | import { EnhancedConfigValidator } from '../dist/services/enhanced-config-validator.js'; 11 | import { DatabaseAdapter } from '../dist/database/database-adapter.js'; 12 | import { Logger } from '../dist/utils/logger.js'; 13 | import path from 'path'; 14 | import { fileURLToPath } from 'url'; 15 | 16 | const __filename = fileURLToPath(import.meta.url); 17 | const __dirname = path.dirname(__filename); 18 | 19 | const logger = new Logger({ prefix: '[TestErrorValidation]' }); 20 | 21 | async function runTests() { 22 | // Initialize database 23 | const dbPath = path.join(__dirname, '..', 'data', 'n8n-nodes.db'); 24 | const adapter = new DatabaseAdapter(); 25 | adapter.initialize({ 26 | type: 'better-sqlite3', 27 | filename: dbPath 28 | }); 29 | const db = adapter.getDatabase(); 30 | 31 | const nodeRepository = new NodeRepository(db); 32 | const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator); 33 | 34 | console.log('\n🧪 Testing Error Output Validation Improvements\n'); 35 | console.log('=' .repeat(60)); 36 | 37 | // Test 1: Incorrect configuration - multiple nodes in same array 38 | console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]'); 39 | console.log('-'.repeat(40)); 40 | 41 | const incorrectWorkflow = { 42 | nodes: [ 43 | { 44 | id: '132ef0dc-87af-41de-a95d-cabe3a0a5342', 45 | name: 'Validate Input', 46 | type: 'n8n-nodes-base.set', 47 | typeVersion: 3.4, 48 | position: [-400, 64] as [number, number], 49 | parameters: {} 50 | }, 51 | { 52 | id: '5dedf217-63f9-409f-b34e-7780b22e199a', 53 | name: 'Filter URLs', 54 | type: 'n8n-nodes-base.filter', 55 | typeVersion: 2.2, 56 | position: [-176, 64] as [number, number], 57 | parameters: {} 58 | }, 59 | { 60 | id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3', 61 | name: 'Error Response1', 62 | type: 'n8n-nodes-base.respondToWebhook', 63 | typeVersion: 1.5, 64 | position: [-160, 240] as [number, number], 65 | parameters: {} 66 | } 67 | ], 68 | connections: { 69 | 'Validate Input': { 70 | main: [ 71 | [ 72 | { node: 'Filter URLs', type: 'main', index: 0 }, 73 | { node: 'Error Response1', type: 'main', index: 0 } // WRONG! 74 | ] 75 | ] 76 | } 77 | } 78 | }; 79 | 80 | const result1 = await validator.validateWorkflow(incorrectWorkflow); 81 | 82 | if (result1.errors.length > 0) { 83 | console.log('❌ ERROR DETECTED (as expected):'); 84 | const errorMessage = result1.errors.find(e => 85 | e.message.includes('Incorrect error output configuration') 86 | ); 87 | if (errorMessage) { 88 | console.log('\n' + errorMessage.message); 89 | } 90 | } else { 91 | console.log('✅ No errors found (but should have detected the issue!)'); 92 | } 93 | 94 | // Test 2: Correct configuration - separate arrays 95 | console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]'); 96 | console.log('-'.repeat(40)); 97 | 98 | const correctWorkflow = { 99 | nodes: [ 100 | { 101 | id: '132ef0dc-87af-41de-a95d-cabe3a0a5342', 102 | name: 'Validate Input', 103 | type: 'n8n-nodes-base.set', 104 | typeVersion: 3.4, 105 | position: [-400, 64] as [number, number], 106 | parameters: {}, 107 | onError: 'continueErrorOutput' as const 108 | }, 109 | { 110 | id: '5dedf217-63f9-409f-b34e-7780b22e199a', 111 | name: 'Filter URLs', 112 | type: 'n8n-nodes-base.filter', 113 | typeVersion: 2.2, 114 | position: [-176, 64] as [number, number], 115 | parameters: {} 116 | }, 117 | { 118 | id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3', 119 | name: 'Error Response1', 120 | type: 'n8n-nodes-base.respondToWebhook', 121 | typeVersion: 1.5, 122 | position: [-160, 240] as [number, number], 123 | parameters: {} 124 | } 125 | ], 126 | connections: { 127 | 'Validate Input': { 128 | main: [ 129 | [ 130 | { node: 'Filter URLs', type: 'main', index: 0 } 131 | ], 132 | [ 133 | { node: 'Error Response1', type: 'main', index: 0 } // CORRECT! 134 | ] 135 | ] 136 | } 137 | } 138 | }; 139 | 140 | const result2 = await validator.validateWorkflow(correctWorkflow); 141 | 142 | const hasIncorrectError = result2.errors.some(e => 143 | e.message.includes('Incorrect error output configuration') 144 | ); 145 | 146 | if (!hasIncorrectError) { 147 | console.log('✅ No error output configuration issues (correct!)'); 148 | } else { 149 | console.log('❌ Unexpected error found'); 150 | } 151 | 152 | // Test 3: onError without error connections 153 | console.log('\n📝 Test 3: onError without error connections'); 154 | console.log('-'.repeat(40)); 155 | 156 | const mismatchWorkflow = { 157 | nodes: [ 158 | { 159 | id: '1', 160 | name: 'HTTP Request', 161 | type: 'n8n-nodes-base.httpRequest', 162 | typeVersion: 4, 163 | position: [100, 100] as [number, number], 164 | parameters: {}, 165 | onError: 'continueErrorOutput' as const 166 | }, 167 | { 168 | id: '2', 169 | name: 'Process Data', 170 | type: 'n8n-nodes-base.set', 171 | typeVersion: 2, 172 | position: [300, 100] as [number, number], 173 | parameters: {} 174 | } 175 | ], 176 | connections: { 177 | 'HTTP Request': { 178 | main: [ 179 | [ 180 | { node: 'Process Data', type: 'main', index: 0 } 181 | ] 182 | // No main[1] for error output 183 | ] 184 | } 185 | } 186 | }; 187 | 188 | const result3 = await validator.validateWorkflow(mismatchWorkflow); 189 | 190 | const mismatchError = result3.errors.find(e => 191 | e.message.includes("has onError: 'continueErrorOutput' but no error output connections") 192 | ); 193 | 194 | if (mismatchError) { 195 | console.log('❌ ERROR DETECTED (as expected):'); 196 | console.log(`Node: ${mismatchError.nodeName}`); 197 | console.log(`Message: ${mismatchError.message}`); 198 | } else { 199 | console.log('✅ No mismatch detected (but should have!)'); 200 | } 201 | 202 | // Test 4: Error connections without onError 203 | console.log('\n📝 Test 4: Error connections without onError property'); 204 | console.log('-'.repeat(40)); 205 | 206 | const missingOnErrorWorkflow = { 207 | nodes: [ 208 | { 209 | id: '1', 210 | name: 'HTTP Request', 211 | type: 'n8n-nodes-base.httpRequest', 212 | typeVersion: 4, 213 | position: [100, 100] as [number, number], 214 | parameters: {} 215 | // Missing onError property 216 | }, 217 | { 218 | id: '2', 219 | name: 'Process Data', 220 | type: 'n8n-nodes-base.set', 221 | position: [300, 100] as [number, number], 222 | parameters: {} 223 | }, 224 | { 225 | id: '3', 226 | name: 'Error Handler', 227 | type: 'n8n-nodes-base.set', 228 | position: [300, 300] as [number, number], 229 | parameters: {} 230 | } 231 | ], 232 | connections: { 233 | 'HTTP Request': { 234 | main: [ 235 | [ 236 | { node: 'Process Data', type: 'main', index: 0 } 237 | ], 238 | [ 239 | { node: 'Error Handler', type: 'main', index: 0 } 240 | ] 241 | ] 242 | } 243 | } 244 | }; 245 | 246 | const result4 = await validator.validateWorkflow(missingOnErrorWorkflow); 247 | 248 | const missingOnErrorWarning = result4.warnings.find(w => 249 | w.message.includes('error output connections in main[1] but missing onError') 250 | ); 251 | 252 | if (missingOnErrorWarning) { 253 | console.log('⚠️ WARNING DETECTED (as expected):'); 254 | console.log(`Node: ${missingOnErrorWarning.nodeName}`); 255 | console.log(`Message: ${missingOnErrorWarning.message}`); 256 | } else { 257 | console.log('✅ No warning (but should have warned!)'); 258 | } 259 | 260 | console.log('\n' + '='.repeat(60)); 261 | console.log('\n📊 Summary:'); 262 | console.log('- Error output validation is working correctly'); 263 | console.log('- Detects incorrect configurations (multiple nodes in main[0])'); 264 | console.log('- Validates onError property matches connections'); 265 | console.log('- Provides clear error messages with fix examples'); 266 | 267 | // Close database 268 | adapter.close(); 269 | } 270 | 271 | runTests().catch(error => { 272 | console.error('Test failed:', error); 273 | process.exit(1); 274 | }); ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/factories.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Test Data Factories 3 | * 4 | * Provides factory functions for generating test data dynamically. 5 | * Useful for creating variations of workflows, nodes, and parameters. 6 | */ 7 | 8 | import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api'; 9 | import { createTestWorkflowName } from './test-context'; 10 | 11 | /** 12 | * Create a webhook node with custom parameters 13 | * 14 | * @param options - Node options 15 | * @returns WorkflowNode 16 | */ 17 | export function createWebhookNode(options: { 18 | id?: string; 19 | name?: string; 20 | httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 21 | path?: string; 22 | position?: [number, number]; 23 | responseMode?: 'onReceived' | 'lastNode'; 24 | }): WorkflowNode { 25 | return { 26 | id: options.id || `webhook-${Date.now()}`, 27 | name: options.name || 'Webhook', 28 | type: 'n8n-nodes-base.webhook', 29 | typeVersion: 2, 30 | position: options.position || [250, 300], 31 | parameters: { 32 | httpMethod: options.httpMethod || 'GET', 33 | path: options.path || `test-${Date.now()}`, 34 | responseMode: options.responseMode || 'lastNode' 35 | } 36 | }; 37 | } 38 | 39 | /** 40 | * Create an HTTP Request node with custom parameters 41 | * 42 | * @param options - Node options 43 | * @returns WorkflowNode 44 | */ 45 | export function createHttpRequestNode(options: { 46 | id?: string; 47 | name?: string; 48 | url?: string; 49 | method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; 50 | position?: [number, number]; 51 | authentication?: string; 52 | }): WorkflowNode { 53 | return { 54 | id: options.id || `http-${Date.now()}`, 55 | name: options.name || 'HTTP Request', 56 | type: 'n8n-nodes-base.httpRequest', 57 | typeVersion: 4.2, 58 | position: options.position || [450, 300], 59 | parameters: { 60 | url: options.url || 'https://httpbin.org/get', 61 | method: options.method || 'GET', 62 | authentication: options.authentication || 'none' 63 | } 64 | }; 65 | } 66 | 67 | /** 68 | * Create a Set node with custom assignments 69 | * 70 | * @param options - Node options 71 | * @returns WorkflowNode 72 | */ 73 | export function createSetNode(options: { 74 | id?: string; 75 | name?: string; 76 | position?: [number, number]; 77 | assignments?: Array<{ 78 | name: string; 79 | value: any; 80 | type?: 'string' | 'number' | 'boolean' | 'object' | 'array'; 81 | }>; 82 | }): WorkflowNode { 83 | const assignments = options.assignments || [ 84 | { name: 'key', value: 'value', type: 'string' as const } 85 | ]; 86 | 87 | return { 88 | id: options.id || `set-${Date.now()}`, 89 | name: options.name || 'Set', 90 | type: 'n8n-nodes-base.set', 91 | typeVersion: 3.4, 92 | position: options.position || [450, 300], 93 | parameters: { 94 | assignments: { 95 | assignments: assignments.map((a, idx) => ({ 96 | id: `assign-${idx}`, 97 | name: a.name, 98 | value: a.value, 99 | type: a.type || 'string' 100 | })) 101 | }, 102 | options: {} 103 | } 104 | }; 105 | } 106 | 107 | /** 108 | * Create a Manual Trigger node 109 | * 110 | * @param options - Node options 111 | * @returns WorkflowNode 112 | */ 113 | export function createManualTriggerNode(options: { 114 | id?: string; 115 | name?: string; 116 | position?: [number, number]; 117 | } = {}): WorkflowNode { 118 | return { 119 | id: options.id || `manual-${Date.now()}`, 120 | name: options.name || 'When clicking "Test workflow"', 121 | type: 'n8n-nodes-base.manualTrigger', 122 | typeVersion: 1, 123 | position: options.position || [250, 300], 124 | parameters: {} 125 | }; 126 | } 127 | 128 | /** 129 | * Create a simple connection between two nodes 130 | * 131 | * @param from - Source node name 132 | * @param to - Target node name 133 | * @param options - Connection options 134 | * @returns Connection object 135 | */ 136 | export function createConnection( 137 | from: string, 138 | to: string, 139 | options: { 140 | sourceOutput?: string; 141 | targetInput?: string; 142 | sourceIndex?: number; 143 | targetIndex?: number; 144 | } = {} 145 | ): Record<string, any> { 146 | const sourceOutput = options.sourceOutput || 'main'; 147 | const targetInput = options.targetInput || 'main'; 148 | const sourceIndex = options.sourceIndex || 0; 149 | const targetIndex = options.targetIndex || 0; 150 | 151 | return { 152 | [from]: { 153 | [sourceOutput]: [ 154 | [ 155 | { 156 | node: to, 157 | type: targetInput, 158 | index: targetIndex 159 | } 160 | ] 161 | ] 162 | } 163 | }; 164 | } 165 | 166 | /** 167 | * Create a workflow from nodes with automatic connections 168 | * 169 | * Connects nodes in sequence: node1 -> node2 -> node3, etc. 170 | * 171 | * @param name - Workflow name 172 | * @param nodes - Array of nodes 173 | * @returns Partial workflow 174 | */ 175 | export function createSequentialWorkflow( 176 | name: string, 177 | nodes: WorkflowNode[] 178 | ): Partial<Workflow> { 179 | const connections: Record<string, any> = {}; 180 | 181 | // Create connections between sequential nodes 182 | for (let i = 0; i < nodes.length - 1; i++) { 183 | const currentNode = nodes[i]; 184 | const nextNode = nodes[i + 1]; 185 | 186 | connections[currentNode.name] = { 187 | main: [[{ node: nextNode.name, type: 'main', index: 0 }]] 188 | }; 189 | } 190 | 191 | return { 192 | name: createTestWorkflowName(name), 193 | nodes, 194 | connections, 195 | settings: { 196 | executionOrder: 'v1' 197 | } 198 | }; 199 | } 200 | 201 | /** 202 | * Create a workflow with parallel branches 203 | * 204 | * Creates a workflow with one trigger node that splits into multiple 205 | * parallel execution paths. 206 | * 207 | * @param name - Workflow name 208 | * @param trigger - Trigger node 209 | * @param branches - Array of branch nodes 210 | * @returns Partial workflow 211 | */ 212 | export function createParallelWorkflow( 213 | name: string, 214 | trigger: WorkflowNode, 215 | branches: WorkflowNode[] 216 | ): Partial<Workflow> { 217 | const connections: Record<string, any> = { 218 | [trigger.name]: { 219 | main: [branches.map(node => ({ node: node.name, type: 'main', index: 0 }))] 220 | } 221 | }; 222 | 223 | return { 224 | name: createTestWorkflowName(name), 225 | nodes: [trigger, ...branches], 226 | connections, 227 | settings: { 228 | executionOrder: 'v1' 229 | } 230 | }; 231 | } 232 | 233 | /** 234 | * Generate a random string for test data 235 | * 236 | * @param length - String length (default: 8) 237 | * @returns Random string 238 | */ 239 | export function randomString(length: number = 8): string { 240 | const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; 241 | let result = ''; 242 | for (let i = 0; i < length; i++) { 243 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 244 | } 245 | return result; 246 | } 247 | 248 | /** 249 | * Generate a unique ID for testing 250 | * 251 | * @param prefix - Optional prefix 252 | * @returns Unique ID 253 | */ 254 | export function uniqueId(prefix: string = 'test'): string { 255 | return `${prefix}-${Date.now()}-${randomString(4)}`; 256 | } 257 | 258 | /** 259 | * Create a workflow with error handling 260 | * 261 | * @param name - Workflow name 262 | * @param mainNode - Main processing node 263 | * @param errorNode - Error handling node 264 | * @returns Partial workflow with error handling configured 265 | */ 266 | export function createErrorHandlingWorkflow( 267 | name: string, 268 | mainNode: WorkflowNode, 269 | errorNode: WorkflowNode 270 | ): Partial<Workflow> { 271 | const trigger = createWebhookNode({ 272 | name: 'Trigger', 273 | position: [250, 300] 274 | }); 275 | 276 | // Configure main node for error handling 277 | const mainNodeWithError = { 278 | ...mainNode, 279 | continueOnFail: true, 280 | onError: 'continueErrorOutput' as const 281 | }; 282 | 283 | const connections: Record<string, any> = { 284 | [trigger.name]: { 285 | main: [[{ node: mainNode.name, type: 'main', index: 0 }]] 286 | }, 287 | [mainNode.name]: { 288 | error: [[{ node: errorNode.name, type: 'main', index: 0 }]] 289 | } 290 | }; 291 | 292 | return { 293 | name: createTestWorkflowName(name), 294 | nodes: [trigger, mainNodeWithError, errorNode], 295 | connections, 296 | settings: { 297 | executionOrder: 'v1' 298 | } 299 | }; 300 | } 301 | 302 | /** 303 | * Create test workflow tags 304 | * 305 | * @param additional - Additional tags to include 306 | * @returns Array of tags for test workflows 307 | */ 308 | export function createTestTags(additional: string[] = []): string[] { 309 | return ['mcp-integration-test', ...additional]; 310 | } 311 | 312 | /** 313 | * Create workflow settings with common test configurations 314 | * 315 | * @param overrides - Settings to override 316 | * @returns Workflow settings object 317 | */ 318 | export function createWorkflowSettings(overrides: Record<string, any> = {}): Record<string, any> { 319 | return { 320 | executionOrder: 'v1', 321 | saveDataErrorExecution: 'all', 322 | saveDataSuccessExecution: 'all', 323 | saveManualExecutions: true, 324 | ...overrides 325 | }; 326 | } 327 | ``` -------------------------------------------------------------------------------- /tests/http-server-auth.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { tmpdir } from 'os'; 4 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 5 | import type { MockedFunction } from 'vitest'; 6 | 7 | // Import the actual functions we'll be testing 8 | import { loadAuthToken, startFixedHTTPServer } from '../src/http-server'; 9 | 10 | // Mock dependencies 11 | vi.mock('../src/utils/logger', () => ({ 12 | logger: { 13 | info: vi.fn(), 14 | error: vi.fn(), 15 | warn: vi.fn(), 16 | debug: vi.fn() 17 | }, 18 | Logger: vi.fn().mockImplementation(() => ({ 19 | info: vi.fn(), 20 | error: vi.fn(), 21 | warn: vi.fn(), 22 | debug: vi.fn() 23 | })), 24 | LogLevel: { 25 | ERROR: 0, 26 | WARN: 1, 27 | INFO: 2, 28 | DEBUG: 3 29 | } 30 | })); 31 | 32 | vi.mock('dotenv'); 33 | 34 | // Mock other dependencies to prevent side effects 35 | vi.mock('../src/mcp/server', () => ({ 36 | N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({ 37 | executeTool: vi.fn() 38 | })) 39 | })); 40 | 41 | vi.mock('../src/mcp/tools', () => ({ 42 | n8nDocumentationToolsFinal: [] 43 | })); 44 | 45 | vi.mock('../src/mcp/tools-n8n-manager', () => ({ 46 | n8nManagementTools: [] 47 | })); 48 | 49 | vi.mock('../src/utils/version', () => ({ 50 | PROJECT_VERSION: '2.7.4' 51 | })); 52 | 53 | vi.mock('../src/config/n8n-api', () => ({ 54 | isN8nApiConfigured: vi.fn().mockReturnValue(false) 55 | })); 56 | 57 | vi.mock('../src/utils/url-detector', () => ({ 58 | getStartupBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'), 59 | formatEndpointUrls: vi.fn().mockReturnValue({ 60 | health: 'http://localhost:3000/health', 61 | mcp: 'http://localhost:3000/mcp' 62 | }), 63 | detectBaseUrl: vi.fn().mockReturnValue('http://localhost:3000') 64 | })); 65 | 66 | // Create mock server instance 67 | const mockServer = { 68 | on: vi.fn(), 69 | close: vi.fn((callback) => callback()) 70 | }; 71 | 72 | // Mock Express to prevent server from starting 73 | const mockExpressApp = { 74 | use: vi.fn(), 75 | get: vi.fn(), 76 | post: vi.fn(), 77 | listen: vi.fn((port: any, host: any, callback: any) => { 78 | // Call the callback immediately to simulate server start 79 | if (callback) callback(); 80 | return mockServer; 81 | }), 82 | set: vi.fn() 83 | }; 84 | 85 | vi.mock('express', () => { 86 | const express: any = vi.fn(() => mockExpressApp); 87 | express.json = vi.fn(); 88 | express.urlencoded = vi.fn(); 89 | express.static = vi.fn(); 90 | express.Request = {}; 91 | express.Response = {}; 92 | express.NextFunction = {}; 93 | return { default: express }; 94 | }); 95 | 96 | describe('HTTP Server Authentication', () => { 97 | const originalEnv = process.env; 98 | let tempDir: string; 99 | let authTokenFile: string; 100 | 101 | beforeEach(() => { 102 | // Reset modules and environment 103 | vi.clearAllMocks(); 104 | vi.resetModules(); 105 | process.env = { ...originalEnv }; 106 | 107 | // Create temporary directory for test files 108 | tempDir = join(tmpdir(), `http-server-auth-test-${Date.now()}`); 109 | mkdirSync(tempDir, { recursive: true }); 110 | authTokenFile = join(tempDir, 'auth-token'); 111 | }); 112 | 113 | afterEach(() => { 114 | // Restore original environment 115 | process.env = originalEnv; 116 | 117 | // Clean up temporary directory 118 | try { 119 | rmSync(tempDir, { recursive: true, force: true }); 120 | } catch (error) { 121 | // Ignore cleanup errors 122 | } 123 | }); 124 | 125 | describe('loadAuthToken', () => { 126 | it('should load token when AUTH_TOKEN environment variable is set', () => { 127 | process.env.AUTH_TOKEN = 'test-token-from-env'; 128 | delete process.env.AUTH_TOKEN_FILE; 129 | 130 | const token = loadAuthToken(); 131 | expect(token).toBe('test-token-from-env'); 132 | }); 133 | 134 | it('should load token from file when only AUTH_TOKEN_FILE is set', () => { 135 | delete process.env.AUTH_TOKEN; 136 | process.env.AUTH_TOKEN_FILE = authTokenFile; 137 | 138 | // Write test token to file 139 | writeFileSync(authTokenFile, 'test-token-from-file\n'); 140 | 141 | const token = loadAuthToken(); 142 | expect(token).toBe('test-token-from-file'); 143 | }); 144 | 145 | it('should trim whitespace when reading token from file', () => { 146 | delete process.env.AUTH_TOKEN; 147 | process.env.AUTH_TOKEN_FILE = authTokenFile; 148 | 149 | // Write token with whitespace 150 | writeFileSync(authTokenFile, ' test-token-with-spaces \n\n'); 151 | 152 | const token = loadAuthToken(); 153 | expect(token).toBe('test-token-with-spaces'); 154 | }); 155 | 156 | it('should prefer AUTH_TOKEN when both variables are set', () => { 157 | process.env.AUTH_TOKEN = 'env-token'; 158 | process.env.AUTH_TOKEN_FILE = authTokenFile; 159 | writeFileSync(authTokenFile, 'file-token'); 160 | 161 | const token = loadAuthToken(); 162 | expect(token).toBe('env-token'); 163 | }); 164 | 165 | it('should return null when AUTH_TOKEN_FILE points to non-existent file', async () => { 166 | delete process.env.AUTH_TOKEN; 167 | process.env.AUTH_TOKEN_FILE = join(tempDir, 'non-existent-file'); 168 | 169 | // Import logger to check calls 170 | const { logger } = await import('../src/utils/logger'); 171 | 172 | // Clear any previous mock calls 173 | vi.clearAllMocks(); 174 | 175 | const token = loadAuthToken(); 176 | expect(token).toBeNull(); 177 | expect(logger.error).toHaveBeenCalled(); 178 | const errorCall = (logger.error as MockedFunction<any>).mock.calls[0]; 179 | expect(errorCall[0]).toContain('Failed to read AUTH_TOKEN_FILE'); 180 | // Check that the second argument exists and is truthy (the error object) 181 | expect(errorCall[1]).toBeTruthy(); 182 | }); 183 | 184 | it('should return null when no auth variables are set', () => { 185 | delete process.env.AUTH_TOKEN; 186 | delete process.env.AUTH_TOKEN_FILE; 187 | 188 | const token = loadAuthToken(); 189 | expect(token).toBeNull(); 190 | }); 191 | }); 192 | 193 | describe('validateEnvironment', () => { 194 | it('should exit process when no auth token is available', async () => { 195 | delete process.env.AUTH_TOKEN; 196 | delete process.env.AUTH_TOKEN_FILE; 197 | 198 | const mockExit = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { 199 | throw new Error('Process exited'); 200 | }); 201 | 202 | // validateEnvironment is called when starting the server 203 | await expect(async () => { 204 | await startFixedHTTPServer(); 205 | }).rejects.toThrow('Process exited'); 206 | 207 | expect(mockExit).toHaveBeenCalledWith(1); 208 | mockExit.mockRestore(); 209 | }); 210 | 211 | it('should warn when token length is less than 32 characters', async () => { 212 | process.env.AUTH_TOKEN = 'short-token'; 213 | 214 | // Import logger to check calls 215 | const { logger } = await import('../src/utils/logger'); 216 | 217 | // Clear any previous mock calls 218 | vi.clearAllMocks(); 219 | 220 | // Ensure the mock server is properly configured 221 | mockExpressApp.listen.mockReturnValue(mockServer); 222 | mockServer.on.mockReturnValue(undefined); 223 | 224 | // Start the server which will trigger validateEnvironment 225 | await startFixedHTTPServer(); 226 | 227 | expect(logger.warn).toHaveBeenCalledWith( 228 | 'AUTH_TOKEN should be at least 32 characters for security' 229 | ); 230 | }); 231 | }); 232 | 233 | describe('Integration test scenarios', () => { 234 | it('should authenticate successfully when token is loaded from file', () => { 235 | // This is more of an integration test placeholder 236 | // In a real scenario, you'd start the server and make HTTP requests 237 | 238 | writeFileSync(authTokenFile, 'very-secure-token-with-more-than-32-characters'); 239 | process.env.AUTH_TOKEN_FILE = authTokenFile; 240 | delete process.env.AUTH_TOKEN; 241 | 242 | const token = loadAuthToken(); 243 | expect(token).toBe('very-secure-token-with-more-than-32-characters'); 244 | }); 245 | 246 | it('should load token when using Docker secrets pattern', () => { 247 | // Docker secrets are typically mounted at /run/secrets/ 248 | const dockerSecretPath = join(tempDir, 'run', 'secrets', 'auth_token'); 249 | mkdirSync(join(tempDir, 'run', 'secrets'), { recursive: true }); 250 | writeFileSync(dockerSecretPath, 'docker-secret-token'); 251 | 252 | process.env.AUTH_TOKEN_FILE = dockerSecretPath; 253 | delete process.env.AUTH_TOKEN; 254 | 255 | const token = loadAuthToken(); 256 | expect(token).toBe('docker-secret-token'); 257 | }); 258 | }); 259 | }); ``` -------------------------------------------------------------------------------- /src/n8n/MCPNode.node.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | IExecuteFunctions, 3 | INodeExecutionData, 4 | INodeType, 5 | INodeTypeDescription, 6 | NodeOperationError, 7 | } from 'n8n-workflow'; 8 | import { MCPClient } from '../utils/mcp-client'; 9 | import { N8NMCPBridge } from '../utils/bridge'; 10 | 11 | export class MCPNode implements INodeType { 12 | description: INodeTypeDescription = { 13 | displayName: 'MCP', 14 | name: 'mcp', 15 | icon: 'file:mcp.svg', 16 | group: ['transform'], 17 | version: 1, 18 | description: 'Interact with Model Context Protocol (MCP) servers', 19 | defaults: { 20 | name: 'MCP', 21 | }, 22 | inputs: ['main'], 23 | outputs: ['main'], 24 | credentials: [ 25 | { 26 | name: 'mcpApi', 27 | required: true, 28 | }, 29 | ], 30 | properties: [ 31 | { 32 | displayName: 'Operation', 33 | name: 'operation', 34 | type: 'options', 35 | noDataExpression: true, 36 | options: [ 37 | { 38 | name: 'Call Tool', 39 | value: 'callTool', 40 | description: 'Execute an MCP tool', 41 | }, 42 | { 43 | name: 'List Tools', 44 | value: 'listTools', 45 | description: 'List available MCP tools', 46 | }, 47 | { 48 | name: 'Read Resource', 49 | value: 'readResource', 50 | description: 'Read an MCP resource', 51 | }, 52 | { 53 | name: 'List Resources', 54 | value: 'listResources', 55 | description: 'List available MCP resources', 56 | }, 57 | { 58 | name: 'Get Prompt', 59 | value: 'getPrompt', 60 | description: 'Get an MCP prompt', 61 | }, 62 | { 63 | name: 'List Prompts', 64 | value: 'listPrompts', 65 | description: 'List available MCP prompts', 66 | }, 67 | ], 68 | default: 'callTool', 69 | }, 70 | // Tool-specific fields 71 | { 72 | displayName: 'Tool Name', 73 | name: 'toolName', 74 | type: 'string', 75 | required: true, 76 | displayOptions: { 77 | show: { 78 | operation: ['callTool'], 79 | }, 80 | }, 81 | default: '', 82 | description: 'Name of the MCP tool to execute', 83 | }, 84 | { 85 | displayName: 'Tool Arguments', 86 | name: 'toolArguments', 87 | type: 'json', 88 | required: false, 89 | displayOptions: { 90 | show: { 91 | operation: ['callTool'], 92 | }, 93 | }, 94 | default: '{}', 95 | description: 'Arguments to pass to the MCP tool', 96 | }, 97 | // Resource-specific fields 98 | { 99 | displayName: 'Resource URI', 100 | name: 'resourceUri', 101 | type: 'string', 102 | required: true, 103 | displayOptions: { 104 | show: { 105 | operation: ['readResource'], 106 | }, 107 | }, 108 | default: '', 109 | description: 'URI of the MCP resource to read', 110 | }, 111 | // Prompt-specific fields 112 | { 113 | displayName: 'Prompt Name', 114 | name: 'promptName', 115 | type: 'string', 116 | required: true, 117 | displayOptions: { 118 | show: { 119 | operation: ['getPrompt'], 120 | }, 121 | }, 122 | default: '', 123 | description: 'Name of the MCP prompt to retrieve', 124 | }, 125 | { 126 | displayName: 'Prompt Arguments', 127 | name: 'promptArguments', 128 | type: 'json', 129 | required: false, 130 | displayOptions: { 131 | show: { 132 | operation: ['getPrompt'], 133 | }, 134 | }, 135 | default: '{}', 136 | description: 'Arguments to pass to the MCP prompt', 137 | }, 138 | ], 139 | }; 140 | 141 | async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { 142 | const items = this.getInputData(); 143 | const returnData: INodeExecutionData[] = []; 144 | const operation = this.getNodeParameter('operation', 0) as string; 145 | 146 | // Get credentials 147 | const credentials = await this.getCredentials('mcpApi'); 148 | 149 | for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { 150 | try { 151 | let result: any; 152 | 153 | switch (operation) { 154 | case 'callTool': 155 | const toolName = this.getNodeParameter('toolName', itemIndex) as string; 156 | const toolArgumentsJson = this.getNodeParameter('toolArguments', itemIndex) as string; 157 | const toolArguments = JSON.parse(toolArgumentsJson); 158 | 159 | result = await (this as any).callMCPTool(credentials, toolName, toolArguments); 160 | break; 161 | 162 | case 'listTools': 163 | result = await (this as any).listMCPTools(credentials); 164 | break; 165 | 166 | case 'readResource': 167 | const resourceUri = this.getNodeParameter('resourceUri', itemIndex) as string; 168 | result = await (this as any).readMCPResource(credentials, resourceUri); 169 | break; 170 | 171 | case 'listResources': 172 | result = await (this as any).listMCPResources(credentials); 173 | break; 174 | 175 | case 'getPrompt': 176 | const promptName = this.getNodeParameter('promptName', itemIndex) as string; 177 | const promptArgumentsJson = this.getNodeParameter('promptArguments', itemIndex) as string; 178 | const promptArguments = JSON.parse(promptArgumentsJson); 179 | 180 | result = await (this as any).getMCPPrompt(credentials, promptName, promptArguments); 181 | break; 182 | 183 | case 'listPrompts': 184 | result = await (this as any).listMCPPrompts(credentials); 185 | break; 186 | 187 | default: 188 | throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`); 189 | } 190 | 191 | returnData.push({ 192 | json: result, 193 | pairedItem: itemIndex, 194 | }); 195 | } catch (error) { 196 | if (this.continueOnFail()) { 197 | returnData.push({ 198 | json: { 199 | error: error instanceof Error ? error.message : 'Unknown error', 200 | }, 201 | pairedItem: itemIndex, 202 | }); 203 | continue; 204 | } 205 | throw error; 206 | } 207 | } 208 | 209 | return [returnData]; 210 | } 211 | 212 | // MCP client methods 213 | private async getMCPClient(credentials: any): Promise<MCPClient> { 214 | const client = new MCPClient({ 215 | serverUrl: credentials.serverUrl, 216 | authToken: credentials.authToken, 217 | connectionType: credentials.connectionType || 'websocket', 218 | }); 219 | await client.connect(); 220 | return client; 221 | } 222 | 223 | private async callMCPTool(credentials: any, toolName: string, args: any): Promise<any> { 224 | const client = await this.getMCPClient(credentials); 225 | try { 226 | const result = await client.callTool(toolName, args); 227 | return N8NMCPBridge.mcpToN8NExecutionData(result).json; 228 | } finally { 229 | await client.disconnect(); 230 | } 231 | } 232 | 233 | private async listMCPTools(credentials: any): Promise<any> { 234 | const client = await this.getMCPClient(credentials); 235 | try { 236 | return await client.listTools(); 237 | } finally { 238 | await client.disconnect(); 239 | } 240 | } 241 | 242 | private async readMCPResource(credentials: any, uri: string): Promise<any> { 243 | const client = await this.getMCPClient(credentials); 244 | try { 245 | const result = await client.readResource(uri); 246 | return N8NMCPBridge.mcpToN8NExecutionData(result).json; 247 | } finally { 248 | await client.disconnect(); 249 | } 250 | } 251 | 252 | private async listMCPResources(credentials: any): Promise<any> { 253 | const client = await this.getMCPClient(credentials); 254 | try { 255 | return await client.listResources(); 256 | } finally { 257 | await client.disconnect(); 258 | } 259 | } 260 | 261 | private async getMCPPrompt(credentials: any, promptName: string, args: any): Promise<any> { 262 | const client = await this.getMCPClient(credentials); 263 | try { 264 | const result = await client.getPrompt(promptName, args); 265 | return N8NMCPBridge.mcpPromptArgsToN8N(result); 266 | } finally { 267 | await client.disconnect(); 268 | } 269 | } 270 | 271 | private async listMCPPrompts(credentials: any): Promise<any> { 272 | const client = await this.getMCPClient(credentials); 273 | try { 274 | return await client.listPrompts(); 275 | } finally { 276 | await client.disconnect(); 277 | } 278 | } 279 | } ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/utils/fixtures.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Workflow Fixtures for Integration Tests 3 | * 4 | * Provides reusable workflow templates for testing. 5 | * All fixtures use FULL node type format (n8n-nodes-base.*) 6 | * as required by the n8n API. 7 | */ 8 | 9 | import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api'; 10 | 11 | /** 12 | * Simple webhook workflow with a single Webhook node 13 | * 14 | * Use this for basic workflow creation tests. 15 | */ 16 | export const SIMPLE_WEBHOOK_WORKFLOW: Partial<Workflow> = { 17 | nodes: [ 18 | { 19 | id: 'webhook-1', 20 | name: 'Webhook', 21 | type: 'n8n-nodes-base.webhook', 22 | typeVersion: 2, 23 | position: [250, 300], 24 | parameters: { 25 | httpMethod: 'GET', 26 | path: 'test-webhook' 27 | } 28 | } 29 | ], 30 | connections: {}, 31 | settings: { 32 | executionOrder: 'v1' 33 | } 34 | }; 35 | 36 | /** 37 | * Simple HTTP request workflow 38 | * 39 | * Contains a Webhook trigger and an HTTP Request node. 40 | * Tests basic workflow connections. 41 | */ 42 | export const SIMPLE_HTTP_WORKFLOW: Partial<Workflow> = { 43 | nodes: [ 44 | { 45 | id: 'webhook-1', 46 | name: 'Webhook', 47 | type: 'n8n-nodes-base.webhook', 48 | typeVersion: 2, 49 | position: [250, 300], 50 | parameters: { 51 | httpMethod: 'GET', 52 | path: 'trigger' 53 | } 54 | }, 55 | { 56 | id: 'http-1', 57 | name: 'HTTP Request', 58 | type: 'n8n-nodes-base.httpRequest', 59 | typeVersion: 4.2, 60 | position: [450, 300], 61 | parameters: { 62 | url: 'https://httpbin.org/get', 63 | method: 'GET' 64 | } 65 | } 66 | ], 67 | connections: { 68 | Webhook: { 69 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] 70 | } 71 | }, 72 | settings: { 73 | executionOrder: 'v1' 74 | } 75 | }; 76 | 77 | /** 78 | * Multi-node workflow with branching 79 | * 80 | * Tests complex connections and multiple execution paths. 81 | */ 82 | export const MULTI_NODE_WORKFLOW: Partial<Workflow> = { 83 | nodes: [ 84 | { 85 | id: 'webhook-1', 86 | name: 'Webhook', 87 | type: 'n8n-nodes-base.webhook', 88 | typeVersion: 2, 89 | position: [250, 300], 90 | parameters: { 91 | httpMethod: 'POST', 92 | path: 'multi-node' 93 | } 94 | }, 95 | { 96 | id: 'set-1', 97 | name: 'Set 1', 98 | type: 'n8n-nodes-base.set', 99 | typeVersion: 3.4, 100 | position: [450, 200], 101 | parameters: { 102 | assignments: { 103 | assignments: [ 104 | { 105 | id: 'assign-1', 106 | name: 'branch', 107 | value: 'top', 108 | type: 'string' 109 | } 110 | ] 111 | }, 112 | options: {} 113 | } 114 | }, 115 | { 116 | id: 'set-2', 117 | name: 'Set 2', 118 | type: 'n8n-nodes-base.set', 119 | typeVersion: 3.4, 120 | position: [450, 400], 121 | parameters: { 122 | assignments: { 123 | assignments: [ 124 | { 125 | id: 'assign-2', 126 | name: 'branch', 127 | value: 'bottom', 128 | type: 'string' 129 | } 130 | ] 131 | }, 132 | options: {} 133 | } 134 | }, 135 | { 136 | id: 'merge-1', 137 | name: 'Merge', 138 | type: 'n8n-nodes-base.merge', 139 | typeVersion: 3, 140 | position: [650, 300], 141 | parameters: { 142 | mode: 'append', 143 | options: {} 144 | } 145 | } 146 | ], 147 | connections: { 148 | Webhook: { 149 | main: [ 150 | [ 151 | { node: 'Set 1', type: 'main', index: 0 }, 152 | { node: 'Set 2', type: 'main', index: 0 } 153 | ] 154 | ] 155 | }, 156 | 'Set 1': { 157 | main: [[{ node: 'Merge', type: 'main', index: 0 }]] 158 | }, 159 | 'Set 2': { 160 | main: [[{ node: 'Merge', type: 'main', index: 1 }]] 161 | } 162 | }, 163 | settings: { 164 | executionOrder: 'v1' 165 | } 166 | }; 167 | 168 | /** 169 | * Workflow with error handling 170 | * 171 | * Tests error output configuration and error workflows. 172 | */ 173 | export const ERROR_HANDLING_WORKFLOW: Partial<Workflow> = { 174 | nodes: [ 175 | { 176 | id: 'webhook-1', 177 | name: 'Webhook', 178 | type: 'n8n-nodes-base.webhook', 179 | typeVersion: 2, 180 | position: [250, 300], 181 | parameters: { 182 | httpMethod: 'GET', 183 | path: 'error-test' 184 | } 185 | }, 186 | { 187 | id: 'http-1', 188 | name: 'HTTP Request', 189 | type: 'n8n-nodes-base.httpRequest', 190 | typeVersion: 4.2, 191 | position: [450, 300], 192 | parameters: { 193 | url: 'https://httpbin.org/status/500', 194 | method: 'GET' 195 | }, 196 | continueOnFail: true, 197 | onError: 'continueErrorOutput' 198 | }, 199 | { 200 | id: 'set-error', 201 | name: 'Handle Error', 202 | type: 'n8n-nodes-base.set', 203 | typeVersion: 3.4, 204 | position: [650, 400], 205 | parameters: { 206 | assignments: { 207 | assignments: [ 208 | { 209 | id: 'error-assign', 210 | name: 'error_handled', 211 | value: 'true', 212 | type: 'boolean' 213 | } 214 | ] 215 | }, 216 | options: {} 217 | } 218 | } 219 | ], 220 | connections: { 221 | Webhook: { 222 | main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] 223 | }, 224 | 'HTTP Request': { 225 | main: [[{ node: 'Handle Error', type: 'main', index: 0 }]], 226 | error: [[{ node: 'Handle Error', type: 'main', index: 0 }]] 227 | } 228 | }, 229 | settings: { 230 | executionOrder: 'v1' 231 | } 232 | }; 233 | 234 | /** 235 | * AI Agent workflow (langchain nodes) 236 | * 237 | * Tests langchain node support. 238 | */ 239 | export const AI_AGENT_WORKFLOW: Partial<Workflow> = { 240 | nodes: [ 241 | { 242 | id: 'manual-1', 243 | name: 'When clicking "Test workflow"', 244 | type: 'n8n-nodes-base.manualTrigger', 245 | typeVersion: 1, 246 | position: [250, 300], 247 | parameters: {} 248 | }, 249 | { 250 | id: 'agent-1', 251 | name: 'AI Agent', 252 | type: '@n8n/n8n-nodes-langchain.agent', 253 | typeVersion: 1.7, 254 | position: [450, 300], 255 | parameters: { 256 | promptType: 'define', 257 | text: '={{ $json.input }}', 258 | options: {} 259 | } 260 | } 261 | ], 262 | connections: { 263 | 'When clicking "Test workflow"': { 264 | main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] 265 | } 266 | }, 267 | settings: { 268 | executionOrder: 'v1' 269 | } 270 | }; 271 | 272 | /** 273 | * Workflow with n8n expressions 274 | * 275 | * Tests expression validation. 276 | */ 277 | export const EXPRESSION_WORKFLOW: Partial<Workflow> = { 278 | nodes: [ 279 | { 280 | id: 'manual-1', 281 | name: 'Manual Trigger', 282 | type: 'n8n-nodes-base.manualTrigger', 283 | typeVersion: 1, 284 | position: [250, 300], 285 | parameters: {} 286 | }, 287 | { 288 | id: 'set-1', 289 | name: 'Set Variables', 290 | type: 'n8n-nodes-base.set', 291 | typeVersion: 3.4, 292 | position: [450, 300], 293 | parameters: { 294 | assignments: { 295 | assignments: [ 296 | { 297 | id: 'expr-1', 298 | name: 'timestamp', 299 | value: '={{ $now }}', 300 | type: 'string' 301 | }, 302 | { 303 | id: 'expr-2', 304 | name: 'item_count', 305 | value: '={{ $json.items.length }}', 306 | type: 'number' 307 | }, 308 | { 309 | id: 'expr-3', 310 | name: 'first_item', 311 | value: '={{ $node["Manual Trigger"].json }}', 312 | type: 'object' 313 | } 314 | ] 315 | }, 316 | options: {} 317 | } 318 | } 319 | ], 320 | connections: { 321 | 'Manual Trigger': { 322 | main: [[{ node: 'Set Variables', type: 'main', index: 0 }]] 323 | } 324 | }, 325 | settings: { 326 | executionOrder: 'v1' 327 | } 328 | }; 329 | 330 | /** 331 | * Get a fixture by name 332 | * 333 | * @param name - Fixture name 334 | * @returns Workflow fixture 335 | */ 336 | export function getFixture( 337 | name: 338 | | 'simple-webhook' 339 | | 'simple-http' 340 | | 'multi-node' 341 | | 'error-handling' 342 | | 'ai-agent' 343 | | 'expression' 344 | ): Partial<Workflow> { 345 | const fixtures = { 346 | 'simple-webhook': SIMPLE_WEBHOOK_WORKFLOW, 347 | 'simple-http': SIMPLE_HTTP_WORKFLOW, 348 | 'multi-node': MULTI_NODE_WORKFLOW, 349 | 'error-handling': ERROR_HANDLING_WORKFLOW, 350 | 'ai-agent': AI_AGENT_WORKFLOW, 351 | expression: EXPRESSION_WORKFLOW 352 | }; 353 | 354 | return JSON.parse(JSON.stringify(fixtures[name])); // Deep clone 355 | } 356 | 357 | /** 358 | * Create a minimal workflow with custom nodes 359 | * 360 | * @param nodes - Array of workflow nodes 361 | * @param connections - Optional connections object 362 | * @returns Workflow fixture 363 | */ 364 | export function createCustomWorkflow( 365 | nodes: WorkflowNode[], 366 | connections: Record<string, any> = {} 367 | ): Partial<Workflow> { 368 | return { 369 | nodes, 370 | connections, 371 | settings: { 372 | executionOrder: 'v1' 373 | } 374 | }; 375 | } 376 | ``` -------------------------------------------------------------------------------- /tests/integration/n8n-api/system/list-tools.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Integration Tests: handleListAvailableTools 3 | * 4 | * Tests tool listing functionality. 5 | * Covers tool discovery and configuration status. 6 | */ 7 | 8 | import { describe, it, expect, beforeEach } from 'vitest'; 9 | import { createMcpContext } from '../utils/mcp-context'; 10 | import { InstanceContext } from '../../../../src/types/instance-context'; 11 | import { handleListAvailableTools } from '../../../../src/mcp/handlers-n8n-manager'; 12 | import { ListToolsResponse } from '../utils/response-types'; 13 | 14 | describe('Integration: handleListAvailableTools', () => { 15 | let mcpContext: InstanceContext; 16 | 17 | beforeEach(() => { 18 | mcpContext = createMcpContext(); 19 | }); 20 | 21 | // ====================================================================== 22 | // List All Tools 23 | // ====================================================================== 24 | 25 | describe('Tool Listing', () => { 26 | it('should list all available tools organized by category', async () => { 27 | const response = await handleListAvailableTools(mcpContext); 28 | 29 | expect(response.success).toBe(true); 30 | expect(response.data).toBeDefined(); 31 | 32 | const data = response.data as ListToolsResponse; 33 | 34 | // Verify tools array exists 35 | expect(data).toHaveProperty('tools'); 36 | expect(Array.isArray(data.tools)).toBe(true); 37 | expect(data.tools.length).toBeGreaterThan(0); 38 | 39 | // Verify tool categories 40 | const categories = data.tools.map((cat: any) => cat.category); 41 | expect(categories).toContain('Workflow Management'); 42 | expect(categories).toContain('Execution Management'); 43 | expect(categories).toContain('System'); 44 | 45 | // Verify each category has tools 46 | data.tools.forEach(category => { 47 | expect(category).toHaveProperty('category'); 48 | expect(category).toHaveProperty('tools'); 49 | expect(Array.isArray(category.tools)).toBe(true); 50 | expect(category.tools.length).toBeGreaterThan(0); 51 | 52 | // Verify each tool has required fields 53 | category.tools.forEach(tool => { 54 | expect(tool).toHaveProperty('name'); 55 | expect(tool).toHaveProperty('description'); 56 | expect(typeof tool.name).toBe('string'); 57 | expect(typeof tool.description).toBe('string'); 58 | }); 59 | }); 60 | }); 61 | 62 | it('should include API configuration status', async () => { 63 | const response = await handleListAvailableTools(mcpContext); 64 | 65 | expect(response.success).toBe(true); 66 | const data = response.data as ListToolsResponse; 67 | 68 | // Verify configuration status 69 | expect(data).toHaveProperty('apiConfigured'); 70 | expect(typeof data.apiConfigured).toBe('boolean'); 71 | 72 | // Since tests run with API configured, should be true 73 | expect(data.apiConfigured).toBe(true); 74 | 75 | // Verify configuration details are present when configured 76 | if (data.apiConfigured) { 77 | expect(data).toHaveProperty('configuration'); 78 | expect(data.configuration).toBeDefined(); 79 | expect(data.configuration).toHaveProperty('apiUrl'); 80 | expect(data.configuration).toHaveProperty('timeout'); 81 | expect(data.configuration).toHaveProperty('maxRetries'); 82 | } 83 | }); 84 | 85 | it('should include API limitations information', async () => { 86 | const response = await handleListAvailableTools(mcpContext); 87 | 88 | expect(response.success).toBe(true); 89 | const data = response.data as ListToolsResponse; 90 | 91 | // Verify limitations are documented 92 | expect(data).toHaveProperty('limitations'); 93 | expect(Array.isArray(data.limitations)).toBe(true); 94 | expect(data.limitations.length).toBeGreaterThan(0); 95 | 96 | // Verify limitations are informative strings 97 | data.limitations.forEach(limitation => { 98 | expect(typeof limitation).toBe('string'); 99 | expect(limitation.length).toBeGreaterThan(0); 100 | }); 101 | 102 | // Common known limitations 103 | const limitationsText = data.limitations.join(' '); 104 | expect(limitationsText).toContain('Cannot activate'); 105 | expect(limitationsText).toContain('Cannot execute workflows directly'); 106 | }); 107 | }); 108 | 109 | // ====================================================================== 110 | // Workflow Management Tools 111 | // ====================================================================== 112 | 113 | describe('Workflow Management Tools', () => { 114 | it('should include all workflow management tools', async () => { 115 | const response = await handleListAvailableTools(mcpContext); 116 | const data = response.data as ListToolsResponse; 117 | 118 | const workflowCategory = data.tools.find(cat => cat.category === 'Workflow Management'); 119 | expect(workflowCategory).toBeDefined(); 120 | 121 | const toolNames = workflowCategory!.tools.map(t => t.name); 122 | 123 | // Core workflow tools 124 | expect(toolNames).toContain('n8n_create_workflow'); 125 | expect(toolNames).toContain('n8n_get_workflow'); 126 | expect(toolNames).toContain('n8n_update_workflow'); 127 | expect(toolNames).toContain('n8n_delete_workflow'); 128 | expect(toolNames).toContain('n8n_list_workflows'); 129 | 130 | // Enhanced workflow tools 131 | expect(toolNames).toContain('n8n_get_workflow_details'); 132 | expect(toolNames).toContain('n8n_get_workflow_structure'); 133 | expect(toolNames).toContain('n8n_get_workflow_minimal'); 134 | expect(toolNames).toContain('n8n_validate_workflow'); 135 | expect(toolNames).toContain('n8n_autofix_workflow'); 136 | }); 137 | }); 138 | 139 | // ====================================================================== 140 | // Execution Management Tools 141 | // ====================================================================== 142 | 143 | describe('Execution Management Tools', () => { 144 | it('should include all execution management tools', async () => { 145 | const response = await handleListAvailableTools(mcpContext); 146 | const data = response.data as ListToolsResponse; 147 | 148 | const executionCategory = data.tools.find(cat => cat.category === 'Execution Management'); 149 | expect(executionCategory).toBeDefined(); 150 | 151 | const toolNames = executionCategory!.tools.map(t => t.name); 152 | 153 | expect(toolNames).toContain('n8n_trigger_webhook_workflow'); 154 | expect(toolNames).toContain('n8n_get_execution'); 155 | expect(toolNames).toContain('n8n_list_executions'); 156 | expect(toolNames).toContain('n8n_delete_execution'); 157 | }); 158 | }); 159 | 160 | // ====================================================================== 161 | // System Tools 162 | // ====================================================================== 163 | 164 | describe('System Tools', () => { 165 | it('should include system tools', async () => { 166 | const response = await handleListAvailableTools(mcpContext); 167 | const data = response.data as ListToolsResponse; 168 | 169 | const systemCategory = data.tools.find(cat => cat.category === 'System'); 170 | expect(systemCategory).toBeDefined(); 171 | 172 | const toolNames = systemCategory!.tools.map(t => t.name); 173 | 174 | expect(toolNames).toContain('n8n_health_check'); 175 | expect(toolNames).toContain('n8n_list_available_tools'); 176 | }); 177 | }); 178 | 179 | // ====================================================================== 180 | // Response Format Verification 181 | // ====================================================================== 182 | 183 | describe('Response Format', () => { 184 | it('should return complete tool list response structure', async () => { 185 | const response = await handleListAvailableTools(mcpContext); 186 | 187 | expect(response.success).toBe(true); 188 | expect(response.data).toBeDefined(); 189 | 190 | const data = response.data as ListToolsResponse; 191 | 192 | // Verify all required fields 193 | expect(data).toHaveProperty('tools'); 194 | expect(data).toHaveProperty('apiConfigured'); 195 | expect(data).toHaveProperty('limitations'); 196 | 197 | // Verify optional configuration field 198 | if (data.apiConfigured) { 199 | expect(data).toHaveProperty('configuration'); 200 | } 201 | 202 | // Verify data types 203 | expect(Array.isArray(data.tools)).toBe(true); 204 | expect(typeof data.apiConfigured).toBe('boolean'); 205 | expect(Array.isArray(data.limitations)).toBe(true); 206 | }); 207 | }); 208 | }); 209 | ``` -------------------------------------------------------------------------------- /tests/unit/services/resource-similarity-service.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for ResourceSimilarityService 3 | */ 4 | 5 | import { describe, it, expect, beforeEach } from 'vitest'; 6 | import { ResourceSimilarityService } from '../../../src/services/resource-similarity-service'; 7 | import { NodeRepository } from '../../../src/database/node-repository'; 8 | import { createTestDatabase } from '../../utils/database-utils'; 9 | 10 | describe('ResourceSimilarityService', () => { 11 | let service: ResourceSimilarityService; 12 | let repository: NodeRepository; 13 | let testDb: any; 14 | 15 | beforeEach(async () => { 16 | testDb = await createTestDatabase(); 17 | repository = testDb.nodeRepository; 18 | service = new ResourceSimilarityService(repository); 19 | 20 | // Add test node with resources 21 | const testNode = { 22 | nodeType: 'nodes-base.googleDrive', 23 | packageName: 'n8n-nodes-base', 24 | displayName: 'Google Drive', 25 | description: 'Access Google Drive', 26 | category: 'transform', 27 | style: 'declarative' as const, 28 | isAITool: false, 29 | isTrigger: false, 30 | isWebhook: false, 31 | isVersioned: true, 32 | version: '1', 33 | properties: [ 34 | { 35 | name: 'resource', 36 | type: 'options', 37 | options: [ 38 | { value: 'file', name: 'File' }, 39 | { value: 'folder', name: 'Folder' }, 40 | { value: 'drive', name: 'Shared Drive' }, 41 | { value: 'fileFolder', name: 'File & Folder' } 42 | ] 43 | } 44 | ], 45 | operations: [], 46 | credentials: [] 47 | }; 48 | 49 | repository.saveNode(testNode); 50 | 51 | // Add Slack node for testing different patterns 52 | const slackNode = { 53 | nodeType: 'nodes-base.slack', 54 | packageName: 'n8n-nodes-base', 55 | displayName: 'Slack', 56 | description: 'Send messages to Slack', 57 | category: 'communication', 58 | style: 'declarative' as const, 59 | isAITool: false, 60 | isTrigger: false, 61 | isWebhook: false, 62 | isVersioned: true, 63 | version: '2', 64 | properties: [ 65 | { 66 | name: 'resource', 67 | type: 'options', 68 | options: [ 69 | { value: 'channel', name: 'Channel' }, 70 | { value: 'message', name: 'Message' }, 71 | { value: 'user', name: 'User' }, 72 | { value: 'file', name: 'File' }, 73 | { value: 'star', name: 'Star' } 74 | ] 75 | } 76 | ], 77 | operations: [], 78 | credentials: [] 79 | }; 80 | 81 | repository.saveNode(slackNode); 82 | }); 83 | 84 | afterEach(async () => { 85 | if (testDb) { 86 | await testDb.cleanup(); 87 | } 88 | }); 89 | 90 | describe('findSimilarResources', () => { 91 | it('should find exact match', () => { 92 | const suggestions = service.findSimilarResources( 93 | 'nodes-base.googleDrive', 94 | 'file', 95 | 5 96 | ); 97 | 98 | expect(suggestions).toHaveLength(0); // No suggestions for valid resource 99 | }); 100 | 101 | it('should suggest singular form for plural input', () => { 102 | const suggestions = service.findSimilarResources( 103 | 'nodes-base.googleDrive', 104 | 'files', 105 | 5 106 | ); 107 | 108 | expect(suggestions.length).toBeGreaterThan(0); 109 | expect(suggestions[0].value).toBe('file'); 110 | expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9); 111 | expect(suggestions[0].reason).toContain('singular'); 112 | }); 113 | 114 | it('should suggest singular form for folders', () => { 115 | const suggestions = service.findSimilarResources( 116 | 'nodes-base.googleDrive', 117 | 'folders', 118 | 5 119 | ); 120 | 121 | expect(suggestions.length).toBeGreaterThan(0); 122 | expect(suggestions[0].value).toBe('folder'); 123 | expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9); 124 | }); 125 | 126 | it('should handle typos with Levenshtein distance', () => { 127 | const suggestions = service.findSimilarResources( 128 | 'nodes-base.googleDrive', 129 | 'flie', 130 | 5 131 | ); 132 | 133 | expect(suggestions.length).toBeGreaterThan(0); 134 | expect(suggestions[0].value).toBe('file'); 135 | expect(suggestions[0].confidence).toBeGreaterThan(0.7); 136 | }); 137 | 138 | it('should handle combined resources', () => { 139 | const suggestions = service.findSimilarResources( 140 | 'nodes-base.googleDrive', 141 | 'fileAndFolder', 142 | 5 143 | ); 144 | 145 | expect(suggestions.length).toBeGreaterThan(0); 146 | // Should suggest 'fileFolder' (the actual combined resource) 147 | const fileFolderSuggestion = suggestions.find(s => s.value === 'fileFolder'); 148 | expect(fileFolderSuggestion).toBeDefined(); 149 | }); 150 | 151 | it('should return empty array for node not found', () => { 152 | const suggestions = service.findSimilarResources( 153 | 'nodes-base.nonexistent', 154 | 'resource', 155 | 5 156 | ); 157 | 158 | expect(suggestions).toEqual([]); 159 | }); 160 | }); 161 | 162 | describe('plural/singular detection', () => { 163 | it('should handle regular plurals (s)', () => { 164 | const suggestions = service.findSimilarResources( 165 | 'nodes-base.slack', 166 | 'channels', 167 | 5 168 | ); 169 | 170 | expect(suggestions.length).toBeGreaterThan(0); 171 | expect(suggestions[0].value).toBe('channel'); 172 | }); 173 | 174 | it('should handle plural ending in es', () => { 175 | const suggestions = service.findSimilarResources( 176 | 'nodes-base.slack', 177 | 'messages', 178 | 5 179 | ); 180 | 181 | expect(suggestions.length).toBeGreaterThan(0); 182 | expect(suggestions[0].value).toBe('message'); 183 | }); 184 | 185 | it('should handle plural ending in ies', () => { 186 | // Test with a hypothetical 'entities' -> 'entity' conversion 187 | const suggestions = service.findSimilarResources( 188 | 'nodes-base.googleDrive', 189 | 'entities', 190 | 5 191 | ); 192 | 193 | // Should not crash and provide some suggestions 194 | expect(suggestions).toBeDefined(); 195 | }); 196 | }); 197 | 198 | describe('node-specific patterns', () => { 199 | it('should apply Google Drive specific patterns', () => { 200 | const suggestions = service.findSimilarResources( 201 | 'nodes-base.googleDrive', 202 | 'sharedDrives', 203 | 5 204 | ); 205 | 206 | expect(suggestions.length).toBeGreaterThan(0); 207 | const driveSuggestion = suggestions.find(s => s.value === 'drive'); 208 | expect(driveSuggestion).toBeDefined(); 209 | }); 210 | 211 | it('should apply Slack specific patterns', () => { 212 | const suggestions = service.findSimilarResources( 213 | 'nodes-base.slack', 214 | 'users', 215 | 5 216 | ); 217 | 218 | expect(suggestions.length).toBeGreaterThan(0); 219 | expect(suggestions[0].value).toBe('user'); 220 | }); 221 | }); 222 | 223 | describe('similarity calculation', () => { 224 | it('should rank exact matches highest', () => { 225 | const suggestions = service.findSimilarResources( 226 | 'nodes-base.googleDrive', 227 | 'file', 228 | 5 229 | ); 230 | 231 | expect(suggestions).toHaveLength(0); // Exact match, no suggestions 232 | }); 233 | 234 | it('should rank substring matches high', () => { 235 | const suggestions = service.findSimilarResources( 236 | 'nodes-base.googleDrive', 237 | 'fil', 238 | 5 239 | ); 240 | 241 | expect(suggestions.length).toBeGreaterThan(0); 242 | const fileSuggestion = suggestions.find(s => s.value === 'file'); 243 | expect(fileSuggestion).toBeDefined(); 244 | expect(fileSuggestion!.confidence).toBeGreaterThanOrEqual(0.7); 245 | }); 246 | }); 247 | 248 | describe('caching', () => { 249 | it('should cache results for repeated queries', () => { 250 | // First call 251 | const suggestions1 = service.findSimilarResources( 252 | 'nodes-base.googleDrive', 253 | 'files', 254 | 5 255 | ); 256 | 257 | // Second call with same params 258 | const suggestions2 = service.findSimilarResources( 259 | 'nodes-base.googleDrive', 260 | 'files', 261 | 5 262 | ); 263 | 264 | expect(suggestions1).toEqual(suggestions2); 265 | }); 266 | 267 | it('should clear cache when requested', () => { 268 | // Add to cache 269 | service.findSimilarResources( 270 | 'nodes-base.googleDrive', 271 | 'test', 272 | 5 273 | ); 274 | 275 | // Clear cache 276 | service.clearCache(); 277 | 278 | // This would fetch fresh data (behavior is the same, just uncached) 279 | const suggestions = service.findSimilarResources( 280 | 'nodes-base.googleDrive', 281 | 'test', 282 | 5 283 | ); 284 | 285 | expect(suggestions).toBeDefined(); 286 | }); 287 | }); 288 | }); ``` -------------------------------------------------------------------------------- /tests/unit/__mocks__/n8n-nodes-base.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { getNodeTypes, mockNodeBehavior, resetAllMocks, registerMockNode } from './n8n-nodes-base'; 3 | 4 | describe('n8n-nodes-base mock', () => { 5 | beforeEach(() => { 6 | resetAllMocks(); 7 | }); 8 | 9 | describe('getNodeTypes', () => { 10 | it('should return node types registry', () => { 11 | const registry = getNodeTypes(); 12 | expect(registry).toBeDefined(); 13 | expect(registry.getByName).toBeDefined(); 14 | expect(registry.getByNameAndVersion).toBeDefined(); 15 | }); 16 | 17 | it('should retrieve webhook node', () => { 18 | const registry = getNodeTypes(); 19 | const webhookNode = registry.getByName('webhook'); 20 | 21 | expect(webhookNode).toBeDefined(); 22 | expect(webhookNode?.description.name).toBe('webhook'); 23 | expect(webhookNode?.description.group).toContain('trigger'); 24 | expect(webhookNode?.webhook).toBeDefined(); 25 | }); 26 | 27 | it('should retrieve httpRequest node', () => { 28 | const registry = getNodeTypes(); 29 | const httpNode = registry.getByName('httpRequest'); 30 | 31 | expect(httpNode).toBeDefined(); 32 | expect(httpNode?.description.name).toBe('httpRequest'); 33 | expect(httpNode?.description.version).toBe(3); 34 | expect(httpNode?.execute).toBeDefined(); 35 | }); 36 | 37 | it('should retrieve slack node', () => { 38 | const registry = getNodeTypes(); 39 | const slackNode = registry.getByName('slack'); 40 | 41 | expect(slackNode).toBeDefined(); 42 | expect(slackNode?.description.credentials).toHaveLength(1); 43 | expect(slackNode?.description.credentials?.[0].name).toBe('slackApi'); 44 | }); 45 | }); 46 | 47 | describe('node execution', () => { 48 | it('should execute webhook node', async () => { 49 | const registry = getNodeTypes(); 50 | const webhookNode = registry.getByName('webhook'); 51 | 52 | const mockContext = { 53 | getWebhookName: vi.fn(() => 'default'), 54 | getBodyData: vi.fn(() => ({ test: 'data' })), 55 | getHeaderData: vi.fn(() => ({ 'content-type': 'application/json' })), 56 | getQueryData: vi.fn(() => ({ query: 'param' })), 57 | getRequestObject: vi.fn(), 58 | getResponseObject: vi.fn(), 59 | helpers: { 60 | returnJsonArray: vi.fn((data) => [{ json: data }]), 61 | }, 62 | }; 63 | 64 | const result = await webhookNode?.webhook?.call(mockContext as any); 65 | 66 | expect(result).toBeDefined(); 67 | expect(result?.workflowData).toBeDefined(); 68 | expect(result?.workflowData[0]).toHaveLength(1); 69 | expect(result?.workflowData[0][0].json).toMatchObject({ 70 | headers: { 'content-type': 'application/json' }, 71 | params: { query: 'param' }, 72 | body: { test: 'data' }, 73 | }); 74 | }); 75 | 76 | it('should execute httpRequest node', async () => { 77 | const registry = getNodeTypes(); 78 | const httpNode = registry.getByName('httpRequest'); 79 | 80 | const mockContext = { 81 | getInputData: vi.fn(() => [{ json: { test: 'input' } }]), 82 | getNodeParameter: vi.fn((name: string) => { 83 | if (name === 'method') return 'POST'; 84 | if (name === 'url') return 'https://api.example.com'; 85 | return ''; 86 | }), 87 | getCredentials: vi.fn(), 88 | helpers: { 89 | returnJsonArray: vi.fn((data) => [{ json: data }]), 90 | httpRequest: vi.fn(), 91 | webhook: vi.fn(), 92 | }, 93 | }; 94 | 95 | const result = await httpNode?.execute?.call(mockContext as any); 96 | 97 | expect(result).toBeDefined(); 98 | expect(result!).toHaveLength(1); 99 | expect(result![0]).toHaveLength(1); 100 | expect(result![0][0].json).toMatchObject({ 101 | statusCode: 200, 102 | body: { 103 | success: true, 104 | method: 'POST', 105 | url: 'https://api.example.com', 106 | }, 107 | }); 108 | }); 109 | }); 110 | 111 | describe('mockNodeBehavior', () => { 112 | it('should override node execution behavior', async () => { 113 | const customExecute = vi.fn(async function() { 114 | return [[{ json: { custom: 'response' } }]]; 115 | }); 116 | 117 | mockNodeBehavior('httpRequest', { 118 | execute: customExecute, 119 | }); 120 | 121 | const registry = getNodeTypes(); 122 | const httpNode = registry.getByName('httpRequest'); 123 | 124 | const mockContext = { 125 | getInputData: vi.fn(() => []), 126 | getNodeParameter: vi.fn(), 127 | getCredentials: vi.fn(), 128 | helpers: { 129 | returnJsonArray: vi.fn(), 130 | httpRequest: vi.fn(), 131 | webhook: vi.fn(), 132 | }, 133 | }; 134 | 135 | const result = await httpNode?.execute?.call(mockContext as any); 136 | 137 | expect(customExecute).toHaveBeenCalled(); 138 | expect(result).toEqual([[{ json: { custom: 'response' } }]]); 139 | }); 140 | 141 | it('should override node description', () => { 142 | mockNodeBehavior('slack', { 143 | description: { 144 | displayName: 'Custom Slack', 145 | version: 3, 146 | name: 'slack', 147 | group: ['output'], 148 | description: 'Send messages to Slack', 149 | defaults: { name: 'Slack' }, 150 | inputs: ['main'], 151 | outputs: ['main'], 152 | properties: [], 153 | }, 154 | }); 155 | 156 | const registry = getNodeTypes(); 157 | const slackNode = registry.getByName('slack'); 158 | 159 | expect(slackNode?.description.displayName).toBe('Custom Slack'); 160 | expect(slackNode?.description.version).toBe(3); 161 | expect(slackNode?.description.name).toBe('slack'); // Original preserved 162 | }); 163 | }); 164 | 165 | describe('registerMockNode', () => { 166 | it('should register custom node', () => { 167 | const customNode = { 168 | description: { 169 | displayName: 'Custom Node', 170 | name: 'customNode', 171 | group: ['transform'], 172 | version: 1, 173 | description: 'A custom test node', 174 | defaults: { name: 'Custom' }, 175 | inputs: ['main'], 176 | outputs: ['main'], 177 | properties: [], 178 | }, 179 | execute: vi.fn(async function() { 180 | return [[{ json: { custom: true } }]]; 181 | }), 182 | }; 183 | 184 | registerMockNode('customNode', customNode); 185 | 186 | const registry = getNodeTypes(); 187 | const retrievedNode = registry.getByName('customNode'); 188 | 189 | expect(retrievedNode).toBe(customNode); 190 | expect(retrievedNode?.description.name).toBe('customNode'); 191 | }); 192 | }); 193 | 194 | describe('conditional nodes', () => { 195 | it('should execute if node with two outputs', async () => { 196 | const registry = getNodeTypes(); 197 | const ifNode = registry.getByName('if'); 198 | 199 | const mockContext = { 200 | getInputData: vi.fn(() => [ 201 | { json: { value: 1 } }, 202 | { json: { value: 2 } }, 203 | { json: { value: 3 } }, 204 | { json: { value: 4 } }, 205 | ]), 206 | getNodeParameter: vi.fn(), 207 | getCredentials: vi.fn(), 208 | helpers: { 209 | returnJsonArray: vi.fn(), 210 | httpRequest: vi.fn(), 211 | webhook: vi.fn(), 212 | }, 213 | }; 214 | 215 | const result = await ifNode?.execute?.call(mockContext as any); 216 | 217 | expect(result!).toHaveLength(2); // true and false outputs 218 | expect(result![0]).toHaveLength(2); // even indices 219 | expect(result![1]).toHaveLength(2); // odd indices 220 | }); 221 | 222 | it('should execute switch node with multiple outputs', async () => { 223 | const registry = getNodeTypes(); 224 | const switchNode = registry.getByName('switch'); 225 | 226 | const mockContext = { 227 | getInputData: vi.fn(() => [ 228 | { json: { value: 1 } }, 229 | { json: { value: 2 } }, 230 | { json: { value: 3 } }, 231 | { json: { value: 4 } }, 232 | ]), 233 | getNodeParameter: vi.fn(), 234 | getCredentials: vi.fn(), 235 | helpers: { 236 | returnJsonArray: vi.fn(), 237 | httpRequest: vi.fn(), 238 | webhook: vi.fn(), 239 | }, 240 | }; 241 | 242 | const result = await switchNode?.execute?.call(mockContext as any); 243 | 244 | expect(result!).toHaveLength(4); // 4 outputs 245 | expect(result![0]).toHaveLength(1); // item 0 246 | expect(result![1]).toHaveLength(1); // item 1 247 | expect(result![2]).toHaveLength(1); // item 2 248 | expect(result![3]).toHaveLength(1); // item 3 249 | }); 250 | }); 251 | }); ``` -------------------------------------------------------------------------------- /src/types/n8n-api.ts: -------------------------------------------------------------------------------- ```typescript 1 | // n8n API Types - Ported from n8n-manager-for-ai-agents 2 | // These types define the structure of n8n API requests and responses 3 | 4 | // Resource Locator Types 5 | export interface ResourceLocatorValue { 6 | __rl: true; 7 | value: string; 8 | mode: 'id' | 'url' | 'expression' | string; 9 | } 10 | 11 | // Expression Format Types 12 | export type ExpressionValue = string | ResourceLocatorValue; 13 | 14 | // Workflow Node Types 15 | export interface WorkflowNode { 16 | id: string; 17 | name: string; 18 | type: string; 19 | typeVersion: number; 20 | position: [number, number]; 21 | parameters: Record<string, unknown>; 22 | credentials?: Record<string, unknown>; 23 | disabled?: boolean; 24 | notes?: string; 25 | notesInFlow?: boolean; 26 | continueOnFail?: boolean; 27 | onError?: 'continueRegularOutput' | 'continueErrorOutput' | 'stopWorkflow'; 28 | retryOnFail?: boolean; 29 | maxTries?: number; 30 | waitBetweenTries?: number; 31 | alwaysOutputData?: boolean; 32 | executeOnce?: boolean; 33 | } 34 | 35 | export interface WorkflowConnection { 36 | [sourceNodeId: string]: { 37 | [outputType: string]: Array<Array<{ 38 | node: string; 39 | type: string; 40 | index: number; 41 | }>>; 42 | }; 43 | } 44 | 45 | export interface WorkflowSettings { 46 | executionOrder?: 'v0' | 'v1'; 47 | timezone?: string; 48 | saveDataErrorExecution?: 'all' | 'none'; 49 | saveDataSuccessExecution?: 'all' | 'none'; 50 | saveManualExecutions?: boolean; 51 | saveExecutionProgress?: boolean; 52 | executionTimeout?: number; 53 | errorWorkflow?: string; 54 | } 55 | 56 | export interface Workflow { 57 | id?: string; 58 | name: string; 59 | nodes: WorkflowNode[]; 60 | connections: WorkflowConnection; 61 | active?: boolean; // Optional for creation as it's read-only 62 | isArchived?: boolean; // Optional, available in newer n8n versions 63 | settings?: WorkflowSettings; 64 | staticData?: Record<string, unknown>; 65 | tags?: string[]; 66 | updatedAt?: string; 67 | createdAt?: string; 68 | versionId?: string; 69 | meta?: { 70 | instanceId?: string; 71 | }; 72 | } 73 | 74 | // Execution Types 75 | export enum ExecutionStatus { 76 | SUCCESS = 'success', 77 | ERROR = 'error', 78 | WAITING = 'waiting', 79 | // Note: 'running' status is not returned by the API 80 | } 81 | 82 | export interface ExecutionSummary { 83 | id: string; 84 | finished: boolean; 85 | mode: string; 86 | retryOf?: string; 87 | retrySuccessId?: string; 88 | status: ExecutionStatus; 89 | startedAt: string; 90 | stoppedAt?: string; 91 | workflowId: string; 92 | workflowName?: string; 93 | waitTill?: string; 94 | } 95 | 96 | export interface ExecutionData { 97 | startData?: Record<string, unknown>; 98 | resultData: { 99 | runData: Record<string, unknown>; 100 | lastNodeExecuted?: string; 101 | error?: Record<string, unknown>; 102 | }; 103 | executionData?: Record<string, unknown>; 104 | } 105 | 106 | export interface Execution extends ExecutionSummary { 107 | data?: ExecutionData; 108 | } 109 | 110 | // Credential Types 111 | export interface Credential { 112 | id?: string; 113 | name: string; 114 | type: string; 115 | data?: Record<string, unknown>; 116 | nodesAccess?: Array<{ 117 | nodeType: string; 118 | date?: string; 119 | }>; 120 | createdAt?: string; 121 | updatedAt?: string; 122 | } 123 | 124 | // Tag Types 125 | export interface Tag { 126 | id?: string; 127 | name: string; 128 | workflowIds?: string[]; 129 | createdAt?: string; 130 | updatedAt?: string; 131 | } 132 | 133 | // Variable Types 134 | export interface Variable { 135 | id?: string; 136 | key: string; 137 | value: string; 138 | type?: 'string'; 139 | } 140 | 141 | // Import/Export Types 142 | export interface WorkflowExport { 143 | id: string; 144 | name: string; 145 | active: boolean; 146 | createdAt: string; 147 | updatedAt: string; 148 | nodes: WorkflowNode[]; 149 | connections: WorkflowConnection; 150 | settings?: WorkflowSettings; 151 | staticData?: Record<string, unknown>; 152 | tags?: string[]; 153 | pinData?: Record<string, unknown>; 154 | versionId?: string; 155 | meta?: Record<string, unknown>; 156 | } 157 | 158 | export interface WorkflowImport { 159 | name: string; 160 | nodes: WorkflowNode[]; 161 | connections: WorkflowConnection; 162 | settings?: WorkflowSettings; 163 | staticData?: Record<string, unknown>; 164 | tags?: string[]; 165 | pinData?: Record<string, unknown>; 166 | } 167 | 168 | // Source Control Types 169 | export interface SourceControlStatus { 170 | ahead: number; 171 | behind: number; 172 | conflicted: string[]; 173 | created: string[]; 174 | current: string; 175 | deleted: string[]; 176 | detached: boolean; 177 | files: Array<{ 178 | path: string; 179 | status: string; 180 | }>; 181 | modified: string[]; 182 | notAdded: string[]; 183 | renamed: Array<{ 184 | from: string; 185 | to: string; 186 | }>; 187 | staged: string[]; 188 | tracking: string; 189 | } 190 | 191 | export interface SourceControlPullResult { 192 | conflicts: string[]; 193 | files: Array<{ 194 | path: string; 195 | status: string; 196 | }>; 197 | mergeConflicts: boolean; 198 | pullResult: 'success' | 'conflict' | 'error'; 199 | } 200 | 201 | export interface SourceControlPushResult { 202 | ahead: number; 203 | conflicts: string[]; 204 | files: Array<{ 205 | path: string; 206 | status: string; 207 | }>; 208 | pushResult: 'success' | 'conflict' | 'error'; 209 | } 210 | 211 | // Health Check Types 212 | export interface HealthCheckResponse { 213 | status: 'ok' | 'error'; 214 | instanceId?: string; 215 | n8nVersion?: string; 216 | features?: { 217 | sourceControl?: boolean; 218 | externalHooks?: boolean; 219 | workers?: boolean; 220 | [key: string]: boolean | undefined; 221 | }; 222 | } 223 | 224 | // Request Parameter Types 225 | export interface WorkflowListParams { 226 | limit?: number; 227 | cursor?: string; 228 | active?: boolean; 229 | tags?: string | null; // Comma-separated string per n8n API spec 230 | projectId?: string; 231 | excludePinnedData?: boolean; 232 | instance?: string; 233 | } 234 | 235 | export interface WorkflowListResponse { 236 | data: Workflow[]; 237 | nextCursor?: string | null; 238 | } 239 | 240 | export interface ExecutionListParams { 241 | limit?: number; 242 | cursor?: string; 243 | workflowId?: string; 244 | projectId?: string; 245 | status?: ExecutionStatus; 246 | includeData?: boolean; 247 | } 248 | 249 | export interface ExecutionListResponse { 250 | data: Execution[]; 251 | nextCursor?: string | null; 252 | } 253 | 254 | export interface CredentialListParams { 255 | limit?: number; 256 | cursor?: string; 257 | filter?: Record<string, unknown>; 258 | } 259 | 260 | export interface CredentialListResponse { 261 | data: Credential[]; 262 | nextCursor?: string | null; 263 | } 264 | 265 | export interface TagListParams { 266 | limit?: number; 267 | cursor?: string; 268 | withUsageCount?: boolean; 269 | } 270 | 271 | export interface TagListResponse { 272 | data: Tag[]; 273 | nextCursor?: string | null; 274 | } 275 | 276 | // Webhook Request Type 277 | export interface WebhookRequest { 278 | webhookUrl: string; 279 | httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; 280 | data?: Record<string, unknown>; 281 | headers?: Record<string, string>; 282 | waitForResponse?: boolean; 283 | } 284 | 285 | // MCP Tool Response Type 286 | export interface McpToolResponse { 287 | success: boolean; 288 | data?: unknown; 289 | error?: string; 290 | message?: string; 291 | code?: string; 292 | details?: Record<string, unknown>; 293 | executionId?: string; 294 | workflowId?: string; 295 | } 296 | 297 | // Execution Filtering Types 298 | export type ExecutionMode = 'preview' | 'summary' | 'filtered' | 'full'; 299 | 300 | export interface ExecutionPreview { 301 | totalNodes: number; 302 | executedNodes: number; 303 | estimatedSizeKB: number; 304 | nodes: Record<string, NodePreview>; 305 | } 306 | 307 | export interface NodePreview { 308 | status: 'success' | 'error'; 309 | itemCounts: { 310 | input: number; 311 | output: number; 312 | }; 313 | dataStructure: Record<string, any>; 314 | estimatedSizeKB: number; 315 | error?: string; 316 | } 317 | 318 | export interface ExecutionRecommendation { 319 | canFetchFull: boolean; 320 | suggestedMode: ExecutionMode; 321 | suggestedItemsLimit?: number; 322 | reason: string; 323 | } 324 | 325 | export interface ExecutionFilterOptions { 326 | mode?: ExecutionMode; 327 | nodeNames?: string[]; 328 | itemsLimit?: number; 329 | includeInputData?: boolean; 330 | fieldsToInclude?: string[]; 331 | } 332 | 333 | export interface FilteredExecutionResponse { 334 | id: string; 335 | workflowId: string; 336 | status: ExecutionStatus; 337 | mode: ExecutionMode; 338 | startedAt: string; 339 | stoppedAt?: string; 340 | duration?: number; 341 | finished: boolean; 342 | 343 | // Preview-specific data 344 | preview?: ExecutionPreview; 345 | recommendation?: ExecutionRecommendation; 346 | 347 | // Summary/Filtered data 348 | summary?: { 349 | totalNodes: number; 350 | executedNodes: number; 351 | totalItems: number; 352 | hasMoreData: boolean; 353 | }; 354 | nodes?: Record<string, FilteredNodeData>; 355 | 356 | // Error information 357 | error?: Record<string, unknown>; 358 | } 359 | 360 | export interface FilteredNodeData { 361 | executionTime?: number; 362 | itemsInput: number; 363 | itemsOutput: number; 364 | status: 'success' | 'error'; 365 | error?: string; 366 | data?: { 367 | input?: any[][]; 368 | output?: any[][]; 369 | metadata: { 370 | totalItems: number; 371 | itemsShown: number; 372 | truncated: boolean; 373 | }; 374 | }; 375 | } ``` -------------------------------------------------------------------------------- /tests/mocks/n8n-api/handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { http, HttpResponse, RequestHandler } from 'msw'; 2 | import { mockWorkflows } from './data/workflows'; 3 | import { mockExecutions } from './data/executions'; 4 | import { mockCredentials } from './data/credentials'; 5 | 6 | // Base URL for n8n API (will be overridden by actual URL in tests) 7 | const API_BASE = process.env.N8N_API_URL || 'http://localhost:5678'; 8 | 9 | /** 10 | * Default handlers for n8n API endpoints 11 | * These can be overridden in specific tests using server.use() 12 | */ 13 | export const handlers: RequestHandler[] = [ 14 | // Health check endpoint 15 | http.get('*/api/v1/health', () => { 16 | return HttpResponse.json({ 17 | status: 'ok', 18 | version: '1.103.2', 19 | features: { 20 | workflows: true, 21 | executions: true, 22 | credentials: true, 23 | webhooks: true, 24 | } 25 | }); 26 | }), 27 | 28 | // Workflow endpoints 29 | http.get('*/api/v1/workflows', ({ request }) => { 30 | const url = new URL(request.url); 31 | const limit = parseInt(url.searchParams.get('limit') || '100'); 32 | const cursor = url.searchParams.get('cursor'); 33 | const active = url.searchParams.get('active'); 34 | 35 | let filtered = mockWorkflows; 36 | 37 | // Filter by active status if provided 38 | if (active !== null) { 39 | filtered = filtered.filter(w => w.active === (active === 'true')); 40 | } 41 | 42 | // Simple pagination simulation 43 | const startIndex = cursor ? parseInt(cursor) : 0; 44 | const paginatedData = filtered.slice(startIndex, startIndex + limit); 45 | const hasMore = startIndex + limit < filtered.length; 46 | const nextCursor = hasMore ? String(startIndex + limit) : null; 47 | 48 | return HttpResponse.json({ 49 | data: paginatedData, 50 | nextCursor, 51 | hasMore 52 | }); 53 | }), 54 | 55 | http.get('*/api/v1/workflows/:id', ({ params }) => { 56 | const workflow = mockWorkflows.find(w => w.id === params.id); 57 | 58 | if (!workflow) { 59 | return HttpResponse.json( 60 | { message: 'Workflow not found', code: 'NOT_FOUND' }, 61 | { status: 404 } 62 | ); 63 | } 64 | 65 | return HttpResponse.json({ data: workflow }); 66 | }), 67 | 68 | http.post('*/api/v1/workflows', async ({ request }) => { 69 | const body = await request.json() as any; 70 | 71 | // Validate required fields 72 | if (!body.name || !body.nodes || !body.connections) { 73 | return HttpResponse.json( 74 | { 75 | message: 'Validation failed', 76 | errors: { 77 | name: !body.name ? 'Name is required' : undefined, 78 | nodes: !body.nodes ? 'Nodes are required' : undefined, 79 | connections: !body.connections ? 'Connections are required' : undefined, 80 | }, 81 | code: 'VALIDATION_ERROR' 82 | }, 83 | { status: 400 } 84 | ); 85 | } 86 | 87 | const newWorkflow = { 88 | id: `workflow_${Date.now()}`, 89 | name: body.name, 90 | active: body.active || false, 91 | nodes: body.nodes, 92 | connections: body.connections, 93 | settings: body.settings || {}, 94 | tags: body.tags || [], 95 | createdAt: new Date().toISOString(), 96 | updatedAt: new Date().toISOString(), 97 | versionId: '1' 98 | }; 99 | 100 | mockWorkflows.push(newWorkflow); 101 | 102 | return HttpResponse.json({ data: newWorkflow }, { status: 201 }); 103 | }), 104 | 105 | http.patch('*/api/v1/workflows/:id', async ({ params, request }) => { 106 | const workflowIndex = mockWorkflows.findIndex(w => w.id === params.id); 107 | 108 | if (workflowIndex === -1) { 109 | return HttpResponse.json( 110 | { message: 'Workflow not found', code: 'NOT_FOUND' }, 111 | { status: 404 } 112 | ); 113 | } 114 | 115 | const body = await request.json() as any; 116 | const updatedWorkflow = { 117 | ...mockWorkflows[workflowIndex], 118 | ...body, 119 | id: params.id, // Ensure ID doesn't change 120 | updatedAt: new Date().toISOString(), 121 | versionId: String(parseInt(mockWorkflows[workflowIndex].versionId) + 1) 122 | }; 123 | 124 | mockWorkflows[workflowIndex] = updatedWorkflow; 125 | 126 | return HttpResponse.json({ data: updatedWorkflow }); 127 | }), 128 | 129 | http.delete('*/api/v1/workflows/:id', ({ params }) => { 130 | const workflowIndex = mockWorkflows.findIndex(w => w.id === params.id); 131 | 132 | if (workflowIndex === -1) { 133 | return HttpResponse.json( 134 | { message: 'Workflow not found', code: 'NOT_FOUND' }, 135 | { status: 404 } 136 | ); 137 | } 138 | 139 | mockWorkflows.splice(workflowIndex, 1); 140 | 141 | return HttpResponse.json({ success: true }); 142 | }), 143 | 144 | // Execution endpoints 145 | http.get('*/api/v1/executions', ({ request }) => { 146 | const url = new URL(request.url); 147 | const limit = parseInt(url.searchParams.get('limit') || '100'); 148 | const cursor = url.searchParams.get('cursor'); 149 | const workflowId = url.searchParams.get('workflowId'); 150 | const status = url.searchParams.get('status'); 151 | 152 | let filtered = mockExecutions; 153 | 154 | // Filter by workflow ID if provided 155 | if (workflowId) { 156 | filtered = filtered.filter(e => e.workflowId === workflowId); 157 | } 158 | 159 | // Filter by status if provided 160 | if (status) { 161 | filtered = filtered.filter(e => e.status === status); 162 | } 163 | 164 | // Simple pagination simulation 165 | const startIndex = cursor ? parseInt(cursor) : 0; 166 | const paginatedData = filtered.slice(startIndex, startIndex + limit); 167 | const hasMore = startIndex + limit < filtered.length; 168 | const nextCursor = hasMore ? String(startIndex + limit) : null; 169 | 170 | return HttpResponse.json({ 171 | data: paginatedData, 172 | nextCursor, 173 | hasMore 174 | }); 175 | }), 176 | 177 | http.get('*/api/v1/executions/:id', ({ params }) => { 178 | const execution = mockExecutions.find(e => e.id === params.id); 179 | 180 | if (!execution) { 181 | return HttpResponse.json( 182 | { message: 'Execution not found', code: 'NOT_FOUND' }, 183 | { status: 404 } 184 | ); 185 | } 186 | 187 | return HttpResponse.json({ data: execution }); 188 | }), 189 | 190 | http.delete('*/api/v1/executions/:id', ({ params }) => { 191 | const executionIndex = mockExecutions.findIndex(e => e.id === params.id); 192 | 193 | if (executionIndex === -1) { 194 | return HttpResponse.json( 195 | { message: 'Execution not found', code: 'NOT_FOUND' }, 196 | { status: 404 } 197 | ); 198 | } 199 | 200 | mockExecutions.splice(executionIndex, 1); 201 | 202 | return HttpResponse.json({ success: true }); 203 | }), 204 | 205 | // Webhook endpoints (dynamic handling) 206 | http.all('*/webhook/*', async ({ request }) => { 207 | const url = new URL(request.url); 208 | const method = request.method; 209 | const body = request.body ? await request.json() : undefined; 210 | 211 | // Log webhook trigger in debug mode 212 | if (process.env.MSW_DEBUG === 'true') { 213 | console.log('[MSW] Webhook triggered:', { 214 | url: url.pathname, 215 | method, 216 | body 217 | }); 218 | } 219 | 220 | // Return success response by default 221 | return HttpResponse.json({ 222 | success: true, 223 | webhookUrl: url.pathname, 224 | method, 225 | timestamp: new Date().toISOString(), 226 | data: body 227 | }); 228 | }), 229 | 230 | // Catch-all for unhandled API routes (helps identify missing handlers) 231 | http.all('*/api/*', ({ request }) => { 232 | console.warn('[MSW] Unhandled API request:', request.method, request.url); 233 | 234 | return HttpResponse.json( 235 | { 236 | message: 'Not implemented in mock', 237 | code: 'NOT_IMPLEMENTED', 238 | path: new URL(request.url).pathname, 239 | method: request.method 240 | }, 241 | { status: 501 } 242 | ); 243 | }), 244 | ]; 245 | 246 | /** 247 | * Dynamic handler registration helpers 248 | */ 249 | export const dynamicHandlers = { 250 | /** 251 | * Add a workflow that will be returned by GET requests 252 | */ 253 | addWorkflow: (workflow: any) => { 254 | mockWorkflows.push(workflow); 255 | }, 256 | 257 | /** 258 | * Clear all mock workflows 259 | */ 260 | clearWorkflows: () => { 261 | mockWorkflows.length = 0; 262 | }, 263 | 264 | /** 265 | * Add an execution that will be returned by GET requests 266 | */ 267 | addExecution: (execution: any) => { 268 | mockExecutions.push(execution); 269 | }, 270 | 271 | /** 272 | * Clear all mock executions 273 | */ 274 | clearExecutions: () => { 275 | mockExecutions.length = 0; 276 | }, 277 | 278 | /** 279 | * Reset all mock data to initial state 280 | */ 281 | resetAll: () => { 282 | // Reset arrays to initial state (implementation depends on data modules) 283 | mockWorkflows.length = 0; 284 | mockExecutions.length = 0; 285 | mockCredentials.length = 0; 286 | } 287 | }; ```