This is page 37 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 │ │ │ ├── sqljs-memory-leak.test.ts │ │ │ ├── template-node-configs.test.ts │ │ │ ├── template-repository.test.ts │ │ │ ├── test-utils.ts │ │ │ └── transactions.test.ts │ │ ├── database-integration.test.ts │ │ ├── docker │ │ │ ├── docker-config.test.ts │ │ │ ├── docker-entrypoint.test.ts │ │ │ └── test-helpers.ts │ │ ├── flexible-instance-config.test.ts │ │ ├── mcp │ │ │ └── template-examples-e2e.test.ts │ │ ├── mcp-protocol │ │ │ ├── basic-connection.test.ts │ │ │ ├── error-handling.test.ts │ │ │ ├── performance.test.ts │ │ │ ├── protocol-compliance.test.ts │ │ │ ├── README.md │ │ │ ├── session-management.test.ts │ │ │ ├── test-helpers.ts │ │ │ ├── tool-invocation.test.ts │ │ │ └── workflow-error-validation.test.ts │ │ ├── msw-setup.test.ts │ │ ├── n8n-api │ │ │ ├── executions │ │ │ │ ├── delete-execution.test.ts │ │ │ │ ├── get-execution.test.ts │ │ │ │ ├── list-executions.test.ts │ │ │ │ └── trigger-webhook.test.ts │ │ │ ├── scripts │ │ │ │ └── cleanup-orphans.ts │ │ │ ├── system │ │ │ │ ├── diagnostic.test.ts │ │ │ │ ├── health-check.test.ts │ │ │ │ └── list-tools.test.ts │ │ │ ├── test-connection.ts │ │ │ ├── types │ │ │ │ └── mcp-responses.ts │ │ │ ├── utils │ │ │ │ ├── cleanup-helpers.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── factories.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── mcp-context.ts │ │ │ │ ├── n8n-client.ts │ │ │ │ ├── node-repository.ts │ │ │ │ ├── response-types.ts │ │ │ │ ├── test-context.ts │ │ │ │ └── webhook-workflows.ts │ │ │ └── workflows │ │ │ ├── autofix-workflow.test.ts │ │ │ ├── create-workflow.test.ts │ │ │ ├── delete-workflow.test.ts │ │ │ ├── get-workflow-details.test.ts │ │ │ ├── get-workflow-minimal.test.ts │ │ │ ├── get-workflow-structure.test.ts │ │ │ ├── get-workflow.test.ts │ │ │ ├── list-workflows.test.ts │ │ │ ├── smart-parameters.test.ts │ │ │ ├── update-partial-workflow.test.ts │ │ │ ├── update-workflow.test.ts │ │ │ └── validate-workflow.test.ts │ │ ├── security │ │ │ ├── command-injection-prevention.test.ts │ │ │ └── rate-limiting.test.ts │ │ ├── setup │ │ │ ├── integration-setup.ts │ │ │ └── msw-test-server.ts │ │ ├── telemetry │ │ │ ├── docker-user-id-stability.test.ts │ │ │ └── mcp-telemetry.test.ts │ │ ├── templates │ │ │ └── metadata-operations.test.ts │ │ └── workflow-creation-node-type-format.test.ts │ ├── logger.test.ts │ ├── MOCKING_STRATEGY.md │ ├── mocks │ │ ├── n8n-api │ │ │ ├── data │ │ │ │ ├── credentials.ts │ │ │ │ ├── executions.ts │ │ │ │ └── workflows.ts │ │ │ ├── handlers.ts │ │ │ └── index.ts │ │ └── README.md │ ├── node-storage-export.json │ ├── setup │ │ ├── global-setup.ts │ │ ├── msw-setup.ts │ │ ├── TEST_ENV_DOCUMENTATION.md │ │ └── test-env.ts │ ├── test-database-extraction.js │ ├── test-direct-extraction.js │ ├── test-enhanced-documentation.js │ ├── test-enhanced-integration.js │ ├── test-mcp-extraction.js │ ├── test-mcp-server-extraction.js │ ├── test-mcp-tools-integration.js │ ├── test-node-documentation-service.js │ ├── test-node-list.js │ ├── test-package-info.js │ ├── test-parsing-operations.js │ ├── test-slack-node-complete.js │ ├── test-small-rebuild.js │ ├── test-sqlite-search.js │ ├── test-storage-system.js │ ├── unit │ │ ├── __mocks__ │ │ │ ├── n8n-nodes-base.test.ts │ │ │ ├── n8n-nodes-base.ts │ │ │ └── README.md │ │ ├── database │ │ │ ├── __mocks__ │ │ │ │ └── better-sqlite3.ts │ │ │ ├── database-adapter-unit.test.ts │ │ │ ├── node-repository-core.test.ts │ │ │ ├── node-repository-operations.test.ts │ │ │ ├── node-repository-outputs.test.ts │ │ │ ├── README.md │ │ │ └── template-repository-core.test.ts │ │ ├── docker │ │ │ ├── config-security.test.ts │ │ │ ├── edge-cases.test.ts │ │ │ ├── parse-config.test.ts │ │ │ └── serve-command.test.ts │ │ ├── errors │ │ │ └── validation-service-error.test.ts │ │ ├── examples │ │ │ └── using-n8n-nodes-base-mock.test.ts │ │ ├── flexible-instance-security-advanced.test.ts │ │ ├── flexible-instance-security.test.ts │ │ ├── http-server │ │ │ └── multi-tenant-support.test.ts │ │ ├── http-server-n8n-mode.test.ts │ │ ├── http-server-n8n-reinit.test.ts │ │ ├── http-server-session-management.test.ts │ │ ├── loaders │ │ │ └── node-loader.test.ts │ │ ├── mappers │ │ │ └── docs-mapper.test.ts │ │ ├── mcp │ │ │ ├── get-node-essentials-examples.test.ts │ │ │ ├── handlers-n8n-manager-simple.test.ts │ │ │ ├── handlers-n8n-manager.test.ts │ │ │ ├── handlers-workflow-diff.test.ts │ │ │ ├── lru-cache-behavior.test.ts │ │ │ ├── multi-tenant-tool-listing.test.ts.disabled │ │ │ ├── parameter-validation.test.ts │ │ │ ├── search-nodes-examples.test.ts │ │ │ ├── tools-documentation.test.ts │ │ │ └── tools.test.ts │ │ ├── monitoring │ │ │ └── cache-metrics.test.ts │ │ ├── MULTI_TENANT_TEST_COVERAGE.md │ │ ├── multi-tenant-integration.test.ts │ │ ├── parsers │ │ │ ├── node-parser-outputs.test.ts │ │ │ ├── node-parser.test.ts │ │ │ ├── property-extractor.test.ts │ │ │ └── simple-parser.test.ts │ │ ├── scripts │ │ │ └── fetch-templates-extraction.test.ts │ │ ├── services │ │ │ ├── ai-node-validator.test.ts │ │ │ ├── ai-tool-validators.test.ts │ │ │ ├── confidence-scorer.test.ts │ │ │ ├── config-validator-basic.test.ts │ │ │ ├── config-validator-edge-cases.test.ts │ │ │ ├── config-validator-node-specific.test.ts │ │ │ ├── config-validator-security.test.ts │ │ │ ├── debug-validator.test.ts │ │ │ ├── enhanced-config-validator-integration.test.ts │ │ │ ├── enhanced-config-validator-operations.test.ts │ │ │ ├── enhanced-config-validator.test.ts │ │ │ ├── example-generator.test.ts │ │ │ ├── execution-processor.test.ts │ │ │ ├── expression-format-validator.test.ts │ │ │ ├── expression-validator-edge-cases.test.ts │ │ │ ├── expression-validator.test.ts │ │ │ ├── fixed-collection-validation.test.ts │ │ │ ├── loop-output-edge-cases.test.ts │ │ │ ├── n8n-api-client.test.ts │ │ │ ├── n8n-validation.test.ts │ │ │ ├── node-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 -------------------------------------------------------------------------------- /tests/unit/utils/fixed-collection-validator.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, test, expect } from 'vitest'; 2 | import { FixedCollectionValidator, NodeConfig, NodeConfigValue } from '../../../src/utils/fixed-collection-validator'; 3 | 4 | // Type guard helper for tests 5 | function isNodeConfig(value: NodeConfig | NodeConfigValue[] | undefined): value is NodeConfig { 6 | return typeof value === 'object' && value !== null && !Array.isArray(value); 7 | } 8 | 9 | describe('FixedCollectionValidator', () => { 10 | describe('Core Functionality', () => { 11 | test('should return valid for non-susceptible nodes', () => { 12 | const result = FixedCollectionValidator.validate('n8n-nodes-base.cron', { 13 | triggerTimes: { hour: 10, minute: 30 } 14 | }); 15 | 16 | expect(result.isValid).toBe(true); 17 | expect(result.errors).toHaveLength(0); 18 | }); 19 | 20 | test('should normalize node types correctly', () => { 21 | const nodeTypes = [ 22 | 'n8n-nodes-base.switch', 23 | 'nodes-base.switch', 24 | '@n8n/n8n-nodes-langchain.switch', 25 | 'SWITCH' 26 | ]; 27 | 28 | nodeTypes.forEach(nodeType => { 29 | expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true); 30 | }); 31 | }); 32 | 33 | test('should get all known patterns', () => { 34 | const patterns = FixedCollectionValidator.getAllPatterns(); 35 | expect(patterns.length).toBeGreaterThan(10); // We have at least 11 patterns 36 | expect(patterns.some(p => p.nodeType === 'switch')).toBe(true); 37 | expect(patterns.some(p => p.nodeType === 'summarize')).toBe(true); 38 | }); 39 | }); 40 | 41 | describe('Switch Node Validation', () => { 42 | test('should detect invalid nested conditions structure', () => { 43 | const invalidConfig = { 44 | rules: { 45 | conditions: { 46 | values: [ 47 | { 48 | value1: '={{$json.status}}', 49 | operation: 'equals', 50 | value2: 'active' 51 | } 52 | ] 53 | } 54 | } 55 | }; 56 | 57 | const result = FixedCollectionValidator.validate('n8n-nodes-base.switch', invalidConfig); 58 | 59 | expect(result.isValid).toBe(false); 60 | expect(result.errors).toHaveLength(2); // Both rules.conditions and rules.conditions.values match 61 | // Check that we found the specific pattern 62 | const conditionsValuesError = result.errors.find(e => e.pattern === 'rules.conditions.values'); 63 | expect(conditionsValuesError).toBeDefined(); 64 | expect(conditionsValuesError!.message).toContain('propertyValues[itemName] is not iterable'); 65 | expect(result.autofix).toBeDefined(); 66 | expect(isNodeConfig(result.autofix)).toBe(true); 67 | if (isNodeConfig(result.autofix)) { 68 | expect(result.autofix.rules).toBeDefined(); 69 | expect((result.autofix.rules as any).values).toBeDefined(); 70 | expect((result.autofix.rules as any).values[0].outputKey).toBe('output1'); 71 | } 72 | }); 73 | 74 | test('should provide correct autofix for switch node', () => { 75 | const invalidConfig = { 76 | rules: { 77 | conditions: { 78 | values: [ 79 | { value1: '={{$json.a}}', operation: 'equals', value2: '1' }, 80 | { value1: '={{$json.b}}', operation: 'equals', value2: '2' } 81 | ] 82 | } 83 | } 84 | }; 85 | 86 | const result = FixedCollectionValidator.validate('switch', invalidConfig); 87 | 88 | expect(isNodeConfig(result.autofix)).toBe(true); 89 | if (isNodeConfig(result.autofix)) { 90 | expect((result.autofix.rules as any).values).toHaveLength(2); 91 | expect((result.autofix.rules as any).values[0].outputKey).toBe('output1'); 92 | expect((result.autofix.rules as any).values[1].outputKey).toBe('output2'); 93 | } 94 | }); 95 | }); 96 | 97 | describe('If/Filter Node Validation', () => { 98 | test('should detect invalid nested values structure', () => { 99 | const invalidConfig = { 100 | conditions: { 101 | values: [ 102 | { 103 | value1: '={{$json.age}}', 104 | operation: 'largerEqual', 105 | value2: 18 106 | } 107 | ] 108 | } 109 | }; 110 | 111 | const ifResult = FixedCollectionValidator.validate('n8n-nodes-base.if', invalidConfig); 112 | const filterResult = FixedCollectionValidator.validate('n8n-nodes-base.filter', invalidConfig); 113 | 114 | expect(ifResult.isValid).toBe(false); 115 | expect(ifResult.errors[0].fix).toContain('directly, not nested under "values"'); 116 | expect(ifResult.autofix).toEqual([ 117 | { 118 | value1: '={{$json.age}}', 119 | operation: 'largerEqual', 120 | value2: 18 121 | } 122 | ]); 123 | 124 | expect(filterResult.isValid).toBe(false); 125 | expect(filterResult.autofix).toEqual(ifResult.autofix); 126 | }); 127 | }); 128 | 129 | describe('New Nodes Validation', () => { 130 | test('should validate Summarize node', () => { 131 | const invalidConfig = { 132 | fieldsToSummarize: { 133 | values: { 134 | values: [ 135 | { field: 'amount', aggregation: 'sum' }, 136 | { field: 'count', aggregation: 'count' } 137 | ] 138 | } 139 | } 140 | }; 141 | 142 | const result = FixedCollectionValidator.validate('summarize', invalidConfig); 143 | 144 | expect(result.isValid).toBe(false); 145 | expect(result.errors[0].pattern).toBe('fieldsToSummarize.values.values'); 146 | expect(result.errors[0].fix).toContain('not nested values.values'); 147 | expect(isNodeConfig(result.autofix)).toBe(true); 148 | if (isNodeConfig(result.autofix)) { 149 | expect((result.autofix.fieldsToSummarize as any).values).toHaveLength(2); 150 | } 151 | }); 152 | 153 | test('should validate Compare Datasets node', () => { 154 | const invalidConfig = { 155 | mergeByFields: { 156 | values: { 157 | values: [ 158 | { field1: 'id', field2: 'userId' } 159 | ] 160 | } 161 | } 162 | }; 163 | 164 | const result = FixedCollectionValidator.validate('compareDatasets', invalidConfig); 165 | 166 | expect(result.isValid).toBe(false); 167 | expect(result.errors[0].pattern).toBe('mergeByFields.values.values'); 168 | expect(isNodeConfig(result.autofix)).toBe(true); 169 | if (isNodeConfig(result.autofix)) { 170 | expect((result.autofix.mergeByFields as any).values).toHaveLength(1); 171 | } 172 | }); 173 | 174 | test('should validate Sort node', () => { 175 | const invalidConfig = { 176 | sortFieldsUi: { 177 | sortField: { 178 | values: [ 179 | { fieldName: 'date', order: 'descending' } 180 | ] 181 | } 182 | } 183 | }; 184 | 185 | const result = FixedCollectionValidator.validate('sort', invalidConfig); 186 | 187 | expect(result.isValid).toBe(false); 188 | expect(result.errors[0].pattern).toBe('sortFieldsUi.sortField.values'); 189 | expect(result.errors[0].fix).toContain('not sortField.values'); 190 | expect(isNodeConfig(result.autofix)).toBe(true); 191 | if (isNodeConfig(result.autofix)) { 192 | expect((result.autofix.sortFieldsUi as any).sortField).toHaveLength(1); 193 | } 194 | }); 195 | 196 | test('should validate Aggregate node', () => { 197 | const invalidConfig = { 198 | fieldsToAggregate: { 199 | fieldToAggregate: { 200 | values: [ 201 | { fieldToAggregate: 'price', aggregation: 'average' } 202 | ] 203 | } 204 | } 205 | }; 206 | 207 | const result = FixedCollectionValidator.validate('aggregate', invalidConfig); 208 | 209 | expect(result.isValid).toBe(false); 210 | expect(result.errors[0].pattern).toBe('fieldsToAggregate.fieldToAggregate.values'); 211 | expect(isNodeConfig(result.autofix)).toBe(true); 212 | if (isNodeConfig(result.autofix)) { 213 | expect((result.autofix.fieldsToAggregate as any).fieldToAggregate).toHaveLength(1); 214 | } 215 | }); 216 | 217 | test('should validate Set node', () => { 218 | const invalidConfig = { 219 | fields: { 220 | values: { 221 | values: [ 222 | { name: 'status', value: 'active' } 223 | ] 224 | } 225 | } 226 | }; 227 | 228 | const result = FixedCollectionValidator.validate('set', invalidConfig); 229 | 230 | expect(result.isValid).toBe(false); 231 | expect(result.errors[0].pattern).toBe('fields.values.values'); 232 | expect(isNodeConfig(result.autofix)).toBe(true); 233 | if (isNodeConfig(result.autofix)) { 234 | expect((result.autofix.fields as any).values).toHaveLength(1); 235 | } 236 | }); 237 | 238 | test('should validate HTML node', () => { 239 | const invalidConfig = { 240 | extractionValues: { 241 | values: { 242 | values: [ 243 | { key: 'title', cssSelector: 'h1' } 244 | ] 245 | } 246 | } 247 | }; 248 | 249 | const result = FixedCollectionValidator.validate('html', invalidConfig); 250 | 251 | expect(result.isValid).toBe(false); 252 | expect(result.errors[0].pattern).toBe('extractionValues.values.values'); 253 | expect(isNodeConfig(result.autofix)).toBe(true); 254 | if (isNodeConfig(result.autofix)) { 255 | expect((result.autofix.extractionValues as any).values).toHaveLength(1); 256 | } 257 | }); 258 | 259 | test('should validate HTTP Request node', () => { 260 | const invalidConfig = { 261 | body: { 262 | parameters: { 263 | values: [ 264 | { name: 'api_key', value: '123' } 265 | ] 266 | } 267 | } 268 | }; 269 | 270 | const result = FixedCollectionValidator.validate('httpRequest', invalidConfig); 271 | 272 | expect(result.isValid).toBe(false); 273 | expect(result.errors[0].pattern).toBe('body.parameters.values'); 274 | expect(result.errors[0].fix).toContain('not parameters.values'); 275 | expect(isNodeConfig(result.autofix)).toBe(true); 276 | if (isNodeConfig(result.autofix)) { 277 | expect((result.autofix.body as any).parameters).toHaveLength(1); 278 | } 279 | }); 280 | 281 | test('should validate Airtable node', () => { 282 | const invalidConfig = { 283 | sort: { 284 | sortField: { 285 | values: [ 286 | { fieldName: 'Created', direction: 'desc' } 287 | ] 288 | } 289 | } 290 | }; 291 | 292 | const result = FixedCollectionValidator.validate('airtable', invalidConfig); 293 | 294 | expect(result.isValid).toBe(false); 295 | expect(result.errors[0].pattern).toBe('sort.sortField.values'); 296 | expect(isNodeConfig(result.autofix)).toBe(true); 297 | if (isNodeConfig(result.autofix)) { 298 | expect((result.autofix.sort as any).sortField).toHaveLength(1); 299 | } 300 | }); 301 | }); 302 | 303 | describe('Edge Cases', () => { 304 | test('should handle empty config', () => { 305 | const result = FixedCollectionValidator.validate('switch', {}); 306 | expect(result.isValid).toBe(true); 307 | }); 308 | 309 | test('should handle null/undefined properties', () => { 310 | const result = FixedCollectionValidator.validate('switch', { 311 | rules: null 312 | }); 313 | expect(result.isValid).toBe(true); 314 | }); 315 | 316 | test('should handle valid structures', () => { 317 | const validSwitch = { 318 | rules: { 319 | values: [ 320 | { 321 | conditions: { value1: '={{$json.x}}', operation: 'equals', value2: 1 }, 322 | outputKey: 'output1' 323 | } 324 | ] 325 | } 326 | }; 327 | 328 | const result = FixedCollectionValidator.validate('switch', validSwitch); 329 | expect(result.isValid).toBe(true); 330 | expect(result.errors).toHaveLength(0); 331 | }); 332 | 333 | test('should handle deeply nested invalid structures', () => { 334 | const deeplyNested = { 335 | rules: { 336 | conditions: { 337 | values: [ 338 | { 339 | value1: '={{$json.deep}}', 340 | operation: 'equals', 341 | value2: 'nested' 342 | } 343 | ] 344 | } 345 | } 346 | }; 347 | 348 | const result = FixedCollectionValidator.validate('switch', deeplyNested); 349 | expect(result.isValid).toBe(false); 350 | expect(result.errors).toHaveLength(2); // Both patterns match 351 | }); 352 | }); 353 | 354 | describe('Private Method Testing (through public API)', () => { 355 | describe('isNodeConfig Type Guard', () => { 356 | test('should return true for plain objects', () => { 357 | const validConfig = { property: 'value' }; 358 | const result = FixedCollectionValidator.validate('switch', validConfig); 359 | // Type guard is tested indirectly through validation 360 | expect(result).toBeDefined(); 361 | }); 362 | 363 | test('should handle null values correctly', () => { 364 | const result = FixedCollectionValidator.validate('switch', null as any); 365 | expect(result.isValid).toBe(true); 366 | expect(result.errors).toHaveLength(0); 367 | }); 368 | 369 | test('should handle undefined values correctly', () => { 370 | const result = FixedCollectionValidator.validate('switch', undefined as any); 371 | expect(result.isValid).toBe(true); 372 | expect(result.errors).toHaveLength(0); 373 | }); 374 | 375 | test('should handle arrays correctly', () => { 376 | const result = FixedCollectionValidator.validate('switch', [] as any); 377 | expect(result.isValid).toBe(true); 378 | expect(result.errors).toHaveLength(0); 379 | }); 380 | 381 | test('should handle primitive values correctly', () => { 382 | const result1 = FixedCollectionValidator.validate('switch', 'string' as any); 383 | expect(result1.isValid).toBe(true); 384 | 385 | const result2 = FixedCollectionValidator.validate('switch', 123 as any); 386 | expect(result2.isValid).toBe(true); 387 | 388 | const result3 = FixedCollectionValidator.validate('switch', true as any); 389 | expect(result3.isValid).toBe(true); 390 | }); 391 | }); 392 | 393 | describe('getNestedValue Testing', () => { 394 | test('should handle simple nested paths', () => { 395 | const config = { 396 | rules: { 397 | conditions: { 398 | values: [{ test: 'value' }] 399 | } 400 | } 401 | }; 402 | 403 | const result = FixedCollectionValidator.validate('switch', config); 404 | expect(result.isValid).toBe(false); // This tests the nested value extraction 405 | }); 406 | 407 | test('should handle non-existent paths gracefully', () => { 408 | const config = { 409 | rules: { 410 | // missing conditions property 411 | } 412 | }; 413 | 414 | const result = FixedCollectionValidator.validate('switch', config); 415 | expect(result.isValid).toBe(true); // Should not find invalid structure 416 | }); 417 | 418 | test('should handle interrupted paths (null/undefined in middle)', () => { 419 | const config = { 420 | rules: null 421 | }; 422 | 423 | const result = FixedCollectionValidator.validate('switch', config); 424 | expect(result.isValid).toBe(true); 425 | }); 426 | 427 | test('should handle array interruptions in path', () => { 428 | const config = { 429 | rules: [1, 2, 3] // array instead of object 430 | }; 431 | 432 | const result = FixedCollectionValidator.validate('switch', config); 433 | expect(result.isValid).toBe(true); // Should not find the pattern 434 | }); 435 | }); 436 | 437 | describe('Circular Reference Protection', () => { 438 | test('should handle circular references in config', () => { 439 | const config: any = { 440 | rules: { 441 | conditions: {} 442 | } 443 | }; 444 | // Create circular reference 445 | config.rules.conditions.circular = config.rules; 446 | 447 | const result = FixedCollectionValidator.validate('switch', config); 448 | // Should not crash and should detect the pattern (result is false because it finds rules.conditions) 449 | expect(result.isValid).toBe(false); 450 | expect(result.errors.length).toBeGreaterThan(0); 451 | }); 452 | 453 | test('should handle self-referencing objects', () => { 454 | const config: any = { 455 | rules: {} 456 | }; 457 | config.rules.self = config.rules; 458 | 459 | const result = FixedCollectionValidator.validate('switch', config); 460 | expect(result.isValid).toBe(true); 461 | }); 462 | 463 | test('should handle deeply nested circular references', () => { 464 | const config: any = { 465 | rules: { 466 | conditions: { 467 | values: {} 468 | } 469 | } 470 | }; 471 | config.rules.conditions.values.back = config; 472 | 473 | const result = FixedCollectionValidator.validate('switch', config); 474 | // Should detect the problematic pattern: rules.conditions.values exists 475 | expect(result.isValid).toBe(false); 476 | expect(result.errors.length).toBeGreaterThan(0); 477 | }); 478 | }); 479 | 480 | describe('Deep Copying in getAllPatterns', () => { 481 | test('should return independent copies of patterns', () => { 482 | const patterns1 = FixedCollectionValidator.getAllPatterns(); 483 | const patterns2 = FixedCollectionValidator.getAllPatterns(); 484 | 485 | // Modify one copy 486 | patterns1[0].invalidPatterns.push('test.pattern'); 487 | 488 | // Other copy should be unaffected 489 | expect(patterns2[0].invalidPatterns).not.toContain('test.pattern'); 490 | }); 491 | 492 | test('should deep copy invalidPatterns arrays', () => { 493 | const patterns = FixedCollectionValidator.getAllPatterns(); 494 | const switchPattern = patterns.find(p => p.nodeType === 'switch')!; 495 | 496 | expect(switchPattern.invalidPatterns).toBeInstanceOf(Array); 497 | expect(switchPattern.invalidPatterns.length).toBeGreaterThan(0); 498 | 499 | // Ensure it's a different array instance 500 | const originalPatterns = FixedCollectionValidator.getAllPatterns(); 501 | const originalSwitch = originalPatterns.find(p => p.nodeType === 'switch')!; 502 | 503 | expect(switchPattern.invalidPatterns).not.toBe(originalSwitch.invalidPatterns); 504 | expect(switchPattern.invalidPatterns).toEqual(originalSwitch.invalidPatterns); 505 | }); 506 | }); 507 | }); 508 | 509 | describe('Enhanced Edge Cases', () => { 510 | test('should handle hasOwnProperty edge case', () => { 511 | const config = Object.create(null); 512 | config.rules = { 513 | conditions: { 514 | values: [{ test: 'value' }] 515 | } 516 | }; 517 | 518 | const result = FixedCollectionValidator.validate('switch', config); 519 | expect(result.isValid).toBe(false); // Should still detect the pattern 520 | }); 521 | 522 | test('should handle prototype pollution attempts', () => { 523 | const config = { 524 | rules: { 525 | conditions: { 526 | values: [{ test: 'value' }] 527 | } 528 | } 529 | }; 530 | 531 | // Add prototype property (should be ignored by hasOwnProperty check) 532 | (Object.prototype as any).maliciousProperty = 'evil'; 533 | 534 | try { 535 | const result = FixedCollectionValidator.validate('switch', config); 536 | expect(result.isValid).toBe(false); 537 | expect(result.errors).toHaveLength(2); 538 | } finally { 539 | delete (Object.prototype as any).maliciousProperty; 540 | } 541 | }); 542 | 543 | test('should handle objects with numeric keys', () => { 544 | const config = { 545 | rules: { 546 | '0': { 547 | values: [{ test: 'value' }] 548 | } 549 | } 550 | }; 551 | 552 | const result = FixedCollectionValidator.validate('switch', config); 553 | expect(result.isValid).toBe(true); // Should not match 'conditions' pattern 554 | }); 555 | 556 | test('should handle very deep nesting without crashing', () => { 557 | let deepConfig: any = {}; 558 | let current = deepConfig; 559 | 560 | // Create 100 levels deep 561 | for (let i = 0; i < 100; i++) { 562 | current.next = {}; 563 | current = current.next; 564 | } 565 | 566 | const result = FixedCollectionValidator.validate('switch', deepConfig); 567 | expect(result.isValid).toBe(true); 568 | }); 569 | }); 570 | 571 | describe('Alternative Node Type Formats', () => { 572 | test('should handle all node type normalization cases', () => { 573 | const testCases = [ 574 | 'n8n-nodes-base.switch', 575 | 'nodes-base.switch', 576 | '@n8n/n8n-nodes-langchain.switch', 577 | 'SWITCH', 578 | 'Switch', 579 | 'sWiTcH' 580 | ]; 581 | 582 | testCases.forEach(nodeType => { 583 | expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true); 584 | }); 585 | }); 586 | 587 | test('should handle empty and invalid node types', () => { 588 | expect(FixedCollectionValidator.isNodeSusceptible('')).toBe(false); 589 | expect(FixedCollectionValidator.isNodeSusceptible('unknown-node')).toBe(false); 590 | expect(FixedCollectionValidator.isNodeSusceptible('n8n-nodes-base.unknown')).toBe(false); 591 | }); 592 | }); 593 | 594 | describe('Complex Autofix Scenarios', () => { 595 | test('should handle switch autofix with non-array values', () => { 596 | const invalidConfig = { 597 | rules: { 598 | conditions: { 599 | values: { single: 'condition' } // Object instead of array 600 | } 601 | } 602 | }; 603 | 604 | const result = FixedCollectionValidator.validate('switch', invalidConfig); 605 | expect(result.isValid).toBe(false); 606 | expect(isNodeConfig(result.autofix)).toBe(true); 607 | 608 | if (isNodeConfig(result.autofix)) { 609 | const values = (result.autofix.rules as any).values; 610 | expect(values).toHaveLength(1); 611 | expect(values[0].conditions).toEqual({ single: 'condition' }); 612 | expect(values[0].outputKey).toBe('output1'); 613 | } 614 | }); 615 | 616 | test('should handle if/filter autofix with object values', () => { 617 | const invalidConfig = { 618 | conditions: { 619 | values: { type: 'single', condition: 'test' } 620 | } 621 | }; 622 | 623 | const result = FixedCollectionValidator.validate('if', invalidConfig); 624 | expect(result.isValid).toBe(false); 625 | expect(result.autofix).toEqual({ type: 'single', condition: 'test' }); 626 | }); 627 | 628 | test('should handle applyAutofix for if/filter with null values', () => { 629 | const invalidConfig = { 630 | conditions: { 631 | values: null 632 | } 633 | }; 634 | 635 | const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!; 636 | const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern); 637 | 638 | // Should return the original config when values is null 639 | expect(fixed).toEqual(invalidConfig); 640 | }); 641 | 642 | test('should handle applyAutofix for if/filter with undefined values', () => { 643 | const invalidConfig = { 644 | conditions: { 645 | values: undefined 646 | } 647 | }; 648 | 649 | const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!; 650 | const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern); 651 | 652 | // Should return the original config when values is undefined 653 | expect(fixed).toEqual(invalidConfig); 654 | }); 655 | }); 656 | 657 | describe('applyAutofix Method', () => { 658 | test('should apply autofix correctly for if/filter nodes', () => { 659 | const invalidConfig = { 660 | conditions: { 661 | values: [ 662 | { value1: '={{$json.test}}', operation: 'equals', value2: 'yes' } 663 | ] 664 | } 665 | }; 666 | 667 | const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if'); 668 | const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!); 669 | 670 | expect(fixed).toEqual([ 671 | { value1: '={{$json.test}}', operation: 'equals', value2: 'yes' } 672 | ]); 673 | }); 674 | 675 | test('should return original config for non-if/filter nodes', () => { 676 | const invalidConfig = { 677 | fieldsToSummarize: { 678 | values: { 679 | values: [{ field: 'test' }] 680 | } 681 | } 682 | }; 683 | 684 | const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'summarize'); 685 | const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!); 686 | 687 | expect(isNodeConfig(fixed)).toBe(true); 688 | if (isNodeConfig(fixed)) { 689 | expect((fixed.fieldsToSummarize as any).values).toEqual([{ field: 'test' }]); 690 | } 691 | }); 692 | 693 | test('should handle filter node applyAutofix edge cases', () => { 694 | const invalidConfig = { 695 | conditions: { 696 | values: 'string-value' // Invalid type 697 | } 698 | }; 699 | 700 | const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'filter'); 701 | const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!); 702 | 703 | // Should return original config when values is not object/array 704 | expect(fixed).toEqual(invalidConfig); 705 | }); 706 | }); 707 | 708 | describe('Missing Function Coverage Tests', () => { 709 | test('should test all generateFixMessage cases', () => { 710 | // Test each node type's fix message generation through validation 711 | const nodeConfigs = [ 712 | { nodeType: 'switch', config: { rules: { conditions: { values: [] } } } }, 713 | { nodeType: 'if', config: { conditions: { values: [] } } }, 714 | { nodeType: 'filter', config: { conditions: { values: [] } } }, 715 | { nodeType: 'summarize', config: { fieldsToSummarize: { values: { values: [] } } } }, 716 | { nodeType: 'comparedatasets', config: { mergeByFields: { values: { values: [] } } } }, 717 | { nodeType: 'sort', config: { sortFieldsUi: { sortField: { values: [] } } } }, 718 | { nodeType: 'aggregate', config: { fieldsToAggregate: { fieldToAggregate: { values: [] } } } }, 719 | { nodeType: 'set', config: { fields: { values: { values: [] } } } }, 720 | { nodeType: 'html', config: { extractionValues: { values: { values: [] } } } }, 721 | { nodeType: 'httprequest', config: { body: { parameters: { values: [] } } } }, 722 | { nodeType: 'airtable', config: { sort: { sortField: { values: [] } } } }, 723 | ]; 724 | 725 | nodeConfigs.forEach(({ nodeType, config }) => { 726 | const result = FixedCollectionValidator.validate(nodeType, config); 727 | expect(result.isValid).toBe(false); 728 | expect(result.errors.length).toBeGreaterThan(0); 729 | expect(result.errors[0].fix).toBeDefined(); 730 | expect(typeof result.errors[0].fix).toBe('string'); 731 | }); 732 | }); 733 | 734 | test('should test default case in generateFixMessage', () => { 735 | // Create a custom pattern with unknown nodeType to test default case 736 | const mockPattern = { 737 | nodeType: 'unknown-node-type', 738 | property: 'testProperty', 739 | expectedStructure: 'test.structure', 740 | invalidPatterns: ['test.invalid.pattern'] 741 | }; 742 | 743 | // We can't directly test the private generateFixMessage method, 744 | // but we can test through the validation logic by temporarily adding to KNOWN_PATTERNS 745 | // Instead, let's verify the method works by checking error messages contain the expected structure 746 | const patterns = FixedCollectionValidator.getAllPatterns(); 747 | expect(patterns.length).toBeGreaterThan(0); 748 | 749 | // Ensure we have patterns that would exercise different fix message paths 750 | const switchPattern = patterns.find(p => p.nodeType === 'switch'); 751 | expect(switchPattern).toBeDefined(); 752 | expect(switchPattern!.expectedStructure).toBe('rules.values array'); 753 | }); 754 | 755 | test('should exercise hasInvalidStructure edge cases', () => { 756 | // Test with property that exists but is not at the end of the pattern 757 | const config = { 758 | rules: { 759 | conditions: 'string-value' // Not an object, so traversal should stop 760 | } 761 | }; 762 | 763 | const result = FixedCollectionValidator.validate('switch', config); 764 | expect(result.isValid).toBe(false); // Should still detect rules.conditions pattern 765 | }); 766 | 767 | test('should test getNestedValue with complex paths', () => { 768 | // Test through hasInvalidStructure which uses getNestedValue 769 | const config = { 770 | deeply: { 771 | nested: { 772 | path: { 773 | to: { 774 | value: 'exists' 775 | } 776 | } 777 | } 778 | } 779 | }; 780 | 781 | // This would exercise the getNestedValue function through hasInvalidStructure 782 | const result = FixedCollectionValidator.validate('switch', config); 783 | expect(result.isValid).toBe(true); // No matching patterns 784 | }); 785 | }); 786 | }); ``` -------------------------------------------------------------------------------- /tests/unit/templates/template-repository-metadata.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 2 | import { TemplateRepository } from '../../../src/templates/template-repository'; 3 | import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter'; 4 | import { logger } from '../../../src/utils/logger'; 5 | 6 | // Mock logger 7 | vi.mock('../../../src/utils/logger', () => ({ 8 | logger: { 9 | info: vi.fn(), 10 | warn: vi.fn(), 11 | error: vi.fn(), 12 | debug: vi.fn() 13 | } 14 | })); 15 | 16 | // Mock template sanitizer 17 | vi.mock('../../../src/utils/template-sanitizer', () => { 18 | class MockTemplateSanitizer { 19 | sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false })); 20 | detectTokens = vi.fn(() => []); 21 | } 22 | 23 | return { 24 | TemplateSanitizer: MockTemplateSanitizer 25 | }; 26 | }); 27 | 28 | // Create mock database adapter 29 | class MockDatabaseAdapter implements DatabaseAdapter { 30 | private statements = new Map<string, MockPreparedStatement>(); 31 | private execCalls: string[] = []; 32 | private _fts5Support = true; 33 | 34 | prepare = vi.fn((sql: string) => { 35 | if (!this.statements.has(sql)) { 36 | this.statements.set(sql, new MockPreparedStatement(sql)); 37 | } 38 | return this.statements.get(sql)!; 39 | }); 40 | 41 | exec = vi.fn((sql: string) => { 42 | this.execCalls.push(sql); 43 | }); 44 | close = vi.fn(); 45 | pragma = vi.fn(); 46 | transaction = vi.fn((fn: () => any) => fn()); 47 | checkFTS5Support = vi.fn(() => this._fts5Support); 48 | inTransaction = false; 49 | 50 | _setFTS5Support(supported: boolean) { 51 | this._fts5Support = supported; 52 | } 53 | 54 | _getStatement(sql: string) { 55 | return this.statements.get(sql); 56 | } 57 | 58 | _getExecCalls() { 59 | return this.execCalls; 60 | } 61 | 62 | _clearExecCalls() { 63 | this.execCalls = []; 64 | } 65 | } 66 | 67 | class MockPreparedStatement implements PreparedStatement { 68 | public mockResults: any[] = []; 69 | public capturedParams: any[][] = []; 70 | 71 | run = vi.fn((...params: any[]): RunResult => { 72 | this.capturedParams.push(params); 73 | return { changes: 1, lastInsertRowid: 1 }; 74 | }); 75 | 76 | get = vi.fn((...params: any[]) => { 77 | this.capturedParams.push(params); 78 | return this.mockResults[0] || null; 79 | }); 80 | 81 | all = vi.fn((...params: any[]) => { 82 | this.capturedParams.push(params); 83 | return this.mockResults; 84 | }); 85 | 86 | iterate = vi.fn(); 87 | pluck = vi.fn(() => this); 88 | expand = vi.fn(() => this); 89 | raw = vi.fn(() => this); 90 | columns = vi.fn(() => []); 91 | bind = vi.fn(() => this); 92 | 93 | constructor(private sql: string) {} 94 | 95 | _setMockResults(results: any[]) { 96 | this.mockResults = results; 97 | } 98 | 99 | _getCapturedParams() { 100 | return this.capturedParams; 101 | } 102 | } 103 | 104 | describe('TemplateRepository - Metadata Filter Tests', () => { 105 | let repository: TemplateRepository; 106 | let mockAdapter: MockDatabaseAdapter; 107 | 108 | beforeEach(() => { 109 | vi.clearAllMocks(); 110 | mockAdapter = new MockDatabaseAdapter(); 111 | repository = new TemplateRepository(mockAdapter); 112 | }); 113 | 114 | afterEach(() => { 115 | vi.clearAllMocks(); 116 | }); 117 | 118 | describe('buildMetadataFilterConditions - All Filter Combinations', () => { 119 | it('should build conditions with no filters', () => { 120 | const stmt = new MockPreparedStatement(''); 121 | stmt._setMockResults([]); 122 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 123 | 124 | repository.searchTemplatesByMetadata({}, 10, 0); 125 | 126 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 127 | // Should only have the base condition 128 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 129 | // Should not have any additional conditions 130 | expect(prepareCall).not.toContain("json_extract(metadata_json, '$.categories')"); 131 | expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity')"); 132 | }); 133 | 134 | it('should build conditions with only category filter', () => { 135 | const stmt = new MockPreparedStatement(''); 136 | stmt._setMockResults([]); 137 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 138 | 139 | repository.searchTemplatesByMetadata({ category: 'automation' }, 10, 0); 140 | 141 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 142 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 143 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); 144 | 145 | const capturedParams = stmt._getCapturedParams(); 146 | expect(capturedParams[0][0]).toBe('automation'); 147 | }); 148 | 149 | it('should build conditions with only complexity filter', () => { 150 | const stmt = new MockPreparedStatement(''); 151 | stmt._setMockResults([]); 152 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 153 | 154 | repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0); 155 | 156 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 157 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 158 | expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?"); 159 | 160 | const capturedParams = stmt._getCapturedParams(); 161 | expect(capturedParams[0][0]).toBe('simple'); 162 | }); 163 | 164 | it('should build conditions with only maxSetupMinutes filter', () => { 165 | const stmt = new MockPreparedStatement(''); 166 | stmt._setMockResults([]); 167 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 168 | 169 | repository.searchTemplatesByMetadata({ maxSetupMinutes: 30 }, 10, 0); 170 | 171 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 172 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 173 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?"); 174 | 175 | const capturedParams = stmt._getCapturedParams(); 176 | expect(capturedParams[0][0]).toBe(30); 177 | }); 178 | 179 | it('should build conditions with only minSetupMinutes filter', () => { 180 | const stmt = new MockPreparedStatement(''); 181 | stmt._setMockResults([]); 182 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 183 | 184 | repository.searchTemplatesByMetadata({ minSetupMinutes: 10 }, 10, 0); 185 | 186 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 187 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 188 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?"); 189 | 190 | const capturedParams = stmt._getCapturedParams(); 191 | expect(capturedParams[0][0]).toBe(10); 192 | }); 193 | 194 | it('should build conditions with only requiredService filter', () => { 195 | const stmt = new MockPreparedStatement(''); 196 | stmt._setMockResults([]); 197 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 198 | 199 | repository.searchTemplatesByMetadata({ requiredService: 'slack' }, 10, 0); 200 | 201 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 202 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 203 | expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); 204 | 205 | const capturedParams = stmt._getCapturedParams(); 206 | expect(capturedParams[0][0]).toBe('slack'); 207 | }); 208 | 209 | it('should build conditions with only targetAudience filter', () => { 210 | const stmt = new MockPreparedStatement(''); 211 | stmt._setMockResults([]); 212 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 213 | 214 | repository.searchTemplatesByMetadata({ targetAudience: 'developers' }, 10, 0); 215 | 216 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 217 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 218 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); 219 | 220 | const capturedParams = stmt._getCapturedParams(); 221 | expect(capturedParams[0][0]).toBe('developers'); 222 | }); 223 | 224 | it('should build conditions with all filters combined', () => { 225 | const stmt = new MockPreparedStatement(''); 226 | stmt._setMockResults([]); 227 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 228 | 229 | repository.searchTemplatesByMetadata({ 230 | category: 'automation', 231 | complexity: 'medium', 232 | maxSetupMinutes: 60, 233 | minSetupMinutes: 15, 234 | requiredService: 'openai', 235 | targetAudience: 'marketers' 236 | }, 10, 0); 237 | 238 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 239 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 240 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); 241 | expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?"); 242 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?"); 243 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?"); 244 | expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); 245 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); 246 | 247 | const capturedParams = stmt._getCapturedParams(); 248 | expect(capturedParams[0]).toEqual(['automation', 'medium', 60, 15, 'openai', 'marketers', 10, 0]); 249 | }); 250 | 251 | it('should build conditions with partial filter combinations', () => { 252 | const stmt = new MockPreparedStatement(''); 253 | stmt._setMockResults([]); 254 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 255 | 256 | repository.searchTemplatesByMetadata({ 257 | category: 'data-processing', 258 | maxSetupMinutes: 45, 259 | targetAudience: 'analysts' 260 | }, 10, 0); 261 | 262 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 263 | expect(prepareCall).toContain('metadata_json IS NOT NULL'); 264 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); 265 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?"); 266 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); 267 | // Should not have complexity, minSetupMinutes, or requiredService conditions 268 | expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity') = ?"); 269 | expect(prepareCall).not.toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?"); 270 | expect(prepareCall).not.toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); 271 | 272 | const capturedParams = stmt._getCapturedParams(); 273 | expect(capturedParams[0]).toEqual(['data-processing', 45, 'analysts', 10, 0]); 274 | }); 275 | 276 | it('should handle complexity variations', () => { 277 | const stmt = new MockPreparedStatement(''); 278 | stmt._setMockResults([]); 279 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 280 | 281 | // Test each complexity level 282 | const complexityLevels: Array<'simple' | 'medium' | 'complex'> = ['simple', 'medium', 'complex']; 283 | 284 | complexityLevels.forEach((complexity) => { 285 | vi.clearAllMocks(); 286 | stmt.capturedParams = []; 287 | 288 | repository.searchTemplatesByMetadata({ complexity }, 10, 0); 289 | 290 | const capturedParams = stmt._getCapturedParams(); 291 | expect(capturedParams[0][0]).toBe(complexity); 292 | }); 293 | }); 294 | 295 | it('should handle setup minutes edge cases', () => { 296 | const stmt = new MockPreparedStatement(''); 297 | stmt._setMockResults([]); 298 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 299 | 300 | // Test zero values 301 | repository.searchTemplatesByMetadata({ maxSetupMinutes: 0, minSetupMinutes: 0 }, 10, 0); 302 | 303 | let capturedParams = stmt._getCapturedParams(); 304 | expect(capturedParams[0]).toContain(0); 305 | 306 | // Test very large values 307 | vi.clearAllMocks(); 308 | stmt.capturedParams = []; 309 | repository.searchTemplatesByMetadata({ maxSetupMinutes: 999999 }, 10, 0); 310 | 311 | capturedParams = stmt._getCapturedParams(); 312 | expect(capturedParams[0]).toContain(999999); 313 | 314 | // Test negative values (should still work, though might not make sense semantically) 315 | vi.clearAllMocks(); 316 | stmt.capturedParams = []; 317 | repository.searchTemplatesByMetadata({ minSetupMinutes: -10 }, 10, 0); 318 | 319 | capturedParams = stmt._getCapturedParams(); 320 | expect(capturedParams[0]).toContain(-10); 321 | }); 322 | 323 | it('should sanitize special characters in string filters', () => { 324 | const stmt = new MockPreparedStatement(''); 325 | stmt._setMockResults([]); 326 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 327 | 328 | const specialCategory = 'test"with\'quotes'; 329 | const specialService = 'service\\with\\backslashes'; 330 | const specialAudience = 'audience\nwith\nnewlines'; 331 | 332 | repository.searchTemplatesByMetadata({ 333 | category: specialCategory, 334 | requiredService: specialService, 335 | targetAudience: specialAudience 336 | }, 10, 0); 337 | 338 | const capturedParams = stmt._getCapturedParams(); 339 | // JSON.stringify escapes special characters, then slice(1, -1) removes quotes 340 | expect(capturedParams[0][0]).toBe(JSON.stringify(specialCategory).slice(1, -1)); 341 | expect(capturedParams[0][1]).toBe(JSON.stringify(specialService).slice(1, -1)); 342 | expect(capturedParams[0][2]).toBe(JSON.stringify(specialAudience).slice(1, -1)); 343 | }); 344 | }); 345 | 346 | describe('Performance Logging and Timing', () => { 347 | it('should log debug info on successful search', () => { 348 | const stmt = new MockPreparedStatement(''); 349 | stmt._setMockResults([ 350 | { id: 1 }, 351 | { id: 2 } 352 | ]); 353 | 354 | const stmt2 = new MockPreparedStatement(''); 355 | stmt2._setMockResults([ 356 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 357 | { id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' } 358 | ]); 359 | 360 | let callCount = 0; 361 | mockAdapter.prepare = vi.fn((sql: string) => { 362 | callCount++; 363 | return callCount === 1 ? stmt : stmt2; 364 | }); 365 | 366 | repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0); 367 | 368 | expect(logger.debug).toHaveBeenCalledWith( 369 | expect.stringContaining('Metadata search found'), 370 | expect.objectContaining({ 371 | filters: { complexity: 'simple' }, 372 | count: 2, 373 | phase1Ms: expect.any(Number), 374 | phase2Ms: expect.any(Number), 375 | totalMs: expect.any(Number), 376 | optimization: 'two-phase-with-ordering' 377 | }) 378 | ); 379 | }); 380 | 381 | it('should log debug info on empty results', () => { 382 | const stmt = new MockPreparedStatement(''); 383 | stmt._setMockResults([]); 384 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 385 | 386 | repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0); 387 | 388 | expect(logger.debug).toHaveBeenCalledWith( 389 | 'Metadata search found 0 results', 390 | expect.objectContaining({ 391 | filters: { category: 'nonexistent' }, 392 | phase1Ms: expect.any(Number) 393 | }) 394 | ); 395 | }); 396 | 397 | it('should include all filter types in logs', () => { 398 | const stmt = new MockPreparedStatement(''); 399 | stmt._setMockResults([]); 400 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 401 | 402 | const filters = { 403 | category: 'automation', 404 | complexity: 'medium' as const, 405 | maxSetupMinutes: 60, 406 | minSetupMinutes: 15, 407 | requiredService: 'slack', 408 | targetAudience: 'developers' 409 | }; 410 | 411 | repository.searchTemplatesByMetadata(filters, 10, 0); 412 | 413 | expect(logger.debug).toHaveBeenCalledWith( 414 | expect.any(String), 415 | expect.objectContaining({ 416 | filters: filters 417 | }) 418 | ); 419 | }); 420 | }); 421 | 422 | describe('ID Filtering and Validation', () => { 423 | it('should filter out negative IDs', () => { 424 | const stmt1 = new MockPreparedStatement(''); 425 | stmt1._setMockResults([ 426 | { id: 1 }, 427 | { id: -5 }, 428 | { id: 2 } 429 | ]); 430 | 431 | const stmt2 = new MockPreparedStatement(''); 432 | stmt2._setMockResults([ 433 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 434 | { id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' } 435 | ]); 436 | 437 | let callCount = 0; 438 | mockAdapter.prepare = vi.fn((sql: string) => { 439 | callCount++; 440 | return callCount === 1 ? stmt1 : stmt2; 441 | }); 442 | 443 | repository.searchTemplatesByMetadata({}, 10, 0); 444 | 445 | // Should only fetch valid IDs (1 and 2) 446 | const prepareCall = mockAdapter.prepare.mock.calls[1][0]; 447 | expect(prepareCall).toContain('(1, 0)'); 448 | expect(prepareCall).toContain('(2, 1)'); 449 | expect(prepareCall).not.toContain('-5'); 450 | }); 451 | 452 | it('should filter out zero IDs', () => { 453 | const stmt1 = new MockPreparedStatement(''); 454 | stmt1._setMockResults([ 455 | { id: 0 }, 456 | { id: 1 } 457 | ]); 458 | 459 | const stmt2 = new MockPreparedStatement(''); 460 | stmt2._setMockResults([ 461 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' } 462 | ]); 463 | 464 | let callCount = 0; 465 | mockAdapter.prepare = vi.fn((sql: string) => { 466 | callCount++; 467 | return callCount === 1 ? stmt1 : stmt2; 468 | }); 469 | 470 | repository.searchTemplatesByMetadata({}, 10, 0); 471 | 472 | // Should only fetch valid ID (1) 473 | const prepareCall = mockAdapter.prepare.mock.calls[1][0]; 474 | expect(prepareCall).toContain('(1, 0)'); 475 | expect(prepareCall).not.toContain('(0,'); 476 | }); 477 | 478 | it('should filter out non-integer IDs', () => { 479 | const stmt1 = new MockPreparedStatement(''); 480 | stmt1._setMockResults([ 481 | { id: 1 }, 482 | { id: 2.5 }, 483 | { id: 3 } 484 | ]); 485 | 486 | const stmt2 = new MockPreparedStatement(''); 487 | stmt2._setMockResults([ 488 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 489 | { id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' } 490 | ]); 491 | 492 | let callCount = 0; 493 | mockAdapter.prepare = vi.fn((sql: string) => { 494 | callCount++; 495 | return callCount === 1 ? stmt1 : stmt2; 496 | }); 497 | 498 | repository.searchTemplatesByMetadata({}, 10, 0); 499 | 500 | // Should only fetch integer IDs (1 and 3) 501 | const prepareCall = mockAdapter.prepare.mock.calls[1][0]; 502 | expect(prepareCall).toContain('(1, 0)'); 503 | expect(prepareCall).toContain('(3, 1)'); 504 | expect(prepareCall).not.toContain('2.5'); 505 | }); 506 | 507 | it('should filter out null IDs', () => { 508 | const stmt1 = new MockPreparedStatement(''); 509 | stmt1._setMockResults([ 510 | { id: 1 }, 511 | { id: null }, 512 | { id: 2 } 513 | ]); 514 | 515 | const stmt2 = new MockPreparedStatement(''); 516 | stmt2._setMockResults([ 517 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 518 | { id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' } 519 | ]); 520 | 521 | let callCount = 0; 522 | mockAdapter.prepare = vi.fn((sql: string) => { 523 | callCount++; 524 | return callCount === 1 ? stmt1 : stmt2; 525 | }); 526 | 527 | repository.searchTemplatesByMetadata({}, 10, 0); 528 | 529 | // Should only fetch valid IDs (1 and 2) 530 | const prepareCall = mockAdapter.prepare.mock.calls[1][0]; 531 | expect(prepareCall).toContain('(1, 0)'); 532 | expect(prepareCall).toContain('(2, 1)'); 533 | expect(prepareCall).not.toContain('null'); 534 | }); 535 | 536 | it('should warn when no valid IDs after filtering', () => { 537 | const stmt = new MockPreparedStatement(''); 538 | stmt._setMockResults([ 539 | { id: -1 }, 540 | { id: 0 }, 541 | { id: null } 542 | ]); 543 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 544 | 545 | const result = repository.searchTemplatesByMetadata({}, 10, 0); 546 | 547 | expect(result).toHaveLength(0); 548 | expect(logger.warn).toHaveBeenCalledWith( 549 | 'No valid IDs after filtering', 550 | expect.objectContaining({ 551 | filters: {}, 552 | originalCount: 3 553 | }) 554 | ); 555 | }); 556 | 557 | it('should warn when some IDs are filtered out', () => { 558 | const stmt1 = new MockPreparedStatement(''); 559 | stmt1._setMockResults([ 560 | { id: 1 }, 561 | { id: -2 }, 562 | { id: 3 }, 563 | { id: null } 564 | ]); 565 | 566 | const stmt2 = new MockPreparedStatement(''); 567 | stmt2._setMockResults([ 568 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 569 | { id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' } 570 | ]); 571 | 572 | let callCount = 0; 573 | mockAdapter.prepare = vi.fn((sql: string) => { 574 | callCount++; 575 | return callCount === 1 ? stmt1 : stmt2; 576 | }); 577 | 578 | repository.searchTemplatesByMetadata({}, 10, 0); 579 | 580 | expect(logger.warn).toHaveBeenCalledWith( 581 | 'Some IDs were filtered out as invalid', 582 | expect.objectContaining({ 583 | original: 4, 584 | valid: 2, 585 | filtered: 2 586 | }) 587 | ); 588 | }); 589 | 590 | it('should not warn when all IDs are valid', () => { 591 | const stmt1 = new MockPreparedStatement(''); 592 | stmt1._setMockResults([ 593 | { id: 1 }, 594 | { id: 2 }, 595 | { id: 3 } 596 | ]); 597 | 598 | const stmt2 = new MockPreparedStatement(''); 599 | stmt2._setMockResults([ 600 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 601 | { id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }, 602 | { id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' } 603 | ]); 604 | 605 | let callCount = 0; 606 | mockAdapter.prepare = vi.fn((sql: string) => { 607 | callCount++; 608 | return callCount === 1 ? stmt1 : stmt2; 609 | }); 610 | 611 | repository.searchTemplatesByMetadata({}, 10, 0); 612 | 613 | expect(logger.warn).not.toHaveBeenCalledWith( 614 | 'Some IDs were filtered out as invalid', 615 | expect.any(Object) 616 | ); 617 | }); 618 | }); 619 | 620 | describe('getMetadataSearchCount - Shared Helper Usage', () => { 621 | it('should use buildMetadataFilterConditions for category', () => { 622 | const stmt = new MockPreparedStatement(''); 623 | stmt._setMockResults([{ count: 5 }]); 624 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 625 | 626 | const result = repository.getMetadataSearchCount({ category: 'automation' }); 627 | 628 | expect(result).toBe(5); 629 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 630 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); 631 | 632 | const capturedParams = stmt._getCapturedParams(); 633 | expect(capturedParams[0][0]).toBe('automation'); 634 | }); 635 | 636 | it('should use buildMetadataFilterConditions for complexity', () => { 637 | const stmt = new MockPreparedStatement(''); 638 | stmt._setMockResults([{ count: 10 }]); 639 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 640 | 641 | const result = repository.getMetadataSearchCount({ complexity: 'medium' }); 642 | 643 | expect(result).toBe(10); 644 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 645 | expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?"); 646 | }); 647 | 648 | it('should use buildMetadataFilterConditions for setup minutes', () => { 649 | const stmt = new MockPreparedStatement(''); 650 | stmt._setMockResults([{ count: 3 }]); 651 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 652 | 653 | const result = repository.getMetadataSearchCount({ 654 | maxSetupMinutes: 30, 655 | minSetupMinutes: 10 656 | }); 657 | 658 | expect(result).toBe(3); 659 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 660 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?"); 661 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?"); 662 | }); 663 | 664 | it('should use buildMetadataFilterConditions for service and audience', () => { 665 | const stmt = new MockPreparedStatement(''); 666 | stmt._setMockResults([{ count: 7 }]); 667 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 668 | 669 | const result = repository.getMetadataSearchCount({ 670 | requiredService: 'openai', 671 | targetAudience: 'developers' 672 | }); 673 | 674 | expect(result).toBe(7); 675 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 676 | expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); 677 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); 678 | }); 679 | 680 | it('should use buildMetadataFilterConditions with all filters', () => { 681 | const stmt = new MockPreparedStatement(''); 682 | stmt._setMockResults([{ count: 2 }]); 683 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 684 | 685 | const result = repository.getMetadataSearchCount({ 686 | category: 'integration', 687 | complexity: 'complex', 688 | maxSetupMinutes: 120, 689 | minSetupMinutes: 30, 690 | requiredService: 'slack', 691 | targetAudience: 'marketers' 692 | }); 693 | 694 | expect(result).toBe(2); 695 | const prepareCall = mockAdapter.prepare.mock.calls[0][0]; 696 | expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'"); 697 | expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?"); 698 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?"); 699 | expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?"); 700 | expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'"); 701 | expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'"); 702 | 703 | const capturedParams = stmt._getCapturedParams(); 704 | expect(capturedParams[0]).toEqual(['integration', 'complex', 120, 30, 'slack', 'marketers']); 705 | }); 706 | 707 | it('should return 0 when no matches', () => { 708 | const stmt = new MockPreparedStatement(''); 709 | stmt._setMockResults([{ count: 0 }]); 710 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 711 | 712 | const result = repository.getMetadataSearchCount({ category: 'nonexistent' }); 713 | 714 | expect(result).toBe(0); 715 | }); 716 | }); 717 | 718 | describe('Two-Phase Query Optimization', () => { 719 | it('should execute two separate queries', () => { 720 | const stmt1 = new MockPreparedStatement(''); 721 | stmt1._setMockResults([{ id: 1 }, { id: 2 }]); 722 | 723 | const stmt2 = new MockPreparedStatement(''); 724 | stmt2._setMockResults([ 725 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }, 726 | { id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' } 727 | ]); 728 | 729 | let callCount = 0; 730 | mockAdapter.prepare = vi.fn((sql: string) => { 731 | callCount++; 732 | return callCount === 1 ? stmt1 : stmt2; 733 | }); 734 | 735 | repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0); 736 | 737 | expect(mockAdapter.prepare).toHaveBeenCalledTimes(2); 738 | 739 | // First query should select only ID 740 | const phase1Query = mockAdapter.prepare.mock.calls[0][0]; 741 | expect(phase1Query).toContain('SELECT id FROM templates'); 742 | expect(phase1Query).toContain('ORDER BY views DESC, created_at DESC, id ASC'); 743 | 744 | // Second query should use CTE with ordered IDs 745 | const phase2Query = mockAdapter.prepare.mock.calls[1][0]; 746 | expect(phase2Query).toContain('WITH ordered_ids(id, sort_order) AS'); 747 | expect(phase2Query).toContain('VALUES (1, 0), (2, 1)'); 748 | expect(phase2Query).toContain('SELECT t.* FROM templates t'); 749 | expect(phase2Query).toContain('INNER JOIN ordered_ids o ON t.id = o.id'); 750 | expect(phase2Query).toContain('ORDER BY o.sort_order'); 751 | }); 752 | 753 | it('should skip phase 2 when no IDs found', () => { 754 | const stmt = new MockPreparedStatement(''); 755 | stmt._setMockResults([]); 756 | mockAdapter.prepare = vi.fn().mockReturnValue(stmt); 757 | 758 | const result = repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0); 759 | 760 | expect(result).toHaveLength(0); 761 | // Should only call prepare once (phase 1) 762 | expect(mockAdapter.prepare).toHaveBeenCalledTimes(1); 763 | }); 764 | 765 | it('should preserve ordering with stable sort', () => { 766 | const stmt1 = new MockPreparedStatement(''); 767 | stmt1._setMockResults([ 768 | { id: 5 }, 769 | { id: 3 }, 770 | { id: 1 } 771 | ]); 772 | 773 | const stmt2 = new MockPreparedStatement(''); 774 | stmt2._setMockResults([ 775 | { id: 5, workflow_id: 5, name: 'Template 5', workflow_json: '{}' }, 776 | { id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }, 777 | { id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' } 778 | ]); 779 | 780 | let callCount = 0; 781 | mockAdapter.prepare = vi.fn((sql: string) => { 782 | callCount++; 783 | return callCount === 1 ? stmt1 : stmt2; 784 | }); 785 | 786 | repository.searchTemplatesByMetadata({}, 10, 0); 787 | 788 | // Check that phase 2 query maintains order: (5,0), (3,1), (1,2) 789 | const phase2Query = mockAdapter.prepare.mock.calls[1][0]; 790 | expect(phase2Query).toContain('VALUES (5, 0), (3, 1), (1, 2)'); 791 | }); 792 | }); 793 | }); 794 | ``` -------------------------------------------------------------------------------- /src/services/example-generator.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * ExampleGenerator Service 3 | * 4 | * Provides concrete, working examples for n8n nodes to help AI agents 5 | * understand how to configure them properly. 6 | */ 7 | 8 | export interface NodeExamples { 9 | minimal: Record<string, any>; 10 | common?: Record<string, any>; 11 | advanced?: Record<string, any>; 12 | } 13 | 14 | export class ExampleGenerator { 15 | /** 16 | * Curated examples for the most commonly used nodes. 17 | * Each example is a valid configuration that can be used directly. 18 | */ 19 | private static NODE_EXAMPLES: Record<string, NodeExamples> = { 20 | // HTTP Request - Most versatile node 21 | 'nodes-base.httpRequest': { 22 | minimal: { 23 | url: 'https://api.example.com/data' 24 | }, 25 | common: { 26 | method: 'POST', 27 | url: 'https://api.example.com/users', 28 | sendBody: true, 29 | contentType: 'json', 30 | specifyBody: 'json', 31 | jsonBody: '{\n "name": "John Doe",\n "email": "[email protected]"\n}' 32 | }, 33 | advanced: { 34 | method: 'POST', 35 | url: 'https://api.example.com/protected/resource', 36 | authentication: 'genericCredentialType', 37 | genericAuthType: 'headerAuth', 38 | sendHeaders: true, 39 | headerParameters: { 40 | parameters: [ 41 | { 42 | name: 'X-API-Version', 43 | value: 'v2' 44 | } 45 | ] 46 | }, 47 | sendBody: true, 48 | contentType: 'json', 49 | specifyBody: 'json', 50 | jsonBody: '{\n "action": "update",\n "data": {}\n}', 51 | // Error handling for API calls 52 | onError: 'continueRegularOutput', 53 | retryOnFail: true, 54 | maxTries: 3, 55 | waitBetweenTries: 1000, 56 | alwaysOutputData: true 57 | } 58 | }, 59 | 60 | // Webhook - Entry point for workflows 61 | 'nodes-base.webhook': { 62 | minimal: { 63 | path: 'my-webhook', 64 | httpMethod: 'POST' 65 | }, 66 | common: { 67 | path: 'webhook-endpoint', 68 | httpMethod: 'POST', 69 | responseMode: 'lastNode', 70 | responseData: 'allEntries', 71 | responseCode: 200, 72 | // Webhooks should continue on fail to avoid blocking responses 73 | onError: 'continueRegularOutput', 74 | alwaysOutputData: true 75 | } 76 | }, 77 | 78 | // Webhook data processing example 79 | 'nodes-base.code.webhookProcessing': { 80 | minimal: { 81 | language: 'javaScript', 82 | jsCode: `// ⚠️ CRITICAL: Webhook data is nested under 'body' property! 83 | // This Code node should be connected after a Webhook node 84 | 85 | // ❌ WRONG - This will be undefined: 86 | // const command = items[0].json.testCommand; 87 | 88 | // ✅ CORRECT - Access webhook data through body: 89 | const webhookData = items[0].json.body; 90 | const headers = items[0].json.headers; 91 | const query = items[0].json.query; 92 | 93 | // Process webhook payload 94 | return [{ 95 | json: { 96 | // Extract data from webhook body 97 | command: webhookData.testCommand, 98 | userId: webhookData.userId, 99 | data: webhookData.data, 100 | 101 | // Add metadata 102 | timestamp: DateTime.now().toISO(), 103 | requestId: headers['x-request-id'] || crypto.randomUUID(), 104 | source: query.source || 'webhook', 105 | 106 | // Original webhook info 107 | httpMethod: items[0].json.httpMethod, 108 | webhookPath: items[0].json.webhookPath 109 | } 110 | }];` 111 | } 112 | }, 113 | 114 | // Code - Custom logic 115 | 'nodes-base.code': { 116 | minimal: { 117 | language: 'javaScript', 118 | jsCode: 'return [{json: {result: "success"}}];' 119 | }, 120 | common: { 121 | language: 'javaScript', 122 | jsCode: `// Process each item and add timestamp 123 | return items.map(item => ({ 124 | json: { 125 | ...item.json, 126 | processed: true, 127 | timestamp: DateTime.now().toISO() 128 | } 129 | }));`, 130 | onError: 'continueRegularOutput' 131 | }, 132 | advanced: { 133 | language: 'javaScript', 134 | jsCode: `// Advanced data processing with proper helper checks 135 | const crypto = require('crypto'); 136 | const results = []; 137 | 138 | for (const item of items) { 139 | try { 140 | // Validate required fields 141 | if (!item.json.email || !item.json.name) { 142 | throw new Error('Missing required fields: email or name'); 143 | } 144 | 145 | // Generate secure API key 146 | const apiKey = crypto.randomBytes(16).toString('hex'); 147 | 148 | // Check if $helpers is available before using 149 | let response; 150 | if (typeof $helpers !== 'undefined' && $helpers.httpRequest) { 151 | response = await $helpers.httpRequest({ 152 | method: 'POST', 153 | url: 'https://api.example.com/process', 154 | body: { 155 | email: item.json.email, 156 | name: item.json.name, 157 | apiKey 158 | }, 159 | headers: { 160 | 'Content-Type': 'application/json' 161 | } 162 | }); 163 | } else { 164 | // Fallback if $helpers not available 165 | response = { message: 'HTTP requests not available in this n8n version' }; 166 | } 167 | 168 | // Add to results with response data 169 | results.push({ 170 | json: { 171 | ...item.json, 172 | apiResponse: response, 173 | processedAt: DateTime.now().toISO(), 174 | status: 'success' 175 | } 176 | }); 177 | 178 | } catch (error) { 179 | // Include failed items with error info 180 | results.push({ 181 | json: { 182 | ...item.json, 183 | error: error.message, 184 | status: 'failed', 185 | processedAt: DateTime.now().toISO() 186 | } 187 | }); 188 | } 189 | } 190 | 191 | return results;`, 192 | onError: 'continueRegularOutput', 193 | retryOnFail: true, 194 | maxTries: 2 195 | } 196 | }, 197 | 198 | // Additional Code node examples 199 | 'nodes-base.code.dataTransform': { 200 | minimal: { 201 | language: 'javaScript', 202 | jsCode: `// Transform CSV-like data to JSON 203 | return items.map(item => { 204 | const lines = item.json.data.split('\\n'); 205 | const headers = lines[0].split(','); 206 | const rows = lines.slice(1).map(line => { 207 | const values = line.split(','); 208 | return headers.reduce((obj, header, i) => { 209 | obj[header.trim()] = values[i]?.trim() || ''; 210 | return obj; 211 | }, {}); 212 | }); 213 | 214 | return {json: {rows, count: rows.length}}; 215 | });` 216 | } 217 | }, 218 | 219 | 'nodes-base.code.aggregation': { 220 | minimal: { 221 | language: 'javaScript', 222 | jsCode: `// Aggregate data from all items 223 | const totals = items.reduce((acc, item) => { 224 | acc.count++; 225 | acc.sum += item.json.amount || 0; 226 | acc.categories[item.json.category] = (acc.categories[item.json.category] || 0) + 1; 227 | return acc; 228 | }, {count: 0, sum: 0, categories: {}}); 229 | 230 | return [{ 231 | json: { 232 | totalItems: totals.count, 233 | totalAmount: totals.sum, 234 | averageAmount: totals.sum / totals.count, 235 | categoryCounts: totals.categories, 236 | processedAt: DateTime.now().toISO() 237 | } 238 | }];` 239 | } 240 | }, 241 | 242 | 'nodes-base.code.filtering': { 243 | minimal: { 244 | language: 'javaScript', 245 | jsCode: `// Filter items based on conditions 246 | return items 247 | .filter(item => { 248 | const amount = item.json.amount || 0; 249 | const status = item.json.status || ''; 250 | return amount > 100 && status === 'active'; 251 | }) 252 | .map(item => ({json: item.json}));` 253 | } 254 | }, 255 | 256 | 'nodes-base.code.jmespathFiltering': { 257 | minimal: { 258 | language: 'javaScript', 259 | jsCode: `// JMESPath filtering - IMPORTANT: Use backticks for numeric literals! 260 | const allItems = items.map(item => item.json); 261 | 262 | // ✅ CORRECT - Filter with numeric literals using backticks 263 | const expensiveItems = $jmespath(allItems, '[?price >= \`100\`]'); 264 | const lowStock = $jmespath(allItems, '[?inventory < \`10\`]'); 265 | const highPriority = $jmespath(allItems, '[?priority == \`1\`]'); 266 | 267 | // Combine multiple conditions 268 | const urgentExpensive = $jmespath(allItems, '[?price >= \`100\` && priority == \`1\`]'); 269 | 270 | // String comparisons don't need backticks 271 | const activeItems = $jmespath(allItems, '[?status == "active"]'); 272 | 273 | // Return filtered results 274 | return expensiveItems.map(item => ({json: item}));` 275 | } 276 | }, 277 | 278 | 'nodes-base.code.pythonExample': { 279 | minimal: { 280 | language: 'python', 281 | pythonCode: `# Python data processing - use underscore prefix for built-in variables 282 | import json 283 | from datetime import datetime 284 | import re 285 | 286 | results = [] 287 | 288 | # Use _input.all() to get items in Python 289 | for item in _input.all(): 290 | # Convert JsProxy to Python dict to avoid issues with null values 291 | item_data = item.json.to_py() 292 | 293 | # Clean email addresses 294 | email = item_data.get('email', '') 295 | if email and re.match(r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$', email): 296 | cleaned_data = { 297 | 'email': email.lower(), 298 | 'name': item_data.get('name', '').title(), 299 | 'validated': True, 300 | 'timestamp': datetime.now().isoformat() 301 | } 302 | else: 303 | # Spread operator doesn't work with JsProxy, use dict() 304 | cleaned_data = dict(item_data) 305 | cleaned_data['validated'] = False 306 | cleaned_data['error'] = 'Invalid email format' 307 | 308 | results.append({'json': cleaned_data}) 309 | 310 | return results` 311 | } 312 | }, 313 | 314 | 'nodes-base.code.aiTool': { 315 | minimal: { 316 | language: 'javaScript', 317 | mode: 'runOnceForEachItem', 318 | jsCode: `// Code node as AI tool - calculate discount 319 | const quantity = $json.quantity || 1; 320 | const price = $json.price || 0; 321 | 322 | let discountRate = 0; 323 | if (quantity >= 100) discountRate = 0.20; 324 | else if (quantity >= 50) discountRate = 0.15; 325 | else if (quantity >= 20) discountRate = 0.10; 326 | else if (quantity >= 10) discountRate = 0.05; 327 | 328 | const subtotal = price * quantity; 329 | const discount = subtotal * discountRate; 330 | const total = subtotal - discount; 331 | 332 | return [{ 333 | json: { 334 | quantity, 335 | price, 336 | subtotal, 337 | discountRate: discountRate * 100, 338 | discountAmount: discount, 339 | total, 340 | savings: discount 341 | } 342 | }];` 343 | } 344 | }, 345 | 346 | 'nodes-base.code.crypto': { 347 | minimal: { 348 | language: 'javaScript', 349 | jsCode: `// Using crypto in Code nodes - it IS available! 350 | const crypto = require('crypto'); 351 | 352 | // Generate secure tokens 353 | const token = crypto.randomBytes(32).toString('hex'); 354 | const uuid = crypto.randomUUID(); 355 | 356 | // Create hashes 357 | const hash = crypto.createHash('sha256') 358 | .update(items[0].json.data || 'test') 359 | .digest('hex'); 360 | 361 | return [{ 362 | json: { 363 | token, 364 | uuid, 365 | hash, 366 | timestamp: DateTime.now().toISO() 367 | } 368 | }];` 369 | } 370 | }, 371 | 372 | 'nodes-base.code.staticData': { 373 | minimal: { 374 | language: 'javaScript', 375 | jsCode: `// Using workflow static data correctly 376 | // IMPORTANT: $getWorkflowStaticData is a standalone function! 377 | const staticData = $getWorkflowStaticData('global'); 378 | 379 | // Initialize counter if not exists 380 | if (!staticData.processCount) { 381 | staticData.processCount = 0; 382 | staticData.firstRun = DateTime.now().toISO(); 383 | } 384 | 385 | // Update counter 386 | staticData.processCount++; 387 | staticData.lastRun = DateTime.now().toISO(); 388 | 389 | // Process items 390 | const results = items.map(item => ({ 391 | json: { 392 | ...item.json, 393 | runNumber: staticData.processCount, 394 | processed: true 395 | } 396 | })); 397 | 398 | return results;` 399 | } 400 | }, 401 | 402 | // Set - Data manipulation 403 | 'nodes-base.set': { 404 | minimal: { 405 | mode: 'manual', 406 | assignments: { 407 | assignments: [ 408 | { 409 | id: '1', 410 | name: 'status', 411 | value: 'active', 412 | type: 'string' 413 | } 414 | ] 415 | } 416 | }, 417 | common: { 418 | mode: 'manual', 419 | includeOtherFields: true, 420 | assignments: { 421 | assignments: [ 422 | { 423 | id: '1', 424 | name: 'status', 425 | value: 'processed', 426 | type: 'string' 427 | }, 428 | { 429 | id: '2', 430 | name: 'processedAt', 431 | value: '={{ $now.toISO() }}', 432 | type: 'string' 433 | }, 434 | { 435 | id: '3', 436 | name: 'itemCount', 437 | value: '={{ $items().length }}', 438 | type: 'number' 439 | } 440 | ] 441 | } 442 | } 443 | }, 444 | 445 | // If - Conditional logic 446 | 'nodes-base.if': { 447 | minimal: { 448 | conditions: { 449 | conditions: [ 450 | { 451 | id: '1', 452 | leftValue: '={{ $json.status }}', 453 | rightValue: 'active', 454 | operator: { 455 | type: 'string', 456 | operation: 'equals' 457 | } 458 | } 459 | ] 460 | } 461 | }, 462 | common: { 463 | conditions: { 464 | conditions: [ 465 | { 466 | id: '1', 467 | leftValue: '={{ $json.status }}', 468 | rightValue: 'active', 469 | operator: { 470 | type: 'string', 471 | operation: 'equals' 472 | } 473 | }, 474 | { 475 | id: '2', 476 | leftValue: '={{ $json.count }}', 477 | rightValue: 10, 478 | operator: { 479 | type: 'number', 480 | operation: 'gt' 481 | } 482 | } 483 | ] 484 | }, 485 | combineOperation: 'all' 486 | } 487 | }, 488 | 489 | // PostgreSQL - Database operations 490 | 'nodes-base.postgres': { 491 | minimal: { 492 | operation: 'executeQuery', 493 | query: 'SELECT * FROM users LIMIT 10' 494 | }, 495 | common: { 496 | operation: 'insert', 497 | table: 'users', 498 | columns: 'name,email,created_at', 499 | additionalFields: {} 500 | }, 501 | advanced: { 502 | operation: 'executeQuery', 503 | query: `INSERT INTO users (name, email, status) 504 | VALUES ($1, $2, $3) 505 | ON CONFLICT (email) 506 | DO UPDATE SET 507 | name = EXCLUDED.name, 508 | updated_at = NOW() 509 | RETURNING *;`, 510 | additionalFields: { 511 | queryParams: '={{ $json.name }},{{ $json.email }},active' 512 | }, 513 | // Database operations should retry on connection errors 514 | retryOnFail: true, 515 | maxTries: 3, 516 | waitBetweenTries: 2000, 517 | onError: 'continueErrorOutput' 518 | } 519 | }, 520 | 521 | // OpenAI - AI operations 522 | 'nodes-base.openAi': { 523 | minimal: { 524 | resource: 'chat', 525 | operation: 'message', 526 | modelId: 'gpt-3.5-turbo', 527 | messages: { 528 | values: [ 529 | { 530 | role: 'user', 531 | content: 'Hello, how can you help me?' 532 | } 533 | ] 534 | } 535 | }, 536 | common: { 537 | resource: 'chat', 538 | operation: 'message', 539 | modelId: 'gpt-4', 540 | messages: { 541 | values: [ 542 | { 543 | role: 'system', 544 | content: 'You are a helpful assistant that summarizes text concisely.' 545 | }, 546 | { 547 | role: 'user', 548 | content: '={{ $json.text }}' 549 | } 550 | ] 551 | }, 552 | options: { 553 | maxTokens: 150, 554 | temperature: 0.7 555 | }, 556 | // AI calls should handle rate limits and transient errors 557 | retryOnFail: true, 558 | maxTries: 3, 559 | waitBetweenTries: 5000, 560 | onError: 'continueRegularOutput', 561 | alwaysOutputData: true 562 | } 563 | }, 564 | 565 | // Google Sheets - Spreadsheet operations 566 | 'nodes-base.googleSheets': { 567 | minimal: { 568 | operation: 'read', 569 | documentId: { 570 | __rl: true, 571 | value: 'https://docs.google.com/spreadsheets/d/your-sheet-id', 572 | mode: 'url' 573 | }, 574 | sheetName: 'Sheet1' 575 | }, 576 | common: { 577 | operation: 'append', 578 | documentId: { 579 | __rl: true, 580 | value: 'your-sheet-id', 581 | mode: 'id' 582 | }, 583 | sheetName: 'Sheet1', 584 | dataStartRow: 2, 585 | columns: { 586 | mappingMode: 'defineBelow', 587 | value: { 588 | 'Name': '={{ $json.name }}', 589 | 'Email': '={{ $json.email }}', 590 | 'Date': '={{ $now.toISO() }}' 591 | } 592 | } 593 | } 594 | }, 595 | 596 | // Slack - Messaging 597 | 'nodes-base.slack': { 598 | minimal: { 599 | resource: 'message', 600 | operation: 'post', 601 | channel: '#general', 602 | text: 'Hello from n8n!' 603 | }, 604 | common: { 605 | resource: 'message', 606 | operation: 'post', 607 | channel: '#notifications', 608 | text: 'New order received!', 609 | attachments: [ 610 | { 611 | color: '#36a64f', 612 | title: 'Order #{{ $json.orderId }}', 613 | fields: { 614 | item: [ 615 | { 616 | title: 'Customer', 617 | value: '{{ $json.customerName }}', 618 | short: true 619 | }, 620 | { 621 | title: 'Amount', 622 | value: '${{ $json.amount }}', 623 | short: true 624 | } 625 | ] 626 | } 627 | } 628 | ], 629 | // Messaging services should handle rate limits 630 | retryOnFail: true, 631 | maxTries: 2, 632 | waitBetweenTries: 3000, 633 | onError: 'continueRegularOutput' 634 | } 635 | }, 636 | 637 | // Email - Email operations 638 | 'nodes-base.emailSend': { 639 | minimal: { 640 | fromEmail: '[email protected]', 641 | toEmail: '[email protected]', 642 | subject: 'Test Email', 643 | text: 'This is a test email from n8n.' 644 | }, 645 | common: { 646 | fromEmail: '[email protected]', 647 | toEmail: '={{ $json.email }}', 648 | subject: 'Welcome to our service, {{ $json.name }}!', 649 | html: `<h1>Welcome!</h1> 650 | <p>Hi {{ $json.name }},</p> 651 | <p>Thank you for signing up. We're excited to have you on board!</p> 652 | <p>Best regards,<br>The Team</p>`, 653 | options: { 654 | ccEmail: '[email protected]' 655 | }, 656 | // Email sending should handle transient failures 657 | retryOnFail: true, 658 | maxTries: 3, 659 | waitBetweenTries: 2000, 660 | onError: 'continueRegularOutput' 661 | } 662 | }, 663 | 664 | // Merge - Combining data 665 | 'nodes-base.merge': { 666 | minimal: { 667 | mode: 'append' 668 | }, 669 | common: { 670 | mode: 'mergeByKey', 671 | propertyName1: 'id', 672 | propertyName2: 'userId' 673 | } 674 | }, 675 | 676 | // Function - Legacy custom functions 677 | 'nodes-base.function': { 678 | minimal: { 679 | functionCode: 'return items;' 680 | }, 681 | common: { 682 | functionCode: `// Add a timestamp to each item 683 | const processedItems = items.map(item => { 684 | return { 685 | ...item, 686 | json: { 687 | ...item.json, 688 | processedAt: new Date().toISOString() 689 | } 690 | }; 691 | }); 692 | 693 | return processedItems;` 694 | } 695 | }, 696 | 697 | // Split In Batches - Batch processing 698 | 'nodes-base.splitInBatches': { 699 | minimal: { 700 | batchSize: 10 701 | }, 702 | common: { 703 | batchSize: 100, 704 | options: { 705 | reset: false 706 | } 707 | } 708 | }, 709 | 710 | // Redis - Cache operations 711 | 'nodes-base.redis': { 712 | minimal: { 713 | operation: 'set', 714 | key: 'myKey', 715 | value: 'myValue' 716 | }, 717 | common: { 718 | operation: 'set', 719 | key: 'user:{{ $json.userId }}', 720 | value: '={{ JSON.stringify($json) }}', 721 | expire: true, 722 | ttl: 3600 723 | } 724 | }, 725 | 726 | // MongoDB - NoSQL operations 727 | 'nodes-base.mongoDb': { 728 | minimal: { 729 | operation: 'find', 730 | collection: 'users' 731 | }, 732 | common: { 733 | operation: 'findOneAndUpdate', 734 | collection: 'users', 735 | query: '{ "email": "{{ $json.email }}" }', 736 | update: '{ "$set": { "lastLogin": "{{ $now.toISO() }}" } }', 737 | options: { 738 | upsert: true, 739 | returnNewDocument: true 740 | }, 741 | // NoSQL operations should handle connection issues 742 | retryOnFail: true, 743 | maxTries: 3, 744 | waitBetweenTries: 1000, 745 | onError: 'continueErrorOutput' 746 | } 747 | }, 748 | 749 | // MySQL - Database operations 750 | 'nodes-base.mySql': { 751 | minimal: { 752 | operation: 'executeQuery', 753 | query: 'SELECT * FROM products WHERE active = 1' 754 | }, 755 | common: { 756 | operation: 'insert', 757 | table: 'orders', 758 | columns: 'customer_id,product_id,quantity,order_date', 759 | options: { 760 | queryBatching: 'independently' 761 | }, 762 | // Database writes should handle connection errors 763 | retryOnFail: true, 764 | maxTries: 3, 765 | waitBetweenTries: 2000, 766 | onError: 'stopWorkflow' 767 | } 768 | }, 769 | 770 | // FTP - File transfer 771 | 'nodes-base.ftp': { 772 | minimal: { 773 | operation: 'download', 774 | path: '/files/data.csv' 775 | }, 776 | common: { 777 | operation: 'upload', 778 | path: '/uploads/', 779 | fileName: 'report_{{ $now.format("yyyy-MM-dd") }}.csv', 780 | binaryData: true, 781 | binaryPropertyName: 'data' 782 | } 783 | }, 784 | 785 | // SSH - Remote execution 786 | 'nodes-base.ssh': { 787 | minimal: { 788 | resource: 'command', 789 | operation: 'execute', 790 | command: 'ls -la' 791 | }, 792 | common: { 793 | resource: 'command', 794 | operation: 'execute', 795 | command: 'cd /var/logs && tail -n 100 app.log | grep ERROR', 796 | cwd: '/home/user' 797 | } 798 | }, 799 | 800 | // Execute Command - Local execution 801 | 'nodes-base.executeCommand': { 802 | minimal: { 803 | command: 'echo "Hello from n8n"' 804 | }, 805 | common: { 806 | command: 'node process-data.js --input "{{ $json.filename }}"', 807 | cwd: '/app/scripts' 808 | } 809 | }, 810 | 811 | // GitHub - Version control 812 | 'nodes-base.github': { 813 | minimal: { 814 | resource: 'issue', 815 | operation: 'get', 816 | owner: 'n8n-io', 817 | repository: 'n8n', 818 | issueNumber: 123 819 | }, 820 | common: { 821 | resource: 'issue', 822 | operation: 'create', 823 | owner: '={{ $json.organization }}', 824 | repository: '={{ $json.repo }}', 825 | title: 'Bug: {{ $json.title }}', 826 | body: `## Description 827 | {{ $json.description }} 828 | 829 | ## Steps to Reproduce 830 | {{ $json.steps }} 831 | 832 | ## Expected Behavior 833 | {{ $json.expected }}`, 834 | assignees: ['maintainer'], 835 | labels: ['bug', 'needs-triage'] 836 | } 837 | }, 838 | 839 | // Error Handling Examples and Patterns 840 | 'error-handling.modern-patterns': { 841 | minimal: { 842 | // Basic error handling - continue on error 843 | onError: 'continueRegularOutput' 844 | }, 845 | common: { 846 | // Use error output for special handling 847 | onError: 'continueErrorOutput', 848 | alwaysOutputData: true 849 | }, 850 | advanced: { 851 | // Stop workflow on critical errors 852 | onError: 'stopWorkflow', 853 | // But retry first 854 | retryOnFail: true, 855 | maxTries: 3, 856 | waitBetweenTries: 2000 857 | } 858 | }, 859 | 860 | 'error-handling.api-with-retry': { 861 | minimal: { 862 | url: 'https://api.example.com/data', 863 | retryOnFail: true, 864 | maxTries: 3, 865 | waitBetweenTries: 1000 866 | }, 867 | common: { 868 | method: 'GET', 869 | url: 'https://api.example.com/users/{{ $json.userId }}', 870 | retryOnFail: true, 871 | maxTries: 5, 872 | waitBetweenTries: 2000, 873 | alwaysOutputData: true, 874 | // Headers for better debugging 875 | sendHeaders: true, 876 | headerParameters: { 877 | parameters: [ 878 | { 879 | name: 'X-Request-ID', 880 | value: '={{ $workflow.id }}-{{ $execution.id }}' 881 | } 882 | ] 883 | } 884 | }, 885 | advanced: { 886 | method: 'POST', 887 | url: 'https://api.example.com/critical-operation', 888 | sendBody: true, 889 | contentType: 'json', 890 | specifyBody: 'json', 891 | jsonBody: '{{ JSON.stringify($json) }}', 892 | // Exponential backoff pattern 893 | retryOnFail: true, 894 | maxTries: 5, 895 | waitBetweenTries: 1000, 896 | // Always output for debugging 897 | alwaysOutputData: true, 898 | // Stop workflow on error for critical operations 899 | onError: 'stopWorkflow' 900 | } 901 | }, 902 | 903 | 'error-handling.fault-tolerant': { 904 | minimal: { 905 | // For non-critical operations 906 | onError: 'continueRegularOutput' 907 | }, 908 | common: { 909 | // Data processing that shouldn't stop the workflow 910 | onError: 'continueRegularOutput', 911 | alwaysOutputData: true 912 | }, 913 | advanced: { 914 | // Combination for resilient processing 915 | onError: 'continueRegularOutput', 916 | retryOnFail: true, 917 | maxTries: 2, 918 | waitBetweenTries: 500, 919 | alwaysOutputData: true 920 | } 921 | }, 922 | 923 | 'error-handling.database-patterns': { 924 | minimal: { 925 | // Database reads can continue on error 926 | onError: 'continueRegularOutput', 927 | alwaysOutputData: true 928 | }, 929 | common: { 930 | // Database writes should retry then stop 931 | retryOnFail: true, 932 | maxTries: 3, 933 | waitBetweenTries: 2000, 934 | onError: 'stopWorkflow' 935 | }, 936 | advanced: { 937 | // Transaction-safe operations 938 | onError: 'continueErrorOutput', 939 | retryOnFail: false, // Don't retry transactions 940 | alwaysOutputData: true 941 | } 942 | }, 943 | 944 | 'error-handling.webhook-patterns': { 945 | minimal: { 946 | // Always respond to webhooks 947 | onError: 'continueRegularOutput', 948 | alwaysOutputData: true 949 | }, 950 | common: { 951 | // Process errors separately 952 | onError: 'continueErrorOutput', 953 | alwaysOutputData: true, 954 | // Add custom error response 955 | responseCode: 200, 956 | responseData: 'allEntries' 957 | } 958 | }, 959 | 960 | 'error-handling.ai-patterns': { 961 | minimal: { 962 | // AI calls should handle rate limits 963 | retryOnFail: true, 964 | maxTries: 3, 965 | waitBetweenTries: 5000, 966 | onError: 'continueRegularOutput' 967 | }, 968 | common: { 969 | // Exponential backoff for rate limits 970 | retryOnFail: true, 971 | maxTries: 5, 972 | waitBetweenTries: 2000, 973 | onError: 'continueRegularOutput', 974 | alwaysOutputData: true 975 | } 976 | } 977 | }; 978 | 979 | /** 980 | * Get examples for a specific node type 981 | */ 982 | static getExamples(nodeType: string, essentials?: any): NodeExamples { 983 | // Return curated examples if available 984 | const examples = this.NODE_EXAMPLES[nodeType]; 985 | if (examples) { 986 | return examples; 987 | } 988 | 989 | // Generate basic examples for unconfigured nodes 990 | return this.generateBasicExamples(nodeType, essentials); 991 | } 992 | 993 | /** 994 | * Generate basic examples for nodes without curated ones 995 | */ 996 | private static generateBasicExamples(nodeType: string, essentials?: any): NodeExamples { 997 | const minimal: Record<string, any> = {}; 998 | 999 | // Add required fields with sensible defaults 1000 | if (essentials?.required) { 1001 | for (const prop of essentials.required) { 1002 | minimal[prop.name] = this.getDefaultValue(prop); 1003 | } 1004 | } 1005 | 1006 | // Add first common property if no required fields 1007 | if (Object.keys(minimal).length === 0 && essentials?.common?.length > 0) { 1008 | const firstCommon = essentials.common[0]; 1009 | minimal[firstCommon.name] = this.getDefaultValue(firstCommon); 1010 | } 1011 | 1012 | return { minimal }; 1013 | } 1014 | 1015 | /** 1016 | * Generate a sensible default value for a property 1017 | */ 1018 | private static getDefaultValue(prop: any): any { 1019 | // Use configured default if available 1020 | if (prop.default !== undefined) { 1021 | return prop.default; 1022 | } 1023 | 1024 | // Generate based on type and name 1025 | switch (prop.type) { 1026 | case 'string': 1027 | return this.getStringDefault(prop); 1028 | 1029 | case 'number': 1030 | return prop.name.includes('port') ? 80 : 1031 | prop.name.includes('timeout') ? 30000 : 1032 | prop.name.includes('limit') ? 10 : 0; 1033 | 1034 | case 'boolean': 1035 | return false; 1036 | 1037 | case 'options': 1038 | case 'multiOptions': 1039 | return prop.options?.[0]?.value || ''; 1040 | 1041 | case 'json': 1042 | return '{\n "key": "value"\n}'; 1043 | 1044 | case 'collection': 1045 | case 'fixedCollection': 1046 | return {}; 1047 | 1048 | default: 1049 | return ''; 1050 | } 1051 | } 1052 | 1053 | /** 1054 | * Get default value for string properties based on name 1055 | */ 1056 | private static getStringDefault(prop: any): string { 1057 | const name = prop.name.toLowerCase(); 1058 | 1059 | // URL/endpoint fields 1060 | if (name.includes('url') || name === 'endpoint') { 1061 | return 'https://api.example.com'; 1062 | } 1063 | 1064 | // Email fields 1065 | if (name.includes('email')) { 1066 | return name.includes('from') ? '[email protected]' : '[email protected]'; 1067 | } 1068 | 1069 | // Path fields 1070 | if (name.includes('path')) { 1071 | return name.includes('webhook') ? 'my-webhook' : '/path/to/file'; 1072 | } 1073 | 1074 | // Name fields 1075 | if (name === 'name' || name.includes('username')) { 1076 | return 'John Doe'; 1077 | } 1078 | 1079 | // Key fields 1080 | if (name.includes('key')) { 1081 | return 'myKey'; 1082 | } 1083 | 1084 | // Query fields 1085 | if (name === 'query' || name.includes('sql')) { 1086 | return 'SELECT * FROM table_name LIMIT 10'; 1087 | } 1088 | 1089 | // Collection/table fields 1090 | if (name === 'collection' || name === 'table') { 1091 | return 'users'; 1092 | } 1093 | 1094 | // Use placeholder if available 1095 | if (prop.placeholder) { 1096 | return prop.placeholder; 1097 | } 1098 | 1099 | return ''; 1100 | } 1101 | 1102 | /** 1103 | * Get example for a specific use case 1104 | */ 1105 | static getTaskExample(nodeType: string, task: string): Record<string, any> | undefined { 1106 | const examples = this.NODE_EXAMPLES[nodeType]; 1107 | if (!examples) return undefined; 1108 | 1109 | // Map common tasks to example types 1110 | const taskMap: Record<string, keyof NodeExamples> = { 1111 | 'basic': 'minimal', 1112 | 'simple': 'minimal', 1113 | 'typical': 'common', 1114 | 'standard': 'common', 1115 | 'complex': 'advanced', 1116 | 'full': 'advanced' 1117 | }; 1118 | 1119 | const exampleType = taskMap[task] || 'common'; 1120 | return examples[exampleType] || examples.minimal; 1121 | } 1122 | } ``` -------------------------------------------------------------------------------- /tests/unit/telemetry/config-manager.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { TelemetryConfigManager } from '../../../src/telemetry/config-manager'; 3 | import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; 4 | import { join } from 'path'; 5 | import { homedir } from 'os'; 6 | 7 | // Mock fs module 8 | vi.mock('fs', async () => { 9 | const actual = await vi.importActual<typeof import('fs')>('fs'); 10 | return { 11 | ...actual, 12 | existsSync: vi.fn(), 13 | readFileSync: vi.fn(), 14 | writeFileSync: vi.fn(), 15 | mkdirSync: vi.fn() 16 | }; 17 | }); 18 | 19 | describe('TelemetryConfigManager', () => { 20 | let manager: TelemetryConfigManager; 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks(); 24 | // Clear singleton instance 25 | (TelemetryConfigManager as any).instance = null; 26 | 27 | // Mock console.log to suppress first-run notice in tests 28 | vi.spyOn(console, 'log').mockImplementation(() => {}); 29 | }); 30 | 31 | afterEach(() => { 32 | vi.restoreAllMocks(); 33 | }); 34 | 35 | describe('getInstance', () => { 36 | it('should return singleton instance', () => { 37 | const instance1 = TelemetryConfigManager.getInstance(); 38 | const instance2 = TelemetryConfigManager.getInstance(); 39 | expect(instance1).toBe(instance2); 40 | }); 41 | }); 42 | 43 | describe('loadConfig', () => { 44 | it('should create default config on first run', () => { 45 | vi.mocked(existsSync).mockReturnValue(false); 46 | 47 | manager = TelemetryConfigManager.getInstance(); 48 | const config = manager.loadConfig(); 49 | 50 | expect(config.enabled).toBe(true); 51 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 52 | expect(config.firstRun).toBeDefined(); 53 | expect(vi.mocked(mkdirSync)).toHaveBeenCalledWith( 54 | join(homedir(), '.n8n-mcp'), 55 | { recursive: true } 56 | ); 57 | expect(vi.mocked(writeFileSync)).toHaveBeenCalled(); 58 | }); 59 | 60 | it('should load existing config from disk', () => { 61 | const mockConfig = { 62 | enabled: false, 63 | userId: 'test-user-id', 64 | firstRun: '2024-01-01T00:00:00Z' 65 | }; 66 | 67 | vi.mocked(existsSync).mockReturnValue(true); 68 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig)); 69 | 70 | manager = TelemetryConfigManager.getInstance(); 71 | const config = manager.loadConfig(); 72 | 73 | expect(config).toEqual(mockConfig); 74 | }); 75 | 76 | it('should handle corrupted config file gracefully', () => { 77 | vi.mocked(existsSync).mockReturnValue(true); 78 | vi.mocked(readFileSync).mockReturnValue('invalid json'); 79 | 80 | manager = TelemetryConfigManager.getInstance(); 81 | const config = manager.loadConfig(); 82 | 83 | expect(config.enabled).toBe(false); 84 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 85 | }); 86 | 87 | it('should add userId to config if missing', () => { 88 | const mockConfig = { 89 | enabled: true, 90 | firstRun: '2024-01-01T00:00:00Z' 91 | }; 92 | 93 | vi.mocked(existsSync).mockReturnValue(true); 94 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig)); 95 | 96 | manager = TelemetryConfigManager.getInstance(); 97 | const config = manager.loadConfig(); 98 | 99 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 100 | expect(vi.mocked(writeFileSync)).toHaveBeenCalled(); 101 | }); 102 | }); 103 | 104 | describe('isEnabled', () => { 105 | it('should return true when telemetry is enabled', () => { 106 | vi.mocked(existsSync).mockReturnValue(true); 107 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 108 | enabled: true, 109 | userId: 'test-id' 110 | })); 111 | 112 | manager = TelemetryConfigManager.getInstance(); 113 | expect(manager.isEnabled()).toBe(true); 114 | }); 115 | 116 | it('should return false when telemetry is disabled', () => { 117 | vi.mocked(existsSync).mockReturnValue(true); 118 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 119 | enabled: false, 120 | userId: 'test-id' 121 | })); 122 | 123 | manager = TelemetryConfigManager.getInstance(); 124 | expect(manager.isEnabled()).toBe(false); 125 | }); 126 | }); 127 | 128 | describe('getUserId', () => { 129 | it('should return consistent user ID', () => { 130 | vi.mocked(existsSync).mockReturnValue(true); 131 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 132 | enabled: true, 133 | userId: 'test-user-id-123' 134 | })); 135 | 136 | manager = TelemetryConfigManager.getInstance(); 137 | expect(manager.getUserId()).toBe('test-user-id-123'); 138 | }); 139 | }); 140 | 141 | describe('isFirstRun', () => { 142 | it('should return true if config file does not exist', () => { 143 | vi.mocked(existsSync).mockReturnValue(false); 144 | 145 | manager = TelemetryConfigManager.getInstance(); 146 | expect(manager.isFirstRun()).toBe(true); 147 | }); 148 | 149 | it('should return false if config file exists', () => { 150 | vi.mocked(existsSync).mockReturnValue(true); 151 | 152 | manager = TelemetryConfigManager.getInstance(); 153 | expect(manager.isFirstRun()).toBe(false); 154 | }); 155 | }); 156 | 157 | describe('enable/disable', () => { 158 | beforeEach(() => { 159 | vi.mocked(existsSync).mockReturnValue(true); 160 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 161 | enabled: false, 162 | userId: 'test-id' 163 | })); 164 | }); 165 | 166 | it('should enable telemetry', () => { 167 | manager = TelemetryConfigManager.getInstance(); 168 | manager.enable(); 169 | 170 | const calls = vi.mocked(writeFileSync).mock.calls; 171 | expect(calls.length).toBeGreaterThan(0); 172 | const lastCall = calls[calls.length - 1]; 173 | expect(lastCall[1]).toContain('"enabled": true'); 174 | }); 175 | 176 | it('should disable telemetry', () => { 177 | manager = TelemetryConfigManager.getInstance(); 178 | manager.disable(); 179 | 180 | const calls = vi.mocked(writeFileSync).mock.calls; 181 | expect(calls.length).toBeGreaterThan(0); 182 | const lastCall = calls[calls.length - 1]; 183 | expect(lastCall[1]).toContain('"enabled": false'); 184 | }); 185 | }); 186 | 187 | describe('getStatus', () => { 188 | it('should return formatted status string', () => { 189 | vi.mocked(existsSync).mockReturnValue(true); 190 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 191 | enabled: true, 192 | userId: 'test-id', 193 | firstRun: '2024-01-01T00:00:00Z' 194 | })); 195 | 196 | manager = TelemetryConfigManager.getInstance(); 197 | const status = manager.getStatus(); 198 | 199 | expect(status).toContain('ENABLED'); 200 | expect(status).toContain('test-id'); 201 | expect(status).toContain('2024-01-01T00:00:00Z'); 202 | expect(status).toContain('npx n8n-mcp telemetry'); 203 | }); 204 | }); 205 | 206 | describe('edge cases and error handling', () => { 207 | it('should handle file system errors during config creation', () => { 208 | vi.mocked(existsSync).mockReturnValue(false); 209 | vi.mocked(mkdirSync).mockImplementation(() => { 210 | throw new Error('Permission denied'); 211 | }); 212 | 213 | // Should not crash on file system errors 214 | expect(() => TelemetryConfigManager.getInstance()).not.toThrow(); 215 | }); 216 | 217 | it('should handle write errors during config save', () => { 218 | vi.mocked(existsSync).mockReturnValue(true); 219 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 220 | enabled: false, 221 | userId: 'test-id' 222 | })); 223 | vi.mocked(writeFileSync).mockImplementation(() => { 224 | throw new Error('Disk full'); 225 | }); 226 | 227 | manager = TelemetryConfigManager.getInstance(); 228 | 229 | // Should not crash on write errors 230 | expect(() => manager.enable()).not.toThrow(); 231 | expect(() => manager.disable()).not.toThrow(); 232 | }); 233 | 234 | it('should handle missing home directory', () => { 235 | // Mock homedir to return empty string 236 | const originalHomedir = require('os').homedir; 237 | vi.doMock('os', () => ({ 238 | homedir: () => '' 239 | })); 240 | 241 | vi.mocked(existsSync).mockReturnValue(false); 242 | 243 | expect(() => TelemetryConfigManager.getInstance()).not.toThrow(); 244 | }); 245 | 246 | it('should generate valid user ID when crypto.randomBytes fails', () => { 247 | vi.mocked(existsSync).mockReturnValue(false); 248 | 249 | // Mock crypto to fail 250 | vi.doMock('crypto', () => ({ 251 | randomBytes: () => { 252 | throw new Error('Crypto not available'); 253 | } 254 | })); 255 | 256 | manager = TelemetryConfigManager.getInstance(); 257 | const config = manager.loadConfig(); 258 | 259 | expect(config.userId).toBeDefined(); 260 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 261 | }); 262 | 263 | it('should handle concurrent access to config file', () => { 264 | let readCount = 0; 265 | vi.mocked(existsSync).mockReturnValue(true); 266 | vi.mocked(readFileSync).mockImplementation(() => { 267 | readCount++; 268 | if (readCount === 1) { 269 | return JSON.stringify({ 270 | enabled: false, 271 | userId: 'test-id-1' 272 | }); 273 | } 274 | return JSON.stringify({ 275 | enabled: true, 276 | userId: 'test-id-2' 277 | }); 278 | }); 279 | 280 | const manager1 = TelemetryConfigManager.getInstance(); 281 | const manager2 = TelemetryConfigManager.getInstance(); 282 | 283 | // Should be same instance due to singleton pattern 284 | expect(manager1).toBe(manager2); 285 | }); 286 | 287 | it('should handle environment variable overrides', () => { 288 | const originalEnv = process.env.N8N_MCP_TELEMETRY_DISABLED; 289 | 290 | // Test with environment variable set to disable telemetry 291 | process.env.N8N_MCP_TELEMETRY_DISABLED = 'true'; 292 | vi.mocked(existsSync).mockReturnValue(true); 293 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 294 | enabled: true, 295 | userId: 'test-id' 296 | })); 297 | 298 | (TelemetryConfigManager as any).instance = null; 299 | manager = TelemetryConfigManager.getInstance(); 300 | 301 | expect(manager.isEnabled()).toBe(false); 302 | 303 | // Test with environment variable set to enable telemetry 304 | process.env.N8N_MCP_TELEMETRY_DISABLED = 'false'; 305 | (TelemetryConfigManager as any).instance = null; 306 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 307 | enabled: true, 308 | userId: 'test-id' 309 | })); 310 | manager = TelemetryConfigManager.getInstance(); 311 | 312 | expect(manager.isEnabled()).toBe(true); 313 | 314 | // Restore original environment 315 | process.env.N8N_MCP_TELEMETRY_DISABLED = originalEnv; 316 | }); 317 | 318 | it('should handle invalid JSON in config file gracefully', () => { 319 | vi.mocked(existsSync).mockReturnValue(true); 320 | vi.mocked(readFileSync).mockReturnValue('{ invalid json syntax'); 321 | 322 | manager = TelemetryConfigManager.getInstance(); 323 | const config = manager.loadConfig(); 324 | 325 | expect(config.enabled).toBe(false); // Default to disabled on corrupt config 326 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); // Should generate new user ID 327 | }); 328 | 329 | it('should handle config file with partial structure', () => { 330 | vi.mocked(existsSync).mockReturnValue(true); 331 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 332 | enabled: true 333 | // Missing userId and firstRun 334 | })); 335 | 336 | manager = TelemetryConfigManager.getInstance(); 337 | const config = manager.loadConfig(); 338 | 339 | expect(config.enabled).toBe(true); 340 | expect(config.userId).toMatch(/^[a-f0-9]{16}$/); 341 | // firstRun might not be defined if config is partial and loaded from disk 342 | // The implementation only adds firstRun on first creation 343 | }); 344 | 345 | it('should handle config file with invalid data types', () => { 346 | vi.mocked(existsSync).mockReturnValue(true); 347 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 348 | enabled: 'not-a-boolean', 349 | userId: 12345, // Not a string 350 | firstRun: null 351 | })); 352 | 353 | manager = TelemetryConfigManager.getInstance(); 354 | const config = manager.loadConfig(); 355 | 356 | // The config manager loads the data as-is, so we get the original types 357 | // The validation happens during usage, not loading 358 | expect(config.enabled).toBe('not-a-boolean'); 359 | expect(config.userId).toBe(12345); 360 | }); 361 | 362 | it('should handle very large config files', () => { 363 | const largeConfig = { 364 | enabled: true, 365 | userId: 'test-id', 366 | firstRun: '2024-01-01T00:00:00Z', 367 | extraData: 'x'.repeat(1000000) // 1MB of data 368 | }; 369 | 370 | vi.mocked(existsSync).mockReturnValue(true); 371 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify(largeConfig)); 372 | 373 | expect(() => TelemetryConfigManager.getInstance()).not.toThrow(); 374 | }); 375 | 376 | it('should handle config directory creation race conditions', () => { 377 | vi.mocked(existsSync).mockReturnValue(false); 378 | let mkdirCallCount = 0; 379 | vi.mocked(mkdirSync).mockImplementation(() => { 380 | mkdirCallCount++; 381 | if (mkdirCallCount === 1) { 382 | throw new Error('EEXIST: file already exists'); 383 | } 384 | return undefined; 385 | }); 386 | 387 | expect(() => TelemetryConfigManager.getInstance()).not.toThrow(); 388 | }); 389 | 390 | it('should handle file system permission changes', () => { 391 | vi.mocked(existsSync).mockReturnValue(true); 392 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 393 | enabled: false, 394 | userId: 'test-id' 395 | })); 396 | 397 | manager = TelemetryConfigManager.getInstance(); 398 | 399 | // Simulate permission denied on subsequent write 400 | vi.mocked(writeFileSync).mockImplementationOnce(() => { 401 | throw new Error('EACCES: permission denied'); 402 | }); 403 | 404 | expect(() => manager.enable()).not.toThrow(); 405 | }); 406 | 407 | it('should handle system clock changes affecting timestamps', () => { 408 | const futureDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // 1 year in future 409 | const pastDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // 1 year in past 410 | 411 | vi.mocked(existsSync).mockReturnValue(true); 412 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 413 | enabled: true, 414 | userId: 'test-id', 415 | firstRun: futureDate.toISOString() 416 | })); 417 | 418 | manager = TelemetryConfigManager.getInstance(); 419 | const config = manager.loadConfig(); 420 | 421 | expect(config.firstRun).toBeDefined(); 422 | expect(new Date(config.firstRun as string).getTime()).toBeGreaterThan(0); 423 | }); 424 | 425 | it('should handle config updates during runtime', () => { 426 | vi.mocked(existsSync).mockReturnValue(true); 427 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 428 | enabled: false, 429 | userId: 'test-id' 430 | })); 431 | 432 | manager = TelemetryConfigManager.getInstance(); 433 | expect(manager.isEnabled()).toBe(false); 434 | 435 | // Simulate external config change by clearing cache first 436 | (manager as any).config = null; 437 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 438 | enabled: true, 439 | userId: 'test-id' 440 | })); 441 | 442 | // Now calling loadConfig should pick up changes 443 | const newConfig = manager.loadConfig(); 444 | expect(newConfig.enabled).toBe(true); 445 | expect(manager.isEnabled()).toBe(true); 446 | }); 447 | 448 | it('should handle multiple rapid enable/disable calls', () => { 449 | vi.mocked(existsSync).mockReturnValue(true); 450 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 451 | enabled: false, 452 | userId: 'test-id' 453 | })); 454 | 455 | manager = TelemetryConfigManager.getInstance(); 456 | 457 | // Rapidly toggle state 458 | for (let i = 0; i < 100; i++) { 459 | if (i % 2 === 0) { 460 | manager.enable(); 461 | } else { 462 | manager.disable(); 463 | } 464 | } 465 | 466 | // Should not crash and maintain consistent state 467 | expect(typeof manager.isEnabled()).toBe('boolean'); 468 | }); 469 | 470 | it('should handle user ID collision (extremely unlikely)', () => { 471 | vi.mocked(existsSync).mockReturnValue(false); 472 | 473 | // Mock crypto to always return same bytes 474 | const mockBytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); 475 | vi.doMock('crypto', () => ({ 476 | randomBytes: () => mockBytes 477 | })); 478 | 479 | (TelemetryConfigManager as any).instance = null; 480 | const manager1 = TelemetryConfigManager.getInstance(); 481 | const userId1 = manager1.getUserId(); 482 | 483 | (TelemetryConfigManager as any).instance = null; 484 | const manager2 = TelemetryConfigManager.getInstance(); 485 | const userId2 = manager2.getUserId(); 486 | 487 | // Should generate same ID from same random bytes 488 | expect(userId1).toBe(userId2); 489 | expect(userId1).toMatch(/^[a-f0-9]{16}$/); 490 | }); 491 | 492 | it('should handle status generation with missing fields', () => { 493 | vi.mocked(existsSync).mockReturnValue(true); 494 | vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ 495 | enabled: true 496 | // Missing userId and firstRun 497 | })); 498 | 499 | manager = TelemetryConfigManager.getInstance(); 500 | const status = manager.getStatus(); 501 | 502 | expect(status).toContain('ENABLED'); 503 | expect(status).toBeDefined(); 504 | expect(typeof status).toBe('string'); 505 | }); 506 | }); 507 | 508 | describe('Docker/Cloud user ID generation', () => { 509 | let originalIsDocker: string | undefined; 510 | let originalRailway: string | undefined; 511 | 512 | beforeEach(() => { 513 | originalIsDocker = process.env.IS_DOCKER; 514 | originalRailway = process.env.RAILWAY_ENVIRONMENT; 515 | }); 516 | 517 | afterEach(() => { 518 | if (originalIsDocker === undefined) { 519 | delete process.env.IS_DOCKER; 520 | } else { 521 | process.env.IS_DOCKER = originalIsDocker; 522 | } 523 | 524 | if (originalRailway === undefined) { 525 | delete process.env.RAILWAY_ENVIRONMENT; 526 | } else { 527 | process.env.RAILWAY_ENVIRONMENT = originalRailway; 528 | } 529 | }); 530 | 531 | describe('boot_id reading', () => { 532 | it('should read valid boot_id from /proc/sys/kernel/random/boot_id', () => { 533 | const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac'; 534 | process.env.IS_DOCKER = 'true'; 535 | 536 | vi.mocked(existsSync).mockImplementation((path: any) => { 537 | if (path === '/proc/sys/kernel/random/boot_id') return true; 538 | return false; 539 | }); 540 | 541 | vi.mocked(readFileSync).mockImplementation((path: any) => { 542 | if (path === '/proc/sys/kernel/random/boot_id') return mockBootId; 543 | throw new Error('File not found'); 544 | }); 545 | 546 | (TelemetryConfigManager as any).instance = null; 547 | manager = TelemetryConfigManager.getInstance(); 548 | const userId = manager.getUserId(); 549 | 550 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 551 | expect(vi.mocked(readFileSync)).toHaveBeenCalledWith( 552 | '/proc/sys/kernel/random/boot_id', 553 | 'utf-8' 554 | ); 555 | }); 556 | 557 | it('should validate boot_id UUID format', () => { 558 | const invalidBootId = 'not-a-valid-uuid'; 559 | process.env.IS_DOCKER = 'true'; 560 | 561 | vi.mocked(existsSync).mockImplementation((path: any) => { 562 | if (path === '/proc/sys/kernel/random/boot_id') return true; 563 | if (path === '/proc/cpuinfo') return true; 564 | if (path === '/proc/meminfo') return true; 565 | return false; 566 | }); 567 | 568 | vi.mocked(readFileSync).mockImplementation((path: any) => { 569 | if (path === '/proc/sys/kernel/random/boot_id') return invalidBootId; 570 | if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n'; 571 | if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n'; 572 | throw new Error('File not found'); 573 | }); 574 | 575 | (TelemetryConfigManager as any).instance = null; 576 | manager = TelemetryConfigManager.getInstance(); 577 | const userId = manager.getUserId(); 578 | 579 | // Should fallback to combined fingerprint, not use invalid boot_id 580 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 581 | }); 582 | 583 | it('should handle boot_id file not existing', () => { 584 | process.env.IS_DOCKER = 'true'; 585 | 586 | vi.mocked(existsSync).mockImplementation((path: any) => { 587 | if (path === '/proc/sys/kernel/random/boot_id') return false; 588 | if (path === '/proc/cpuinfo') return true; 589 | if (path === '/proc/meminfo') return true; 590 | return false; 591 | }); 592 | 593 | vi.mocked(readFileSync).mockImplementation((path: any) => { 594 | if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n'; 595 | if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n'; 596 | throw new Error('File not found'); 597 | }); 598 | 599 | (TelemetryConfigManager as any).instance = null; 600 | manager = TelemetryConfigManager.getInstance(); 601 | const userId = manager.getUserId(); 602 | 603 | // Should fallback to combined fingerprint 604 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 605 | }); 606 | 607 | it('should handle boot_id read errors gracefully', () => { 608 | process.env.IS_DOCKER = 'true'; 609 | 610 | vi.mocked(existsSync).mockImplementation((path: any) => { 611 | if (path === '/proc/sys/kernel/random/boot_id') return true; 612 | return false; 613 | }); 614 | 615 | vi.mocked(readFileSync).mockImplementation((path: any) => { 616 | if (path === '/proc/sys/kernel/random/boot_id') { 617 | throw new Error('Permission denied'); 618 | } 619 | throw new Error('File not found'); 620 | }); 621 | 622 | (TelemetryConfigManager as any).instance = null; 623 | manager = TelemetryConfigManager.getInstance(); 624 | const userId = manager.getUserId(); 625 | 626 | // Should fallback gracefully 627 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 628 | }); 629 | 630 | it('should generate consistent user ID from same boot_id', () => { 631 | const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac'; 632 | process.env.IS_DOCKER = 'true'; 633 | 634 | vi.mocked(existsSync).mockImplementation((path: any) => { 635 | if (path === '/proc/sys/kernel/random/boot_id') return true; 636 | return false; 637 | }); 638 | 639 | vi.mocked(readFileSync).mockImplementation((path: any) => { 640 | if (path === '/proc/sys/kernel/random/boot_id') return mockBootId; 641 | throw new Error('File not found'); 642 | }); 643 | 644 | (TelemetryConfigManager as any).instance = null; 645 | const manager1 = TelemetryConfigManager.getInstance(); 646 | const userId1 = manager1.getUserId(); 647 | 648 | (TelemetryConfigManager as any).instance = null; 649 | const manager2 = TelemetryConfigManager.getInstance(); 650 | const userId2 = manager2.getUserId(); 651 | 652 | // Same boot_id should produce same user_id 653 | expect(userId1).toBe(userId2); 654 | }); 655 | }); 656 | 657 | describe('combined fingerprint fallback', () => { 658 | it('should generate fingerprint from CPU, memory, and kernel', () => { 659 | process.env.IS_DOCKER = 'true'; 660 | 661 | vi.mocked(existsSync).mockImplementation((path: any) => { 662 | if (path === '/proc/sys/kernel/random/boot_id') return false; 663 | if (path === '/proc/cpuinfo') return true; 664 | if (path === '/proc/meminfo') return true; 665 | if (path === '/proc/version') return true; 666 | return false; 667 | }); 668 | 669 | vi.mocked(readFileSync).mockImplementation((path: any) => { 670 | if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\nprocessor: 2\nprocessor: 3\n'; 671 | if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n'; 672 | if (path === '/proc/version') return 'Linux version 5.15.49-linuxkit'; 673 | throw new Error('File not found'); 674 | }); 675 | 676 | (TelemetryConfigManager as any).instance = null; 677 | manager = TelemetryConfigManager.getInstance(); 678 | const userId = manager.getUserId(); 679 | 680 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 681 | }); 682 | 683 | it('should require at least 3 signals for combined fingerprint', () => { 684 | process.env.IS_DOCKER = 'true'; 685 | 686 | vi.mocked(existsSync).mockImplementation((path: any) => { 687 | if (path === '/proc/sys/kernel/random/boot_id') return false; 688 | // Only platform and arch available (2 signals) 689 | return false; 690 | }); 691 | 692 | (TelemetryConfigManager as any).instance = null; 693 | manager = TelemetryConfigManager.getInstance(); 694 | const userId = manager.getUserId(); 695 | 696 | // Should fallback to generic Docker ID 697 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 698 | }); 699 | 700 | it('should handle partial /proc data', () => { 701 | process.env.IS_DOCKER = 'true'; 702 | 703 | vi.mocked(existsSync).mockImplementation((path: any) => { 704 | if (path === '/proc/sys/kernel/random/boot_id') return false; 705 | if (path === '/proc/cpuinfo') return true; 706 | // meminfo missing 707 | return false; 708 | }); 709 | 710 | vi.mocked(readFileSync).mockImplementation((path: any) => { 711 | if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n'; 712 | throw new Error('File not found'); 713 | }); 714 | 715 | (TelemetryConfigManager as any).instance = null; 716 | manager = TelemetryConfigManager.getInstance(); 717 | const userId = manager.getUserId(); 718 | 719 | // Should include platform and arch, so 4 signals total 720 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 721 | }); 722 | }); 723 | 724 | describe('environment detection', () => { 725 | it('should use Docker method when IS_DOCKER=true', () => { 726 | process.env.IS_DOCKER = 'true'; 727 | 728 | vi.mocked(existsSync).mockReturnValue(false); 729 | 730 | (TelemetryConfigManager as any).instance = null; 731 | manager = TelemetryConfigManager.getInstance(); 732 | const userId = manager.getUserId(); 733 | 734 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 735 | // Should attempt to read boot_id 736 | expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id'); 737 | }); 738 | 739 | it('should use Docker method for Railway environment', () => { 740 | process.env.RAILWAY_ENVIRONMENT = 'production'; 741 | delete process.env.IS_DOCKER; 742 | 743 | vi.mocked(existsSync).mockReturnValue(false); 744 | 745 | (TelemetryConfigManager as any).instance = null; 746 | manager = TelemetryConfigManager.getInstance(); 747 | const userId = manager.getUserId(); 748 | 749 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 750 | // Should attempt to read boot_id 751 | expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id'); 752 | }); 753 | 754 | it('should use file-based method for local installation', () => { 755 | delete process.env.IS_DOCKER; 756 | delete process.env.RAILWAY_ENVIRONMENT; 757 | 758 | vi.mocked(existsSync).mockReturnValue(false); 759 | 760 | (TelemetryConfigManager as any).instance = null; 761 | manager = TelemetryConfigManager.getInstance(); 762 | const userId = manager.getUserId(); 763 | 764 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 765 | // Should NOT attempt to read boot_id 766 | const calls = vi.mocked(existsSync).mock.calls; 767 | const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id'); 768 | expect(bootIdCalls.length).toBe(0); 769 | }); 770 | 771 | it('should detect cloud platforms', () => { 772 | const cloudEnvVars = [ 773 | 'RAILWAY_ENVIRONMENT', 774 | 'RENDER', 775 | 'FLY_APP_NAME', 776 | 'HEROKU_APP_NAME', 777 | 'AWS_EXECUTION_ENV', 778 | 'KUBERNETES_SERVICE_HOST', 779 | 'GOOGLE_CLOUD_PROJECT', 780 | 'AZURE_FUNCTIONS_ENVIRONMENT' 781 | ]; 782 | 783 | cloudEnvVars.forEach(envVar => { 784 | // Clear all env vars 785 | cloudEnvVars.forEach(v => delete process.env[v]); 786 | delete process.env.IS_DOCKER; 787 | 788 | // Set one cloud env var 789 | process.env[envVar] = 'true'; 790 | 791 | vi.mocked(existsSync).mockReturnValue(false); 792 | 793 | (TelemetryConfigManager as any).instance = null; 794 | manager = TelemetryConfigManager.getInstance(); 795 | const userId = manager.getUserId(); 796 | 797 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 798 | 799 | // Should attempt to read boot_id 800 | const calls = vi.mocked(existsSync).mock.calls; 801 | const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id'); 802 | expect(bootIdCalls.length).toBeGreaterThan(0); 803 | 804 | // Clean up 805 | delete process.env[envVar]; 806 | }); 807 | }); 808 | }); 809 | 810 | describe('fallback chain execution', () => { 811 | it('should fallback from boot_id → combined → generic', () => { 812 | process.env.IS_DOCKER = 'true'; 813 | 814 | // All methods fail 815 | vi.mocked(existsSync).mockReturnValue(false); 816 | vi.mocked(readFileSync).mockImplementation(() => { 817 | throw new Error('File not found'); 818 | }); 819 | 820 | (TelemetryConfigManager as any).instance = null; 821 | manager = TelemetryConfigManager.getInstance(); 822 | const userId = manager.getUserId(); 823 | 824 | // Should still generate a generic Docker ID 825 | expect(userId).toMatch(/^[a-f0-9]{16}$/); 826 | }); 827 | 828 | it('should use boot_id if available (highest priority)', () => { 829 | const mockBootId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; 830 | process.env.IS_DOCKER = 'true'; 831 | 832 | vi.mocked(existsSync).mockImplementation((path: any) => { 833 | if (path === '/proc/sys/kernel/random/boot_id') return true; 834 | return true; // All other files available too 835 | }); 836 | 837 | vi.mocked(readFileSync).mockImplementation((path: any) => { 838 | if (path === '/proc/sys/kernel/random/boot_id') return mockBootId; 839 | if (path === '/proc/cpuinfo') return 'processor: 0\n'; 840 | if (path === '/proc/meminfo') return 'MemTotal: 1000000 kB\n'; 841 | return 'mock data'; 842 | }); 843 | 844 | (TelemetryConfigManager as any).instance = null; 845 | const manager1 = TelemetryConfigManager.getInstance(); 846 | const userId1 = manager1.getUserId(); 847 | 848 | // Now break boot_id but keep combined signals 849 | vi.mocked(existsSync).mockImplementation((path: any) => { 850 | if (path === '/proc/sys/kernel/random/boot_id') return false; 851 | return true; 852 | }); 853 | 854 | (TelemetryConfigManager as any).instance = null; 855 | const manager2 = TelemetryConfigManager.getInstance(); 856 | const userId2 = manager2.getUserId(); 857 | 858 | // Different methods should produce different IDs 859 | expect(userId1).not.toBe(userId2); 860 | expect(userId1).toMatch(/^[a-f0-9]{16}$/); 861 | expect(userId2).toMatch(/^[a-f0-9]{16}$/); 862 | }); 863 | }); 864 | }); 865 | }); ```