mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 22:42:04 +00:00
Compare commits
53 Commits
v2.15.0
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7a0e0487 | ||
|
|
1c56eb0daa | ||
|
|
c519cd5060 | ||
|
|
69f3a31d41 | ||
|
|
bd8a7f68ac | ||
|
|
abc6a31302 | ||
|
|
57459c27e3 | ||
|
|
9380602439 | ||
|
|
a696af8cfa | ||
|
|
b467bec93e | ||
|
|
6e042467b2 | ||
|
|
287b9aa819 | ||
|
|
3331b72df4 | ||
|
|
c0d7145a5a | ||
|
|
08e906739f | ||
|
|
ae329c3bb6 | ||
|
|
1cfbdc3bdf | ||
|
|
b3d42b3390 | ||
|
|
4feb905bd0 | ||
|
|
ad1f611d2a | ||
|
|
02574e5555 | ||
|
|
b27d245dab | ||
|
|
ecf0d50a63 | ||
|
|
1db9ecf33f | ||
|
|
fc973d83db | ||
|
|
2e19eaa309 | ||
|
|
73db3dfdfe | ||
|
|
7fcfa8f696 | ||
|
|
c8cdd3c0b5 | ||
|
|
62d01ab237 | ||
|
|
00289e90d7 | ||
|
|
5c01624c3a | ||
|
|
dad3a442d9 | ||
|
|
7a402bc7ad | ||
|
|
88e288f8f6 | ||
|
|
12a7f1e8bf | ||
|
|
2f18a2bb9a | ||
|
|
9b94e3be9c | ||
|
|
9e1a4129c0 | ||
|
|
4b764c6110 | ||
|
|
c3b691cedf | ||
|
|
4bf8f7006d | ||
|
|
2a9a3b9410 | ||
|
|
cd27d78bfd | ||
|
|
8d1ae278ee | ||
|
|
a84dbd6a15 | ||
|
|
1728495146 | ||
|
|
2305aaab9e | ||
|
|
f74427bdb5 | ||
|
|
fe59688e03 | ||
|
|
675989971c | ||
|
|
d875ac1e0c | ||
|
|
5bf1bc46e9 |
34
.env.example
34
.env.example
@@ -132,4 +132,36 @@ ENABLE_MULTI_TENANT=false
|
||||
|
||||
# Enable metadata generation during template fetch (default: false)
|
||||
# Set to true to automatically generate metadata when running fetch:templates
|
||||
# METADATA_GENERATION_ENABLED=false
|
||||
# METADATA_GENERATION_ENABLED=false
|
||||
|
||||
# ========================================
|
||||
# INTEGRATION TESTING CONFIGURATION
|
||||
# ========================================
|
||||
# Configuration for integration tests that call real n8n instance API
|
||||
|
||||
# n8n API Configuration for Integration Tests
|
||||
# For local development: Use your local n8n instance
|
||||
# For CI: These will be provided by GitHub secrets
|
||||
# N8N_API_URL=http://localhost:5678
|
||||
# N8N_API_KEY=
|
||||
|
||||
# Pre-activated Webhook Workflows for Testing
|
||||
# These workflows must be created manually in n8n and activated
|
||||
# because n8n API doesn't support workflow activation.
|
||||
#
|
||||
# Setup Instructions:
|
||||
# 1. Create 4 workflows in n8n UI (one for each HTTP method)
|
||||
# 2. Each workflow should have a single Webhook node
|
||||
# 3. Configure webhook paths: mcp-test-get, mcp-test-post, mcp-test-put, mcp-test-delete
|
||||
# 4. ACTIVATE each workflow in n8n UI
|
||||
# 5. Copy the workflow IDs here
|
||||
#
|
||||
# N8N_TEST_WEBHOOK_GET_ID= # Workflow ID for GET method webhook
|
||||
# N8N_TEST_WEBHOOK_POST_ID= # Workflow ID for POST method webhook
|
||||
# N8N_TEST_WEBHOOK_PUT_ID= # Workflow ID for PUT method webhook
|
||||
# N8N_TEST_WEBHOOK_DELETE_ID= # Workflow ID for DELETE method webhook
|
||||
|
||||
# Test Configuration
|
||||
N8N_TEST_CLEANUP_ENABLED=true # Enable automatic cleanup of test workflows
|
||||
N8N_TEST_TAG=mcp-integration-test # Tag applied to all test workflows
|
||||
N8N_TEST_NAME_PREFIX=[MCP-TEST] # Name prefix for test workflows
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -72,6 +72,12 @@ jobs:
|
||||
run: npm run test:integration -- --reporter=default --reporter=junit
|
||||
env:
|
||||
CI: true
|
||||
N8N_API_URL: ${{ secrets.N8N_API_URL }}
|
||||
N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
|
||||
N8N_TEST_WEBHOOK_GET_URL: ${{ secrets.N8N_TEST_WEBHOOK_GET_URL }}
|
||||
N8N_TEST_WEBHOOK_POST_URL: ${{ secrets.N8N_TEST_WEBHOOK_POST_URL }}
|
||||
N8N_TEST_WEBHOOK_PUT_URL: ${{ secrets.N8N_TEST_WEBHOOK_PUT_URL }}
|
||||
N8N_TEST_WEBHOOK_DELETE_URL: ${{ secrets.N8N_TEST_WEBHOOK_DELETE_URL }}
|
||||
|
||||
# Generate test summary
|
||||
- name: Generate test summary
|
||||
|
||||
194
CHANGELOG.md
194
CHANGELOG.md
@@ -5,6 +5,200 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.15.5] - 2025-10-04
|
||||
|
||||
### Added
|
||||
- **Phase 5 Integration Tests** - Comprehensive workflow management tests (16 scenarios)
|
||||
- `delete-workflow.test.ts`: 3 test scenarios
|
||||
- Successful deletion
|
||||
- Error handling for non-existent workflows
|
||||
- Cleanup verification (workflow actually deleted from n8n)
|
||||
- `list-workflows.test.ts`: 13 test scenarios
|
||||
- No filters (all workflows)
|
||||
- Filter by active status (true/false)
|
||||
- Pagination (first page, cursor, last page)
|
||||
- Limit variations (1, 50, 100)
|
||||
- Exclude pinned data
|
||||
- Empty results handling
|
||||
- Sort order consistency verification
|
||||
|
||||
### Fixed
|
||||
- **handleDeleteWorkflow** - Now returns deleted workflow data in response
|
||||
- Before: Returned only success message
|
||||
- After: Returns deleted workflow object per n8n API specification
|
||||
- Impact: MCP tool consumers can access deleted workflow data for confirmation, logging, or undo operations
|
||||
|
||||
- **handleListWorkflows Tags Filter** - Fixed tags parameter format for n8n API compliance
|
||||
- Before: Sent tags as array `?tags[]=tag1&tags[]=tag2` (non-functional)
|
||||
- After: Converts to comma-separated string `?tags=tag1,tag2` per n8n OpenAPI spec
|
||||
- Impact: Tags filtering now works correctly when listing workflows
|
||||
- Implementation: `input.tags.join(',')` conversion in handler
|
||||
|
||||
- **N8nApiClient.deleteWorkflow** - Return type now matches n8n API specification
|
||||
- Before: `Promise<void>`
|
||||
- After: `Promise<Workflow>` (returns deleted workflow object)
|
||||
- Impact: Aligns with n8n API behavior where DELETE returns the deleted resource
|
||||
|
||||
### Changed
|
||||
- **WorkflowListParams.tags** - Type changed for API compliance
|
||||
- Before: `tags?: string[] | null` (incorrect)
|
||||
- After: `tags?: string | null` (comma-separated string per n8n OpenAPI spec)
|
||||
- Impact: Type safety now matches actual API behavior
|
||||
|
||||
### Technical Details
|
||||
- **API Compliance**: All fixes align with n8n OpenAPI specification
|
||||
- **Backward Compatibility**: Handler maintains existing MCP tool interface (array input converted internally)
|
||||
- **Type Safety**: TypeScript types now accurately reflect n8n API contracts
|
||||
|
||||
### Test Coverage
|
||||
- Integration tests: 71/71 passing (Phase 1-5 complete)
|
||||
- Total test scenarios across all phases: 87
|
||||
- New coverage:
|
||||
- Workflow deletion: 3 scenarios
|
||||
- Workflow listing with filters: 13 scenarios
|
||||
|
||||
### Impact
|
||||
- **DELETE workflows**: Now returns workflow data for verification
|
||||
- **List with tags**: Tag filtering now functional (was broken before)
|
||||
- **API alignment**: Implementation correctly matches n8n OpenAPI specification
|
||||
- **Test reliability**: All integration tests passing in CI
|
||||
|
||||
## [2.15.4] - 2025-10-04
|
||||
|
||||
### Fixed
|
||||
- **Workflow Settings Updates** - Enhanced `cleanWorkflowForUpdate` to enable settings updates while maintaining Issue #248 protection
|
||||
- Changed from always overwriting settings with `{}` to filtering to whitelisted properties
|
||||
- Filters settings to OpenAPI spec whitelisted properties: `saveExecutionProgress`, `saveManualExecutions`, `saveDataErrorExecution`, `saveDataSuccessExecution`, `executionTimeout`, `errorWorkflow`, `timezone`, `executionOrder`
|
||||
- Removes unsafe properties like `callerPolicy` that cause "additional properties" API errors
|
||||
- Maintains backward compatibility: empty object `{}` still used when no settings provided
|
||||
- Resolves conflict between preventing Issue #248 errors and enabling legitimate settings updates
|
||||
|
||||
- **Phase 4 Integration Tests** - Fixed workflow update tests to comply with n8n API requirements
|
||||
- Updated all `handleUpdateWorkflow` tests to include required fields: `name`, `nodes`, `connections`, `settings`
|
||||
- Tests now fetch current workflow state before updates to obtain required fields
|
||||
- Removed invalid "Update Connections" test that attempted to set empty connections on multi-node workflow (architecturally invalid)
|
||||
- All 42 workflow update test scenarios now passing
|
||||
|
||||
### Changed
|
||||
- **Settings Filtering Strategy** - Updated `cleanWorkflowForUpdate()` implementation
|
||||
- Before: Always set `settings = {}` (prevented all settings updates)
|
||||
- After: Filter to whitelisted properties (allows valid updates, blocks problematic ones)
|
||||
- Impact: Users can now update workflow settings via API while staying protected from validation errors
|
||||
|
||||
### Technical Details
|
||||
- **Whitelist-based Filtering**: Implements principle of least privilege for settings properties
|
||||
- **Reference**: Properties validated against n8n OpenAPI specification `workflowSettings` schema
|
||||
- **Security**: More secure than blacklist approach (fails safe, unknown properties filtered)
|
||||
- **Performance**: Filtering adds <1ms overhead per workflow update
|
||||
|
||||
### Test Coverage
|
||||
- Unit tests: 72/72 passing (100% coverage for n8n-validation)
|
||||
- Integration tests: 433/433 passing (Phase 4 complete)
|
||||
- Test scenarios:
|
||||
- Settings filtering with safe/unsafe property combinations
|
||||
- Empty settings handling
|
||||
- Backward compatibility verification
|
||||
- Multi-node workflow connection validation
|
||||
|
||||
### Impact
|
||||
- **Settings Updates**: Users can now update workflow settings (timezone, executionOrder, etc.) via API
|
||||
- **Issue #248 Protection Maintained**: `callerPolicy` and other problematic properties still filtered
|
||||
- **Test Reliability**: All Phase 4 integration tests passing in CI
|
||||
- **API Compliance**: Tests correctly implement n8n API requirements for workflow updates
|
||||
|
||||
## [2.15.3] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- **Error Message Capture in Telemetry** - Enhanced telemetry tracking to capture actual error messages for better debugging
|
||||
- Added optional `errorMessage` parameter to `trackError()` method
|
||||
- Comprehensive error message sanitization to protect sensitive data
|
||||
- Updated all production and test call sites to pass error messages
|
||||
- Error messages now stored in telemetry events table for analysis
|
||||
|
||||
### Security
|
||||
- **Enhanced Error Message Sanitization** - Comprehensive security hardening for telemetry data
|
||||
- **ReDoS Prevention**: Early truncation to 1500 chars before regex processing
|
||||
- **Full URL Redaction**: Changed from `[URL]/path` to `[URL]` to prevent API structure leakage
|
||||
- **Correct Sanitization Order**: URLs → specific credentials → emails → generic patterns
|
||||
- **Credential Pattern Detection**: Added AWS keys, GitHub tokens, JWT, Bearer tokens
|
||||
- **Error Handling**: Try-catch wrapper with `[SANITIZATION_FAILED]` fallback
|
||||
- **Stack Trace Truncation**: Limited to first 3 lines to reduce attack surface
|
||||
|
||||
### Fixed
|
||||
- **Missing Error Messages**: Resolved issue where 272+ weekly validation errors had no error messages captured
|
||||
- **Data Leakage**: Fixed URL path preservation exposing API versions and user IDs
|
||||
- **Email Exposure**: Fixed sanitization order allowing emails in URLs to leak
|
||||
- **ReDoS Vulnerability**: Removed complex capturing regex patterns that could cause performance issues
|
||||
|
||||
### Changed
|
||||
- **Breaking Change**: `trackError()` signature updated with 4th parameter `errorMessage?: string`
|
||||
- All internal call sites updated in single commit (atomic change)
|
||||
- Not backwards compatible but acceptable as all code is internal
|
||||
|
||||
### Technical Details
|
||||
- **Sanitization Patterns**:
|
||||
- AWS Keys: `AKIA[A-Z0-9]{16}` → `[AWS_KEY]`
|
||||
- GitHub Tokens: `ghp_[a-zA-Z0-9]{36,}` → `[GITHUB_TOKEN]`
|
||||
- JWT: `eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+` → `[JWT]`
|
||||
- Bearer Tokens: `Bearer [^\s]+` → `Bearer [TOKEN]`
|
||||
- Emails: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}` → `[EMAIL]`
|
||||
- Long Keys: `\b[a-zA-Z0-9_-]{32,}\b` → `[KEY]`
|
||||
- Generic Credentials: `password/api_key/token=<value>` → `<field>=[REDACTED]`
|
||||
|
||||
### Test Coverage
|
||||
- Added 18 new security-focused tests
|
||||
- Total telemetry tests: 269 passing
|
||||
- Coverage: 90.75% for telemetry module
|
||||
- All security patterns validated with edge cases
|
||||
|
||||
### Performance
|
||||
- Early truncation prevents ReDoS attacks
|
||||
- Simplified regex patterns (no complex capturing groups)
|
||||
- Sanitization adds <1ms overhead per error
|
||||
- Final message truncated to 500 chars max
|
||||
|
||||
### Impact
|
||||
- **Debugging**: Error messages now available for root cause analysis
|
||||
- **Security**: Comprehensive protection against credential leakage
|
||||
- **Performance**: Protected against ReDoS attacks
|
||||
- **Reliability**: Try-catch ensures sanitization never breaks telemetry
|
||||
|
||||
## [2.15.2] - 2025-10-03
|
||||
|
||||
### Fixed
|
||||
- **Template Search Performance & Reliability** - Enhanced `search_templates_by_metadata` with production-ready improvements
|
||||
- **Ordering Stability**: Implemented CTE with VALUES clause to preserve exact Phase 1 ordering
|
||||
- Prevents ordering discrepancies between ID selection and data fetch phases
|
||||
- Ensures deterministic results across query phases
|
||||
- **Defensive ID Validation**: Added type safety filters before Phase 2 query
|
||||
- Validates only positive integers are used in the CTE
|
||||
- Logs warnings for filtered invalid IDs
|
||||
- **Performance Monitoring**: Added detailed timing metrics (phase1Ms, phase2Ms, totalMs)
|
||||
- Enables quantifying optimization benefits
|
||||
- Debug logging for all search operations
|
||||
- **DRY Refactoring**: Extracted `buildMetadataFilterConditions` helper method
|
||||
- Eliminates duplication between `searchTemplatesByMetadata` and `getMetadataSearchCount`
|
||||
- Centralized filter-building logic
|
||||
|
||||
### Added
|
||||
- **Comprehensive Test Coverage** - 31 new unit tests achieving 100% coverage for changed code
|
||||
- `buildMetadataFilterConditions` - All filter combinations (11 tests)
|
||||
- Performance logging validation (3 tests)
|
||||
- ID filtering edge cases - negative, zero, non-integer, null (7 tests)
|
||||
- `getMetadataSearchCount` - Shared helper usage (7 tests)
|
||||
- Two-phase query optimization verification (3 tests)
|
||||
- Fixed flaky integration tests with deterministic ordering using unique view counts
|
||||
|
||||
### Performance
|
||||
- Query optimization maintains sub-1ms Phase 1 performance
|
||||
- Two-phase approach prevents timeout on large template sets
|
||||
- CTE-based ordering adds negligible overhead (<1ms)
|
||||
|
||||
### Test Results
|
||||
- Unit tests: 31 new tests, all passing
|
||||
- Integration tests: 36 passing, 1 skipped
|
||||
- **Coverage**: 100% for changed code (previously 36.58% patch coverage)
|
||||
|
||||
## [2.15.0] - 2025-10-02
|
||||
|
||||
### 🚀 Major Features
|
||||
|
||||
393
README.md
393
README.md
@@ -4,7 +4,7 @@
|
||||
[](https://github.com/czlonkowski/n8n-mcp)
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
@@ -20,6 +20,8 @@ n8n-MCP serves as a bridge between n8n's workflow automation platform and AI mod
|
||||
- ⚡ **Node operations** - 63.6% coverage of available actions
|
||||
- 📄 **Documentation** - 90% coverage from official n8n docs (including AI nodes)
|
||||
- 🤖 **AI tools** - 263 AI-capable nodes detected with full documentation
|
||||
- 💡 **Real-world examples** - 2,646 pre-extracted configurations from popular templates
|
||||
- 🎯 **Template library** - 2,500+ workflow templates with smart filtering
|
||||
|
||||
|
||||
## ⚠️ Important Safety Warning
|
||||
@@ -397,180 +399,260 @@ Complete guide for integrating n8n-MCP with Codex.
|
||||
|
||||
For the best results when using n8n-MCP with Claude Projects, use these enhanced system instructions:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
You are an expert in n8n automation software using n8n-MCP tools. Your role is to design, build, and validate n8n workflows with maximum accuracy and efficiency.
|
||||
|
||||
## Core Workflow Process
|
||||
## Core Principles
|
||||
|
||||
1. **ALWAYS start new conversation with**: `tools_documentation()` to understand best practices and available tools.
|
||||
### 1. Silent Execution
|
||||
CRITICAL: Execute tools without commentary. Only respond AFTER all tools complete.
|
||||
|
||||
2. **Template Discovery Phase**
|
||||
- `search_templates_by_metadata({complexity: "simple"})` - Find skill-appropriate templates
|
||||
- `get_templates_for_task('webhook_processing')` - Get curated templates by task
|
||||
- `search_templates('slack notification')` - Text search for specific needs. Start by quickly searching with "id" and "name" to find the template you are looking for, only then dive deeper into the template details adding "description" to your search query.
|
||||
- `list_node_templates(['n8n-nodes-base.slack'])` - Find templates using specific nodes
|
||||
|
||||
**Template filtering strategies**:
|
||||
- **For beginners**: `complexity: "simple"` and `maxSetupMinutes: 30`
|
||||
- **By role**: `targetAudience: "marketers"` or `"developers"` or `"analysts"`
|
||||
- **By time**: `maxSetupMinutes: 15` for quick wins
|
||||
- **By service**: `requiredService: "openai"` to find compatible templates
|
||||
❌ BAD: "Let me search for Slack nodes... Great! Now let me get details..."
|
||||
✅ GOOD: [Execute search_nodes and get_node_essentials in parallel, then respond]
|
||||
|
||||
3. **Discovery Phase** - Find the right nodes (if no suitable template):
|
||||
- Think deeply about user request and the logic you are going to build to fulfill it. Ask follow-up questions to clarify the user's intent, if something is unclear. Then, proceed with the rest of your instructions.
|
||||
- `search_nodes({query: 'keyword'})` - Search by functionality
|
||||
### 2. Parallel Execution
|
||||
When operations are independent, execute them in parallel for maximum performance.
|
||||
|
||||
✅ GOOD: Call search_nodes, list_nodes, and search_templates simultaneously
|
||||
❌ BAD: Sequential tool calls (await each one before the next)
|
||||
|
||||
### 3. Templates First
|
||||
ALWAYS check templates before building from scratch (2,500+ available).
|
||||
|
||||
### 4. Multi-Level Validation
|
||||
Use validate_node_minimal → validate_node_operation → validate_workflow pattern.
|
||||
|
||||
### 5. Never Trust Defaults
|
||||
⚠️ CRITICAL: Default parameter values are the #1 source of runtime failures.
|
||||
ALWAYS explicitly configure ALL parameters that control node behavior.
|
||||
|
||||
## Workflow Process
|
||||
|
||||
1. **Start**: Call `tools_documentation()` for best practices
|
||||
|
||||
2. **Template Discovery Phase** (FIRST - parallel when searching multiple)
|
||||
- `search_templates_by_metadata({complexity: "simple"})` - Smart filtering
|
||||
- `get_templates_for_task('webhook_processing')` - Curated by task
|
||||
- `search_templates('slack notification')` - Text search
|
||||
- `list_node_templates(['n8n-nodes-base.slack'])` - By node type
|
||||
|
||||
**Filtering strategies**:
|
||||
- Beginners: `complexity: "simple"` + `maxSetupMinutes: 30`
|
||||
- By role: `targetAudience: "marketers"` | `"developers"` | `"analysts"`
|
||||
- By time: `maxSetupMinutes: 15` for quick wins
|
||||
- By service: `requiredService: "openai"` for compatibility
|
||||
|
||||
3. **Node Discovery** (if no suitable template - parallel execution)
|
||||
- Think deeply about requirements. Ask clarifying questions if unclear.
|
||||
- `search_nodes({query: 'keyword', includeExamples: true})` - Parallel for multiple nodes
|
||||
- `list_nodes({category: 'trigger'})` - Browse by category
|
||||
- `list_ai_tools()` - See AI-capable nodes (remember: ANY node can be an AI tool!)
|
||||
- `list_ai_tools()` - AI-capable nodes
|
||||
|
||||
4. **Configuration Phase** - Get node details efficiently:
|
||||
- `get_node_essentials(nodeType)` - Start here! Only 10-20 essential properties
|
||||
4. **Configuration Phase** (parallel for multiple nodes)
|
||||
- `get_node_essentials(nodeType, {includeExamples: true})` - 10-20 key properties
|
||||
- `search_node_properties(nodeType, 'auth')` - Find specific properties
|
||||
- `get_node_for_task('send_email')` - Get pre-configured templates
|
||||
- `get_node_documentation(nodeType)` - Human-readable docs when needed
|
||||
- It is good common practice to show a visual representation of the workflow architecture to the user and asking for opinion, before moving forward.
|
||||
- `get_node_documentation(nodeType)` - Human-readable docs
|
||||
- Show workflow architecture to user for approval before proceeding
|
||||
|
||||
5. **Pre-Validation Phase** - Validate BEFORE building:
|
||||
5. **Validation Phase** (parallel for multiple nodes)
|
||||
- `validate_node_minimal(nodeType, config)` - Quick required fields check
|
||||
- `validate_node_operation(nodeType, config, profile)` - Full operation-aware validation
|
||||
- Fix any validation errors before proceeding
|
||||
- `validate_node_operation(nodeType, config, 'runtime')` - Full validation with fixes
|
||||
- Fix ALL errors before proceeding
|
||||
|
||||
6. **Building Phase** - Create or customize the workflow:
|
||||
6. **Building Phase**
|
||||
- If using template: `get_template(templateId, {mode: "full"})`
|
||||
- **MANDATORY ATTRIBUTION**: When using a template, ALWAYS inform the user:
|
||||
- "This workflow is based on a template by **[author.name]** (@[author.username])"
|
||||
- "View the original template at: [url]"
|
||||
- Example: "This workflow is based on a template by **David Ashby** (@cfomodz). View the original at: https://n8n.io/workflows/2414"
|
||||
- Customize template or build from validated configurations
|
||||
- **MANDATORY ATTRIBUTION**: "Based on template by **[author.name]** (@[username]). View at: [url]"
|
||||
- Build from validated configurations
|
||||
- ⚠️ EXPLICITLY set ALL parameters - never rely on defaults
|
||||
- Connect nodes with proper structure
|
||||
- Add error handling where appropriate
|
||||
- Use expressions like $json, $node["NodeName"].json
|
||||
- Build the workflow in an artifact for easy editing downstream (unless the user asked to create in n8n instance)
|
||||
- Add error handling
|
||||
- Use n8n expressions: $json, $node["NodeName"].json
|
||||
- Build in artifact (unless deploying to n8n instance)
|
||||
|
||||
7. **Workflow Validation Phase** - Validate complete workflow:
|
||||
- `validate_workflow(workflow)` - Complete validation including connections
|
||||
- `validate_workflow_connections(workflow)` - Check structure and AI tool connections
|
||||
- `validate_workflow_expressions(workflow)` - Validate all n8n expressions
|
||||
- Fix any issues found before deployment
|
||||
7. **Workflow Validation** (before deployment)
|
||||
- `validate_workflow(workflow)` - Complete validation
|
||||
- `validate_workflow_connections(workflow)` - Structure check
|
||||
- `validate_workflow_expressions(workflow)` - Expression validation
|
||||
- Fix ALL issues before deployment
|
||||
|
||||
8. **Deployment Phase** (if n8n API configured):
|
||||
- `n8n_create_workflow(workflow)` - Deploy validated workflow
|
||||
- `n8n_validate_workflow({id: 'workflow-id'})` - Post-deployment validation
|
||||
- `n8n_update_partial_workflow()` - Make incremental updates using diffs
|
||||
- `n8n_trigger_webhook_workflow()` - Test webhook workflows
|
||||
8. **Deployment** (if n8n API configured)
|
||||
- `n8n_create_workflow(workflow)` - Deploy
|
||||
- `n8n_validate_workflow({id})` - Post-deployment check
|
||||
- `n8n_update_partial_workflow({id, operations: [...]})` - Batch updates
|
||||
- `n8n_trigger_webhook_workflow()` - Test webhooks
|
||||
|
||||
## Key Insights
|
||||
## Critical Warnings
|
||||
|
||||
- **TEMPLATES FIRST** - Always check for existing templates before building from scratch (2,500+ available!)
|
||||
- **ATTRIBUTION REQUIRED** - Always credit template authors with name, username, and link to n8n.io
|
||||
- **SMART FILTERING** - Use metadata filters to find templates matching user skill level and time constraints
|
||||
- **USE CODE NODE ONLY WHEN IT IS NECESSARY** - always prefer to use standard nodes over code node. Use code node only when you are sure you need it.
|
||||
- **VALIDATE EARLY AND OFTEN** - Catch errors before they reach deployment
|
||||
- **USE DIFF UPDATES** - Use n8n_update_partial_workflow for 80-90% token savings
|
||||
- **ANY node can be an AI tool** - not just those with usableAsTool=true
|
||||
- **Pre-validate configurations** - Use validate_node_minimal before building
|
||||
- **Post-validate workflows** - Always validate complete workflows before deployment
|
||||
- **Incremental updates** - Use diff operations for existing workflows
|
||||
- **Test thoroughly** - Validate both locally and after deployment to n8n
|
||||
### ⚠️ Never Trust Defaults
|
||||
Default values cause runtime failures. Example:
|
||||
```json
|
||||
// ❌ FAILS at runtime
|
||||
{resource: "message", operation: "post", text: "Hello"}
|
||||
|
||||
// ✅ WORKS - all parameters explicit
|
||||
{resource: "message", operation: "post", select: "channel", channelId: "C123", text: "Hello"}
|
||||
```
|
||||
|
||||
### ⚠️ Example Availability
|
||||
`includeExamples: true` returns real configurations from workflow templates.
|
||||
- Coverage varies by node popularity
|
||||
- When no examples available, use `get_node_essentials` + `validate_node_minimal`
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
### Before Building:
|
||||
1. validate_node_minimal() - Check required fields
|
||||
2. validate_node_operation() - Full configuration validation
|
||||
3. Fix all errors before proceeding
|
||||
### Level 1 - Quick Check (before building)
|
||||
`validate_node_minimal(nodeType, config)` - Required fields only (<100ms)
|
||||
|
||||
### After Building:
|
||||
1. validate_workflow() - Complete workflow validation
|
||||
2. validate_workflow_connections() - Structure validation
|
||||
3. validate_workflow_expressions() - Expression syntax check
|
||||
### Level 2 - Comprehensive (before building)
|
||||
`validate_node_operation(nodeType, config, 'runtime')` - Full validation with fixes
|
||||
|
||||
### After Deployment:
|
||||
1. n8n_validate_workflow({id}) - Validate deployed workflow
|
||||
2. n8n_autofix_workflow({id}) - Auto-fix common errors (expressions, typeVersion, webhooks)
|
||||
3. n8n_list_executions() - Monitor execution status
|
||||
4. n8n_update_partial_workflow() - Fix issues using diffs
|
||||
### Level 3 - Complete (after building)
|
||||
`validate_workflow(workflow)` - Connections, expressions, AI tools
|
||||
|
||||
## Response Structure
|
||||
### Level 4 - Post-Deployment
|
||||
1. `n8n_validate_workflow({id})` - Validate deployed workflow
|
||||
2. `n8n_autofix_workflow({id})` - Auto-fix common errors
|
||||
3. `n8n_list_executions()` - Monitor execution status
|
||||
|
||||
1. **Discovery**: Show available nodes and options
|
||||
2. **Pre-Validation**: Validate node configurations first
|
||||
3. **Configuration**: Show only validated, working configs
|
||||
4. **Building**: Construct workflow with validated components
|
||||
5. **Workflow Validation**: Full workflow validation results
|
||||
6. **Deployment**: Deploy only after all validations pass
|
||||
7. **Post-Validation**: Verify deployment succeeded
|
||||
## Response Format
|
||||
|
||||
### Initial Creation
|
||||
```
|
||||
[Silent tool execution in parallel]
|
||||
|
||||
Created workflow:
|
||||
- Webhook trigger → Slack notification
|
||||
- Configured: POST /webhook → #general channel
|
||||
|
||||
Validation: ✅ All checks passed
|
||||
```
|
||||
|
||||
### Modifications
|
||||
```
|
||||
[Silent tool execution]
|
||||
|
||||
Updated workflow:
|
||||
- Added error handling to HTTP node
|
||||
- Fixed required Slack parameters
|
||||
|
||||
Changes validated successfully.
|
||||
```
|
||||
|
||||
## Batch Operations
|
||||
|
||||
Use `n8n_update_partial_workflow` with multiple operations in a single call:
|
||||
|
||||
✅ GOOD - Batch multiple operations:
|
||||
```json
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {...}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {...}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
❌ BAD - Separate calls:
|
||||
```json
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### Smart Template-First Approach
|
||||
### Template-First Approach
|
||||
|
||||
#### 1. Find existing templates
|
||||
// Find simple Slack templates for marketers
|
||||
const templates = search_templates_by_metadata({
|
||||
```
|
||||
// STEP 1: Template Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_templates_by_metadata({
|
||||
requiredService: 'slack',
|
||||
complexity: 'simple',
|
||||
targetAudience: 'marketers',
|
||||
maxSetupMinutes: 30
|
||||
targetAudience: 'marketers'
|
||||
})
|
||||
|
||||
// Or search by text
|
||||
search_templates('slack notification')
|
||||
|
||||
// Or get curated templates
|
||||
get_templates_for_task('slack_integration')
|
||||
|
||||
#### 2. Use and customize template
|
||||
const workflow = get_template(templates.items[0].id, {mode: 'full'})
|
||||
// STEP 2: Use template
|
||||
get_template(templateId, {mode: 'full'})
|
||||
validate_workflow(workflow)
|
||||
|
||||
### Building from Scratch (if no suitable template)
|
||||
// Response after all tools complete:
|
||||
"Found template by **David Ashby** (@cfomodz).
|
||||
View at: https://n8n.io/workflows/2414
|
||||
|
||||
#### 1. Discovery & Configuration
|
||||
search_nodes({query: 'slack'})
|
||||
get_node_essentials('n8n-nodes-base.slack')
|
||||
Validation: ✅ All checks passed"
|
||||
```
|
||||
|
||||
#### 2. Pre-Validation
|
||||
validate_node_minimal('n8n-nodes-base.slack', {resource:'message', operation:'send'})
|
||||
### Building from Scratch (if no template)
|
||||
|
||||
```
|
||||
// STEP 1: Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_nodes({query: 'slack', includeExamples: true})
|
||||
list_nodes({category: 'communication'})
|
||||
|
||||
// STEP 2: Configuration (parallel execution)
|
||||
[Silent execution]
|
||||
get_node_essentials('n8n-nodes-base.slack', {includeExamples: true})
|
||||
get_node_essentials('n8n-nodes-base.webhook', {includeExamples: true})
|
||||
|
||||
// STEP 3: Validation (parallel execution)
|
||||
[Silent execution]
|
||||
validate_node_minimal('n8n-nodes-base.slack', config)
|
||||
validate_node_operation('n8n-nodes-base.slack', fullConfig, 'runtime')
|
||||
|
||||
#### 3. Build Workflow
|
||||
// Create workflow JSON with validated configs
|
||||
// STEP 4: Build
|
||||
// Construct workflow with validated configs
|
||||
// ⚠️ Set ALL parameters explicitly
|
||||
|
||||
#### 4. Workflow Validation
|
||||
// STEP 5: Validate
|
||||
[Silent execution]
|
||||
validate_workflow(workflowJson)
|
||||
validate_workflow_connections(workflowJson)
|
||||
validate_workflow_expressions(workflowJson)
|
||||
|
||||
#### 5. Deploy (if configured)
|
||||
n8n_create_workflow(validatedWorkflow)
|
||||
n8n_validate_workflow({id: createdWorkflowId})
|
||||
// Response after all tools complete:
|
||||
"Created workflow: Webhook → Slack
|
||||
Validation: ✅ Passed"
|
||||
```
|
||||
|
||||
#### 6. Update Using Diffs
|
||||
### Batch Updates
|
||||
|
||||
```json
|
||||
// ONE call with multiple operations
|
||||
n8n_update_partial_workflow({
|
||||
workflowId: id,
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}}
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {position: [100, 200]}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {position: [300, 200]}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- ALWAYS check for existing templates before building from scratch
|
||||
- LEVERAGE metadata filters to find skill-appropriate templates
|
||||
- **ALWAYS ATTRIBUTE TEMPLATES**: When using any template, you MUST share the author's name, username, and link to the original template on n8n.io
|
||||
- VALIDATE templates before deployment (they may need updates)
|
||||
- USE diff operations for updates (80-90% token savings)
|
||||
- STATE validation results clearly
|
||||
- FIX all errors before proceeding
|
||||
### Core Behavior
|
||||
1. **Silent execution** - No commentary between tools
|
||||
2. **Parallel by default** - Execute independent operations simultaneously
|
||||
3. **Templates first** - Always check before building (2,500+ available)
|
||||
4. **Multi-level validation** - Quick check → Full validation → Workflow validation
|
||||
5. **Never trust defaults** - Explicitly configure ALL parameters
|
||||
|
||||
## Template Discovery Tips
|
||||
### Attribution & Credits
|
||||
- **MANDATORY TEMPLATE ATTRIBUTION**: Share author name, username, and n8n.io link
|
||||
- **Template validation** - Always validate before deployment (may need updates)
|
||||
|
||||
- **97.5% of templates have metadata** - Use smart filtering!
|
||||
- **Filter combinations work best** - Combine complexity + setup time + service
|
||||
- **Templates save 70-90% development time** - Always check first
|
||||
- **Metadata is AI-generated** - Occasionally imprecise but highly useful
|
||||
- **Use `includeMetadata: false` for fast browsing** - Add metadata only when needed
|
||||
```
|
||||
### Performance
|
||||
- **Batch operations** - Use diff operations with multiple changes in one call
|
||||
- **Parallel execution** - Search, validate, and configure simultaneously
|
||||
- **Template metadata** - Use smart filtering for faster discovery
|
||||
|
||||
### Code Node Usage
|
||||
- **Avoid when possible** - Prefer standard nodes
|
||||
- **Only when necessary** - Use code node as last resort
|
||||
- **AI tool capability** - ANY node can be an AI tool (not just marked ones)
|
||||
````
|
||||
|
||||
Save these instructions in your Claude Project for optimal n8n workflow assistance with intelligent template discovery.
|
||||
|
||||
@@ -588,11 +670,11 @@ This tool was created to benefit everyone in the n8n community without friction.
|
||||
## Features
|
||||
|
||||
- **🔍 Smart Node Search**: Find nodes by name, category, or functionality
|
||||
- **📖 Essential Properties**: Get only the 10-20 properties that matter (NEW in v2.4.0)
|
||||
- **🎯 Task Templates**: Pre-configured settings for common automation tasks
|
||||
- **📖 Essential Properties**: Get only the 10-20 properties that matter
|
||||
- **💡 Real-World Examples**: 2,646 pre-extracted configurations from popular templates
|
||||
- **✅ Config Validation**: Validate node configurations before deployment
|
||||
- **🔗 Dependency Analysis**: Understand property relationships and conditions
|
||||
- **💡 Working Examples**: Real-world examples for immediate use
|
||||
- **🎯 Template Discovery**: 2,500+ workflow templates with smart filtering
|
||||
- **⚡ Fast Response**: Average query time ~12ms with optimized SQLite
|
||||
- **🌐 Universal Compatibility**: Works with any Node.js version
|
||||
|
||||
@@ -618,8 +700,8 @@ Once connected, Claude can use these powerful tools:
|
||||
- **`tools_documentation`** - Get documentation for any MCP tool (START HERE!)
|
||||
- **`list_nodes`** - List all n8n nodes with filtering options
|
||||
- **`get_node_info`** - Get comprehensive information about a specific node
|
||||
- **`get_node_essentials`** - Get only essential properties with examples (10-20 properties instead of 200+)
|
||||
- **`search_nodes`** - Full-text search across all node documentation
|
||||
- **`get_node_essentials`** - Get only essential properties (10-20 instead of 200+). Use `includeExamples: true` to get top 3 real-world configurations from popular templates
|
||||
- **`search_nodes`** - Full-text search across all node documentation. Use `includeExamples: true` to get top 2 real-world configurations per node from templates
|
||||
- **`search_node_properties`** - Find specific properties within nodes
|
||||
- **`list_ai_tools`** - List all AI-capable nodes (ANY node can be used as AI tool!)
|
||||
- **`get_node_as_tool_info`** - Get guidance on using any node as an AI tool
|
||||
@@ -633,8 +715,6 @@ Once connected, Claude can use these powerful tools:
|
||||
- **`get_templates_for_task`** - Curated templates for common automation tasks
|
||||
|
||||
### Advanced Tools
|
||||
- **`get_node_for_task`** - Pre-configured node settings for common tasks
|
||||
- **`list_tasks`** - Discover available task templates
|
||||
- **`validate_node_operation`** - Validate node configurations (operation-aware, profiles support)
|
||||
- **`validate_node_minimal`** - Quick validation for just required fields
|
||||
- **`validate_workflow`** - Complete workflow validation including AI tool connections
|
||||
@@ -674,14 +754,17 @@ These powerful tools allow you to manage n8n workflows directly from Claude. The
|
||||
### Example Usage
|
||||
|
||||
```typescript
|
||||
// Get essentials for quick configuration
|
||||
get_node_essentials("nodes-base.httpRequest")
|
||||
// Get essentials with real-world examples from templates
|
||||
get_node_essentials({
|
||||
nodeType: "nodes-base.httpRequest",
|
||||
includeExamples: true // Returns top 3 configs from popular templates
|
||||
})
|
||||
|
||||
// Find nodes for a specific task
|
||||
search_nodes({ query: "send email gmail" })
|
||||
|
||||
// Get pre-configured settings
|
||||
get_node_for_task("send_email")
|
||||
// Search nodes with configuration examples
|
||||
search_nodes({
|
||||
query: "send email gmail",
|
||||
includeExamples: true // Returns top 2 configs per node
|
||||
})
|
||||
|
||||
// Validate before deployment
|
||||
validate_node_operation({
|
||||
@@ -772,12 +855,14 @@ npm run dev:http # HTTP dev mode
|
||||
|
||||
## 📊 Metrics & Coverage
|
||||
|
||||
Current database coverage (n8n v1.106.3):
|
||||
Current database coverage (n8n v1.113.3):
|
||||
|
||||
- ✅ **535/535** nodes loaded (100%)
|
||||
- ✅ **536/536** nodes loaded (100%)
|
||||
- ✅ **528** nodes with properties (98.7%)
|
||||
- ✅ **470** nodes with documentation (88%)
|
||||
- ✅ **267** AI-capable tools detected
|
||||
- ✅ **2,646** pre-extracted template configurations
|
||||
- ✅ **2,500+** workflow templates available
|
||||
- ✅ **AI Agent & LangChain nodes** fully documented
|
||||
- ⚡ **Average response time**: ~12ms
|
||||
- 💾 **Database size**: ~15MB (optimized)
|
||||
@@ -853,22 +938,24 @@ npm run test:bench # Performance benchmarks
|
||||
|
||||
### Testing Architecture
|
||||
|
||||
- **Unit Tests**: Isolated component testing with mocks
|
||||
- Services layer: ~450 tests
|
||||
- Parsers: ~200 tests
|
||||
- Database repositories: ~100 tests
|
||||
- MCP tools: ~180 tests
|
||||
**Total: 3,336 tests** across unit and integration test suites
|
||||
|
||||
- **Integration Tests**: Full system behavior validation
|
||||
- MCP Protocol compliance: 72 tests
|
||||
- Database operations: 89 tests
|
||||
- Error handling: 44 tests
|
||||
- Performance: 44 tests
|
||||
- **Unit Tests** (2,766 tests): Isolated component testing with mocks
|
||||
- Services layer: Enhanced validation, property filtering, workflow validation
|
||||
- Parsers: Node parsing, property extraction, documentation mapping
|
||||
- Database: Repositories, adapters, migrations, FTS5 search
|
||||
- MCP tools: Tool definitions, documentation system
|
||||
- HTTP server: Multi-tenant support, security, configuration
|
||||
|
||||
- **Benchmarks**: Performance testing for critical paths
|
||||
- Database queries
|
||||
- Node loading
|
||||
- Search operations
|
||||
- **Integration Tests** (570 tests): Full system behavior validation
|
||||
- **n8n API Integration** (172 tests): All 18 MCP handler tools tested against real n8n instance
|
||||
- Workflow management: Create, read, update, delete, list, validate, autofix
|
||||
- Execution management: Trigger, retrieve, list, delete
|
||||
- System tools: Health check, tool listing, diagnostics
|
||||
- **MCP Protocol** (119 tests): Protocol compliance, session management, error handling
|
||||
- **Database** (226 tests): Repository operations, transactions, performance, FTS5 search
|
||||
- **Templates** (35 tests): Template fetching, storage, metadata operations
|
||||
- **Docker** (18 tests): Configuration, entrypoint, security validation
|
||||
|
||||
For detailed testing documentation, see [Testing Architecture](./docs/testing-architecture.md).
|
||||
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
0
data/templates.db
Normal file
0
data/templates.db
Normal file
1213
docs/local/DEEP_DIVE_ANALYSIS_2025-10-02.md
Normal file
1213
docs/local/DEEP_DIVE_ANALYSIS_2025-10-02.md
Normal file
File diff suppressed because it is too large
Load Diff
225
docs/local/DEEP_DIVE_ANALYSIS_README.md
Normal file
225
docs/local/DEEP_DIVE_ANALYSIS_README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# N8N-MCP Deep Dive Analysis - October 2, 2025
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains a comprehensive deep-dive analysis of n8n-mcp usage data from September 26 - October 2, 2025.
|
||||
|
||||
**Data Volume Analyzed:**
|
||||
- 212,375 telemetry events
|
||||
- 5,751 workflow creations
|
||||
- 2,119 unique users
|
||||
- 6 days of usage data
|
||||
|
||||
## Report Structure
|
||||
|
||||
|
||||
###: `DEEP_DIVE_ANALYSIS_2025-10-02.md` (Main Report)
|
||||
|
||||
**Sections Covered:**
|
||||
1. **Executive Summary** - Key findings and recommendations
|
||||
2. **Tool Performance Analysis** - Success rates, performance metrics, critical findings
|
||||
3. **Validation Catastrophe** - The node type prefix disaster analysis
|
||||
4. **Usage Patterns & User Segmentation** - User distribution, daily trends
|
||||
5. **Tool Sequence Analysis** - How AI agents use tools together
|
||||
6. **Workflow Creation Patterns** - Complexity distribution, popular nodes
|
||||
7. **Platform & Version Distribution** - OS, architecture, version adoption
|
||||
8. **Error Patterns & Root Causes** - TypeErrors, validation errors, discovery failures
|
||||
9. **P0-P1 Refactoring Recommendations** - Detailed implementation guides
|
||||
|
||||
**Sections Covered:**
|
||||
- Remaining P1 and P2 recommendations
|
||||
- Architectural refactoring suggestions
|
||||
- Telemetry enhancements
|
||||
- CHANGELOG integration
|
||||
- Final recommendations summary
|
||||
|
||||
## Key Findings Summary
|
||||
|
||||
### Critical Issues (P0 - Fix Immediately)
|
||||
|
||||
1. **Node Type Prefix Validation Catastrophe**
|
||||
- 5,000+ validation errors from single root cause
|
||||
- `nodes-base.X` vs `n8n-nodes-base.X` confusion
|
||||
- **Solution**: Auto-normalize prefixes (2-4 hours effort)
|
||||
|
||||
2. **TypeError in Node Information Tools**
|
||||
- 10-18% failure rate in get_node_essentials/info
|
||||
- 1,000+ failures affecting hundreds of users
|
||||
- **Solution**: Complete null-safety audit (1 day effort)
|
||||
|
||||
3. **Task Discovery Failures**
|
||||
- `get_node_for_task` failing 28% of the time
|
||||
- Worst-performing tool in entire system
|
||||
- **Solution**: Expand task library + fuzzy matching (3 days effort)
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
**Excellent Reliability (96-100% success):**
|
||||
- n8n_update_partial_workflow: 98.7%
|
||||
- search_nodes: 99.8%
|
||||
- n8n_create_workflow: 96.1%
|
||||
- All workflow management tools: 100%
|
||||
|
||||
**User Distribution:**
|
||||
- Power Users (12): 2,112 events/user, 33 workflows
|
||||
- Heavy Users (47): 673 events/user, 18 workflows
|
||||
- Regular Users (516): 199 events/user, 7 workflows (CORE AUDIENCE)
|
||||
- Active Users (919): 52 events/user, 2 workflows
|
||||
- Casual Users (625): 8 events/user, 1 workflow
|
||||
|
||||
### Usage Insights
|
||||
|
||||
**Most Used Tools:**
|
||||
1. n8n_update_partial_workflow: 10,177 calls (iterative refinement)
|
||||
2. search_nodes: 8,839 calls (node discovery)
|
||||
3. n8n_create_workflow: 6,046 calls (workflow creation)
|
||||
|
||||
**Most Common Tool Sequences:**
|
||||
1. update → update → update (549x) - Iterative refinement pattern
|
||||
2. create → update (297x) - Create then refine
|
||||
3. update → get_workflow (265x) - Update then verify
|
||||
|
||||
**Most Popular Nodes:**
|
||||
1. code (53% of workflows) - AI agents love programmatic control
|
||||
2. httpRequest (47%) - Integration-heavy usage
|
||||
3. webhook (32%) - Event-driven automation
|
||||
|
||||
## SQL Analytical Views Created
|
||||
|
||||
15 comprehensive views were created in Supabase for ongoing analysis:
|
||||
|
||||
1. `vw_tool_performance` - Performance metrics per tool
|
||||
2. `vw_error_analysis` - Error patterns and frequencies
|
||||
3. `vw_validation_analysis` - Validation failure details
|
||||
4. `vw_tool_sequences` - Tool-to-tool transition patterns
|
||||
5. `vw_workflow_creation_patterns` - Workflow characteristics
|
||||
6. `vw_node_usage_analysis` - Node popularity and complexity
|
||||
7. `vw_node_cooccurrence` - Which nodes are used together
|
||||
8. `vw_user_activity` - Per-user activity metrics
|
||||
9. `vw_session_analysis` - Platform/version distribution
|
||||
10. `vw_workflow_validation_failures` - Workflow validation issues
|
||||
11. `vw_temporal_patterns` - Time-based usage patterns
|
||||
12. `vw_tool_funnel` - User progression through tools
|
||||
13. `vw_search_analysis` - Search behavior
|
||||
14. `vw_tool_success_summary` - Success/failure rates
|
||||
15. `vw_user_journeys` - Complete user session reconstruction
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
|
||||
✅ **P0-R1**: Auto-normalize node type prefixes → Eliminate 4,800 errors
|
||||
✅ **P0-R2**: Complete null-safety audit → Fix 10-18% TypeError failures
|
||||
✅ **P0-R3**: Expand get_node_for_task library → 72% → 95% success rate
|
||||
|
||||
**Expected Impact**: Reduce error rate from 5-10% to <2% overall
|
||||
|
||||
### Next Release (2-3 Weeks)
|
||||
|
||||
✅ **P1-R4**: Batch workflow operations → Save 30-50% tokens
|
||||
✅ **P1-R5**: Proactive node suggestions → Reduce search iterations
|
||||
✅ **P1-R6**: Auto-fix suggestions in errors → Self-service recovery
|
||||
|
||||
**Expected Impact**: 40% faster workflow creation, better UX
|
||||
|
||||
### Future Roadmap (1-3 Months)
|
||||
|
||||
✅ **A1**: Service layer consolidation → Cleaner architecture
|
||||
✅ **A2**: Repository caching → 50% faster node operations
|
||||
✅ **R10**: Workflow template library from usage → 80% coverage
|
||||
✅ **T1-T3**: Enhanced telemetry → Better observability
|
||||
|
||||
**Expected Impact**: Scalable foundation for 10x growth
|
||||
|
||||
## Methodology
|
||||
|
||||
### Data Sources
|
||||
|
||||
1. **Supabase Telemetry Database**
|
||||
- `telemetry_events` table: 212,375 rows
|
||||
- `telemetry_workflows` table: 5,751 rows
|
||||
|
||||
2. **Analytical Views**
|
||||
- Created 15 SQL views for multi-dimensional analysis
|
||||
- Enabled complex queries and pattern recognition
|
||||
|
||||
3. **CHANGELOG Review**
|
||||
- Analyzed recent changes (v2.14.0 - v2.14.6)
|
||||
- Correlated fixes with error patterns
|
||||
|
||||
### Analysis Approach
|
||||
|
||||
1. **Quantitative Analysis**
|
||||
- Success/failure rates per tool
|
||||
- Performance metrics (avg, median, p95, p99)
|
||||
- User segmentation and cohort analysis
|
||||
- Temporal trends and growth patterns
|
||||
|
||||
2. **Pattern Recognition**
|
||||
- Tool sequence analysis (Markov chains)
|
||||
- Node co-occurrence patterns
|
||||
- Workflow complexity distribution
|
||||
- Error clustering and root cause analysis
|
||||
|
||||
3. **Qualitative Insights**
|
||||
- CHANGELOG integration
|
||||
- Error message analysis
|
||||
- User journey reconstruction
|
||||
- Best practice identification
|
||||
|
||||
## How to Use This Analysis
|
||||
|
||||
### For Development Priorities
|
||||
|
||||
1. Review **P0 Critical Recommendations** (Section 8)
|
||||
2. Check estimated effort and impact
|
||||
3. Prioritize based on ROI (impact/effort ratio)
|
||||
4. Follow implementation guides with code examples
|
||||
|
||||
### For Architecture Decisions
|
||||
|
||||
1. Review **Architectural Recommendations** (Section 9)
|
||||
2. Consider service layer consolidation
|
||||
3. Evaluate repository caching opportunities
|
||||
4. Plan for 10x scale
|
||||
|
||||
### For Product Strategy
|
||||
|
||||
1. Review **Usage Patterns** (Section 3 & 5)
|
||||
2. Understand user segments (power vs casual)
|
||||
3. Identify high-value features (most-used tools)
|
||||
4. Focus on reliability over features (96% success rate target)
|
||||
|
||||
### For Telemetry Enhancement
|
||||
|
||||
1. Review **Telemetry Enhancements** (Section 10)
|
||||
2. Add fine-grained timing metrics
|
||||
3. Track workflow creation funnels
|
||||
4. Monitor node-level analytics
|
||||
|
||||
## Contact & Feedback
|
||||
|
||||
For questions about this analysis or to request additional insights:
|
||||
- Data Analyst: Claude Code with Supabase MCP
|
||||
- Analysis Date: October 2, 2025
|
||||
- Data Period: September 26 - October 2, 2025
|
||||
|
||||
## Change Log
|
||||
|
||||
- **2025-10-02**: Initial comprehensive analysis completed
|
||||
- 15 SQL analytical views created
|
||||
- 13 sections of detailed findings
|
||||
- P0/P1/P2 recommendations with implementation guides
|
||||
- Code examples and effort estimates provided
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Review findings with development team
|
||||
2. ✅ Prioritize P0 recommendations for immediate implementation
|
||||
3. ✅ Plan P1 features for next release cycle
|
||||
4. ✅ Set up monitoring for key metrics
|
||||
5. ✅ Schedule follow-up analysis (weekly recommended)
|
||||
|
||||
---
|
||||
|
||||
*This analysis represents a snapshot of n8n-mcp usage during early adoption phase. Patterns may evolve as the user base grows and matures.*
|
||||
1328
docs/local/Deep_dive_p1_p2.md
Normal file
1328
docs/local/Deep_dive_p1_p2.md
Normal file
File diff suppressed because it is too large
Load Diff
3396
docs/local/N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
Normal file
3396
docs/local/N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
Normal file
File diff suppressed because it is too large
Load Diff
1489
docs/local/P0_IMPLEMENTATION_PLAN.md
Normal file
1489
docs/local/P0_IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
369
docs/local/TEMPLATE_MINING_ANALYSIS.md
Normal file
369
docs/local/TEMPLATE_MINING_ANALYSIS.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Template Mining Analysis - Alternative to P0-R3
|
||||
|
||||
**Date**: 2025-10-02
|
||||
**Context**: Analyzing whether to fix `get_node_for_task` (28% failure rate) or replace it with template-based configuration extraction
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**RECOMMENDATION**: Replace `get_node_for_task` with template-based configuration extraction. The template database contains 2,646 real-world workflows with rich node configurations that far exceed the 31 hardcoded task templates.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Template Database Coverage
|
||||
|
||||
- **Total Templates**: 2,646 production workflows from n8n.io
|
||||
- **Unique Node Types**: 543 (covers 103% of our 525 core nodes)
|
||||
- **Metadata Coverage**: 100% (AI-generated structured metadata)
|
||||
|
||||
### 2. Node Type Coverage in Templates
|
||||
|
||||
Top node types by template usage:
|
||||
```
|
||||
3,820 templates: n8n-nodes-base.httpRequest (144% of total templates!)
|
||||
3,678 templates: n8n-nodes-base.set
|
||||
2,445 templates: n8n-nodes-base.code
|
||||
1,700 templates: n8n-nodes-base.googleSheets
|
||||
1,471 templates: @n8n/n8n-nodes-langchain.agent
|
||||
1,269 templates: @n8n/n8n-nodes-langchain.lmChatOpenAi
|
||||
792 templates: n8n-nodes-base.telegram
|
||||
702 templates: n8n-nodes-base.httpRequestTool
|
||||
596 templates: n8n-nodes-base.gmail
|
||||
466 templates: n8n-nodes-base.webhook
|
||||
```
|
||||
|
||||
**Comparison**:
|
||||
- Hardcoded task templates: 31 tasks covering 5.9% of nodes
|
||||
- Real templates: 2,646 templates with 2-3k examples for common nodes
|
||||
|
||||
### 3. Database Structure
|
||||
|
||||
```sql
|
||||
CREATE TABLE templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
workflow_id INTEGER UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- Node information
|
||||
nodes_used TEXT, -- JSON array: ["n8n-nodes-base.httpRequest", ...]
|
||||
workflow_json_compressed TEXT, -- Base64 encoded gzip of full workflow
|
||||
-- Metadata (100% coverage)
|
||||
metadata_json TEXT, -- AI-generated structured metadata
|
||||
-- Stats
|
||||
views INTEGER DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
-- ...
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Real Configuration Examples
|
||||
|
||||
#### HTTP Request Node Configurations
|
||||
|
||||
**Simple URL fetch**:
|
||||
```json
|
||||
{
|
||||
"url": "https://api.example.com/data",
|
||||
"options": {}
|
||||
}
|
||||
```
|
||||
|
||||
**With authentication**:
|
||||
```json
|
||||
{
|
||||
"url": "=https://api.wavespeed.ai/api/v3/predictions/{{ $json.data.id }}/result",
|
||||
"options": {},
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth"
|
||||
}
|
||||
```
|
||||
|
||||
**Complex expressions**:
|
||||
```json
|
||||
{
|
||||
"url": "=https://image.pollinations.ai/prompt/{{$('Social Media Content Factory').item.json.output.description.replaceAll(' ','-').replaceAll(',','').replaceAll('.','') }}",
|
||||
"options": {}
|
||||
}
|
||||
```
|
||||
|
||||
#### Webhook Node Configurations
|
||||
|
||||
**Basic webhook**:
|
||||
```json
|
||||
{
|
||||
"path": "ytube",
|
||||
"options": {},
|
||||
"httpMethod": "POST",
|
||||
"responseMode": "responseNode"
|
||||
}
|
||||
```
|
||||
|
||||
**With binary data**:
|
||||
```json
|
||||
{
|
||||
"path": "your-endpoint",
|
||||
"options": {
|
||||
"binaryPropertyName": "data"
|
||||
},
|
||||
"httpMethod": "POST"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. AI-Generated Metadata
|
||||
|
||||
Each template has structured metadata including:
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": ["automation", "integration", "data processing"],
|
||||
"complexity": "medium",
|
||||
"use_cases": [
|
||||
"Extract transaction data from Gmail",
|
||||
"Automate bookkeeping",
|
||||
"Expense tracking"
|
||||
],
|
||||
"estimated_setup_minutes": 30,
|
||||
"required_services": ["Gmail", "Google Sheets", "Google Gemini"],
|
||||
"key_features": [
|
||||
"Fetch emails by label",
|
||||
"Extract transaction data",
|
||||
"Use LLM for structured output"
|
||||
],
|
||||
"target_audience": ["Accountants", "Small business owners"]
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison: Task Templates vs Real Templates
|
||||
|
||||
### Current Approach (get_node_for_task)
|
||||
|
||||
**Pros**:
|
||||
- Curated configurations with best practices
|
||||
- Predictable, stable responses
|
||||
- Fast lookup (no decompression needed)
|
||||
|
||||
**Cons**:
|
||||
- Only 31 tasks (5.9% node coverage)
|
||||
- 28% failure rate (users can't find what they need)
|
||||
- Requires manual maintenance
|
||||
- Static configurations without real-world context
|
||||
- Usage ratio 22.5:1 (search_nodes is preferred)
|
||||
|
||||
### Template-Based Approach
|
||||
|
||||
**Pros**:
|
||||
- 2,646 real workflows with 2-3k examples for common nodes
|
||||
- 100% metadata coverage for semantic matching
|
||||
- Real-world patterns and best practices
|
||||
- Covers 543 node types (103% coverage)
|
||||
- Self-updating (templates fetched from n8n.io)
|
||||
- Rich context (use cases, complexity, setup time)
|
||||
|
||||
**Cons**:
|
||||
- Requires decompression for full workflow access
|
||||
- May contain template-specific context (but can be filtered)
|
||||
- Need ranking/filtering logic for best matches
|
||||
|
||||
## Proposed Implementation Strategy
|
||||
|
||||
### Phase 1: Extract Node Configurations from Templates
|
||||
|
||||
Create a new service: `TemplateConfigExtractor`
|
||||
|
||||
```typescript
|
||||
interface ExtractedNodeConfig {
|
||||
nodeType: string;
|
||||
configuration: Record<string, any>;
|
||||
source: {
|
||||
templateId: number;
|
||||
templateName: string;
|
||||
templateViews: number;
|
||||
useCases: string[];
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
};
|
||||
patterns: {
|
||||
hasAuthentication: boolean;
|
||||
hasExpressions: boolean;
|
||||
hasOptionalFields: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class TemplateConfigExtractor {
|
||||
async extractConfigsForNode(
|
||||
nodeType: string,
|
||||
options?: {
|
||||
complexity?: 'simple' | 'medium' | 'complex';
|
||||
requiresAuth?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<ExtractedNodeConfig[]> {
|
||||
// 1. Query templates containing nodeType
|
||||
// 2. Decompress workflow_json_compressed
|
||||
// 3. Extract node configurations
|
||||
// 4. Rank by popularity + complexity match
|
||||
// 5. Return top N configurations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Integrate with Existing Tools
|
||||
|
||||
**Option A**: Enhance `get_node_essentials`
|
||||
- Add `includeExamples: boolean` parameter
|
||||
- Return 2-3 real configurations from templates
|
||||
- Preserve existing compact format
|
||||
|
||||
**Option B**: Enhance `get_node_info`
|
||||
- Add `examples` section with template-sourced configs
|
||||
- Include source attribution (template name, views)
|
||||
|
||||
**Option C**: New tool `get_node_examples`
|
||||
- Dedicated tool for retrieving configuration examples
|
||||
- Query by node type, complexity, use case
|
||||
- Returns ranked list of real configurations
|
||||
|
||||
### Phase 3: Deprecate get_node_for_task
|
||||
|
||||
- Mark as deprecated in tool documentation
|
||||
- Redirect to enhanced tools
|
||||
- Remove after 2-3 version cycles
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Decompression Cost
|
||||
|
||||
- Average compressed size: 6-12 KB
|
||||
- Decompression time: ~5-10ms per template
|
||||
- Caching strategy needed for frequently accessed templates
|
||||
|
||||
### Query Strategy
|
||||
|
||||
```sql
|
||||
-- Fast: Get templates for a node type (no decompression)
|
||||
SELECT id, name, views, metadata_json
|
||||
FROM templates
|
||||
WHERE nodes_used LIKE '%n8n-nodes-base.httpRequest%'
|
||||
ORDER BY views DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Then decompress only top matches
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
- Cache decompressed workflows for popular templates (top 100)
|
||||
- TTL: 1 hour
|
||||
- Estimated memory: 100 * 50KB = 5MB
|
||||
|
||||
## Impact on P0-R3
|
||||
|
||||
**Original P0-R3 Plan**: Expand task library from 31 to 100+ tasks using fuzzy matching
|
||||
|
||||
**New Approach**: Mine 2,646 templates for real configurations
|
||||
|
||||
**Impact Assessment**:
|
||||
|
||||
| Metric | Original Plan | Template Mining |
|
||||
|--------|--------------|-----------------|
|
||||
| Configuration examples | 100 (estimated) | 2,646+ actual |
|
||||
| Node coverage | ~20% | 103% |
|
||||
| Maintenance | High (manual) | Low (auto-fetch) |
|
||||
| Accuracy | Curated | Production-tested |
|
||||
| Context richness | Limited | Rich metadata |
|
||||
| Development time | 2-3 weeks | 1 week |
|
||||
|
||||
**Recommendation**: PIVOT to template mining approach for P0-R3
|
||||
|
||||
## Implementation Estimate
|
||||
|
||||
### Week 1: Core Infrastructure
|
||||
- Day 1-2: Create `TemplateConfigExtractor` service
|
||||
- Day 3: Implement caching layer
|
||||
- Day 4-5: Testing and optimization
|
||||
|
||||
### Week 2: Integration
|
||||
- Day 1-2: Enhance `get_node_essentials` with examples
|
||||
- Day 3: Update tool documentation
|
||||
- Day 4-5: Integration testing
|
||||
|
||||
**Total**: 2 weeks vs 3 weeks for original plan
|
||||
|
||||
## Validation Tests
|
||||
|
||||
```typescript
|
||||
// Test: Extract HTTP Request configs
|
||||
const configs = await extractor.extractConfigsForNode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{ complexity: 'simple', limit: 5 }
|
||||
);
|
||||
|
||||
// Expected: 5 configs from top templates
|
||||
// - Simple URL fetch
|
||||
// - With authentication
|
||||
// - With custom headers
|
||||
// - With expressions
|
||||
// - With error handling
|
||||
|
||||
// Test: Extract webhook configs
|
||||
const webhookConfigs = await extractor.extractConfigsForNode(
|
||||
'n8n-nodes-base.webhook',
|
||||
{ limit: 3 }
|
||||
);
|
||||
|
||||
// Expected: 3 configs showing different patterns
|
||||
// - Basic POST webhook
|
||||
// - With response node
|
||||
// - With binary data handling
|
||||
```
|
||||
|
||||
## Risks and Mitigation
|
||||
|
||||
### Risk 1: Template Quality Varies
|
||||
- **Mitigation**: Filter by views (popularity) and metadata complexity rating
|
||||
- Only use templates with >1000 views for examples
|
||||
|
||||
### Risk 2: Decompression Performance
|
||||
- **Mitigation**: Cache decompressed popular templates
|
||||
- Implement lazy loading (decompress on demand)
|
||||
|
||||
### Risk 3: Template-Specific Context
|
||||
- **Mitigation**: Extract only node configuration, strip workflow-specific context
|
||||
- Provide source attribution for context
|
||||
|
||||
### Risk 4: Breaking Changes in Template Structure
|
||||
- **Mitigation**: Robust error handling in decompression
|
||||
- Fallback to cached configs if template fetch fails
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Before** (get_node_for_task):
|
||||
- 392 calls, 72% success rate
|
||||
- 28% failure rate
|
||||
- 31 task templates
|
||||
- 5.9% node coverage
|
||||
|
||||
**Target** (template-based):
|
||||
- 90%+ success rate for configuration discovery
|
||||
- 100%+ node coverage
|
||||
- 2,646+ real-world examples
|
||||
- Self-updating from n8n.io
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Complete template database analysis
|
||||
2. ⏳ Create `TemplateConfigExtractor` service
|
||||
3. ⏳ Implement caching layer
|
||||
4. ⏳ Enhance `get_node_essentials` with examples
|
||||
5. ⏳ Update P0 implementation plan
|
||||
6. ⏳ Begin implementation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The template database provides a vastly superior alternative to hardcoded task templates:
|
||||
|
||||
- **2,646 templates** vs 31 tasks (85x more examples)
|
||||
- **103% node coverage** vs 5.9% coverage (17x improvement)
|
||||
- **Real-world configurations** vs synthetic examples
|
||||
- **Self-updating** vs manual maintenance
|
||||
- **Rich metadata** for semantic matching
|
||||
|
||||
**Recommendation**: Pivot P0-R3 from "expand task library" to "mine template configurations"
|
||||
1240
docs/local/integration-testing-plan.md
Normal file
1240
docs/local/integration-testing-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
260
docs/local/integration-tests-phase1-summary.md
Normal file
260
docs/local/integration-tests-phase1-summary.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Integration Tests Phase 1: Foundation - COMPLETED
|
||||
|
||||
## Overview
|
||||
Phase 1 establishes the foundation for n8n API integration testing. All core utilities, fixtures, and infrastructure are now in place.
|
||||
|
||||
## Branch
|
||||
`feat/integration-tests-foundation`
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### 1. Environment Configuration
|
||||
- ✅ Updated `.env.example` with integration testing configuration
|
||||
- ✅ Added environment variables for:
|
||||
- n8n API credentials (`N8N_API_URL`, `N8N_API_KEY`)
|
||||
- Webhook workflow IDs (4 workflows for GET/POST/PUT/DELETE)
|
||||
- Test configuration (cleanup, tags, naming)
|
||||
- ✅ Included detailed setup instructions in comments
|
||||
|
||||
### 2. Directory Structure
|
||||
```
|
||||
tests/integration/n8n-api/
|
||||
├── workflows/ (empty - for Phase 2+)
|
||||
├── executions/ (empty - for Phase 2+)
|
||||
├── system/ (empty - for Phase 2+)
|
||||
├── scripts/
|
||||
│ └── cleanup-orphans.ts
|
||||
└── utils/
|
||||
├── credentials.ts
|
||||
├── n8n-client.ts
|
||||
├── test-context.ts
|
||||
├── cleanup-helpers.ts
|
||||
├── fixtures.ts
|
||||
├── factories.ts
|
||||
└── webhook-workflows.ts
|
||||
```
|
||||
|
||||
### 3. Core Utilities
|
||||
|
||||
#### `credentials.ts` (200 lines)
|
||||
- Environment-aware credential loading
|
||||
- Detects CI vs local environment automatically
|
||||
- Validation functions with helpful error messages
|
||||
- Non-throwing credential check functions
|
||||
|
||||
**Key Functions:**
|
||||
- `getN8nCredentials()` - Load credentials from .env or GitHub secrets
|
||||
- `validateCredentials()` - Ensure required credentials are present
|
||||
- `validateWebhookWorkflows()` - Check webhook workflow IDs with setup instructions
|
||||
- `hasCredentials()` - Non-throwing credential check
|
||||
- `hasWebhookWorkflows()` - Non-throwing webhook check
|
||||
|
||||
#### `n8n-client.ts` (45 lines)
|
||||
- Singleton n8n API client wrapper
|
||||
- Pre-configured with test credentials
|
||||
- Health check functionality
|
||||
|
||||
**Key Functions:**
|
||||
- `getTestN8nClient()` - Get/create configured API client
|
||||
- `resetTestN8nClient()` - Reset client instance
|
||||
- `isN8nApiAccessible()` - Check API connectivity
|
||||
|
||||
#### `test-context.ts` (120 lines)
|
||||
- Resource tracking for automatic cleanup
|
||||
- Test workflow naming utilities
|
||||
- Tag management
|
||||
|
||||
**Key Functions:**
|
||||
- `createTestContext()` - Create context for tracking resources
|
||||
- `TestContext.trackWorkflow()` - Track workflow for cleanup
|
||||
- `TestContext.trackExecution()` - Track execution for cleanup
|
||||
- `TestContext.cleanup()` - Delete all tracked resources
|
||||
- `createTestWorkflowName()` - Generate unique workflow names
|
||||
- `getTestTag()` - Get configured test tag
|
||||
|
||||
#### `cleanup-helpers.ts` (275 lines)
|
||||
- Multi-level cleanup strategies
|
||||
- Orphaned resource detection
|
||||
- Age-based execution cleanup
|
||||
- Tag-based workflow cleanup
|
||||
|
||||
**Key Functions:**
|
||||
- `cleanupOrphanedWorkflows()` - Find and delete test workflows
|
||||
- `cleanupOldExecutions()` - Delete executions older than X hours
|
||||
- `cleanupAllTestResources()` - Comprehensive cleanup
|
||||
- `cleanupWorkflowsByTag()` - Delete workflows by tag
|
||||
- `cleanupExecutionsByWorkflow()` - Delete workflow's executions
|
||||
|
||||
#### `fixtures.ts` (310 lines)
|
||||
- Pre-built workflow templates
|
||||
- All using FULL node type format (n8n-nodes-base.*)
|
||||
|
||||
**Available Fixtures:**
|
||||
- `SIMPLE_WEBHOOK_WORKFLOW` - Single webhook node
|
||||
- `SIMPLE_HTTP_WORKFLOW` - Webhook + HTTP Request
|
||||
- `MULTI_NODE_WORKFLOW` - Complex branching workflow
|
||||
- `ERROR_HANDLING_WORKFLOW` - Error output configuration
|
||||
- `AI_AGENT_WORKFLOW` - Langchain agent node
|
||||
- `EXPRESSION_WORKFLOW` - n8n expressions testing
|
||||
|
||||
**Helper Functions:**
|
||||
- `getFixture()` - Get fixture by name (with deep clone)
|
||||
- `createCustomWorkflow()` - Build custom workflow from nodes
|
||||
|
||||
#### `factories.ts` (315 lines)
|
||||
- Dynamic test data generation
|
||||
- Node builders with sensible defaults
|
||||
- Workflow composition helpers
|
||||
|
||||
**Node Factories:**
|
||||
- `createWebhookNode()` - Webhook node with customization
|
||||
- `createHttpRequestNode()` - HTTP Request node
|
||||
- `createSetNode()` - Set node with assignments
|
||||
- `createManualTriggerNode()` - Manual trigger node
|
||||
|
||||
**Connection Factories:**
|
||||
- `createConnection()` - Simple node connection
|
||||
- `createSequentialWorkflow()` - Auto-connected sequential nodes
|
||||
- `createParallelWorkflow()` - Trigger with parallel branches
|
||||
- `createErrorHandlingWorkflow()` - Workflow with error handling
|
||||
|
||||
**Utilities:**
|
||||
- `randomString()` - Generate random test data
|
||||
- `uniqueId()` - Unique IDs for testing
|
||||
- `createTestTags()` - Test workflow tags
|
||||
- `createWorkflowSettings()` - Common settings
|
||||
|
||||
#### `webhook-workflows.ts` (215 lines)
|
||||
- Webhook workflow configuration templates
|
||||
- Setup instructions generator
|
||||
- URL generation utilities
|
||||
|
||||
**Key Features:**
|
||||
- `WEBHOOK_WORKFLOW_CONFIGS` - Configurations for all 4 HTTP methods
|
||||
- `printSetupInstructions()` - Print detailed setup guide
|
||||
- `generateWebhookWorkflowJson()` - Generate workflow JSON
|
||||
- `exportAllWebhookWorkflows()` - Export all 4 configs
|
||||
- `getWebhookUrl()` - Get webhook URL for testing
|
||||
- `isValidWebhookWorkflow()` - Validate workflow structure
|
||||
|
||||
### 4. Scripts
|
||||
|
||||
#### `cleanup-orphans.ts` (40 lines)
|
||||
- Standalone cleanup script
|
||||
- Can be run manually or in CI
|
||||
- Comprehensive output logging
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
npm run test:cleanup:orphans
|
||||
```
|
||||
|
||||
### 5. npm Scripts
|
||||
Added to `package.json`:
|
||||
```json
|
||||
{
|
||||
"test:integration:n8n": "vitest run tests/integration/n8n-api",
|
||||
"test:cleanup:orphans": "tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts"
|
||||
}
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
### TypeScript
|
||||
- ✅ All code passes `npm run typecheck`
|
||||
- ✅ All code compiles with `npm run build`
|
||||
- ✅ No TypeScript errors
|
||||
- ✅ Proper type annotations throughout
|
||||
|
||||
### Error Handling
|
||||
- ✅ Comprehensive error messages
|
||||
- ✅ Helpful setup instructions in error messages
|
||||
- ✅ Non-throwing validation functions where appropriate
|
||||
- ✅ Graceful handling of missing credentials
|
||||
|
||||
### Documentation
|
||||
- ✅ All functions have JSDoc comments
|
||||
- ✅ Usage examples in comments
|
||||
- ✅ Clear parameter descriptions
|
||||
- ✅ Return type documentation
|
||||
|
||||
## Files Created
|
||||
|
||||
### Documentation
|
||||
1. `docs/local/integration-testing-plan.md` (550 lines)
|
||||
2. `docs/local/integration-tests-phase1-summary.md` (this file)
|
||||
|
||||
### Code
|
||||
1. `.env.example` - Updated with test configuration (32 new lines)
|
||||
2. `package.json` - Added 2 npm scripts
|
||||
3. `tests/integration/n8n-api/utils/credentials.ts` (200 lines)
|
||||
4. `tests/integration/n8n-api/utils/n8n-client.ts` (45 lines)
|
||||
5. `tests/integration/n8n-api/utils/test-context.ts` (120 lines)
|
||||
6. `tests/integration/n8n-api/utils/cleanup-helpers.ts` (275 lines)
|
||||
7. `tests/integration/n8n-api/utils/fixtures.ts` (310 lines)
|
||||
8. `tests/integration/n8n-api/utils/factories.ts` (315 lines)
|
||||
9. `tests/integration/n8n-api/utils/webhook-workflows.ts` (215 lines)
|
||||
10. `tests/integration/n8n-api/scripts/cleanup-orphans.ts` (40 lines)
|
||||
|
||||
**Total New Code:** ~1,520 lines of production-ready TypeScript
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
Phase 2 will implement the first actual integration tests:
|
||||
- Create workflow creation tests (10+ scenarios)
|
||||
- Test P0 bug fix (SHORT vs FULL node types)
|
||||
- Test workflow retrieval
|
||||
- Test workflow deletion
|
||||
|
||||
**Branch:** `feat/integration-tests-workflow-creation`
|
||||
|
||||
## Prerequisites for Running Tests
|
||||
|
||||
Before running integration tests, you need to:
|
||||
|
||||
1. **Set up n8n instance:**
|
||||
- Local: `npx n8n start`
|
||||
- Or use cloud/self-hosted n8n
|
||||
|
||||
2. **Configure credentials in `.env`:**
|
||||
```bash
|
||||
N8N_API_URL=http://localhost:5678
|
||||
N8N_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
3. **Create 4 webhook workflows manually:**
|
||||
- One for each HTTP method (GET, POST, PUT, DELETE)
|
||||
- Activate each workflow in n8n UI
|
||||
- Set workflow IDs in `.env`:
|
||||
```bash
|
||||
N8N_TEST_WEBHOOK_GET_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_POST_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_PUT_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_DELETE_ID=<workflow-id>
|
||||
```
|
||||
|
||||
See `docs/local/integration-testing-plan.md` for detailed setup instructions.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Phase 1 Success Criteria - ALL MET:
|
||||
- ✅ All utilities implemented and tested
|
||||
- ✅ TypeScript compiles without errors
|
||||
- ✅ Code follows project conventions
|
||||
- ✅ Comprehensive documentation
|
||||
- ✅ Environment configuration complete
|
||||
- ✅ Cleanup infrastructure in place
|
||||
- ✅ Ready for Phase 2 test implementation
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **N8nApiClient Constructor:** Uses config object, not separate parameters
|
||||
2. **Cursor Handling:** n8n API returns `null` for no more pages, need to convert to `undefined`
|
||||
3. **Workflow ID Validation:** Some workflows might have undefined IDs, need null checks
|
||||
4. **Connection Types:** Error connections need explicit typing to avoid TypeScript errors
|
||||
5. **Webhook Activation:** Cannot be done via API, must be manual - hence pre-activated workflow requirement
|
||||
|
||||
## Time Invested
|
||||
|
||||
Phase 1 actual time: ~2 hours (estimated 2-3 days in plan)
|
||||
- Faster than expected due to clear architecture and reusable patterns
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the comprehensive testing infrastructure implemented for the n8n-MCP project. The testing suite includes over 1,100 tests split between unit and integration tests, benchmarks, and a complete CI/CD pipeline ensuring code quality and reliability.
|
||||
This document describes the comprehensive testing infrastructure implemented for the n8n-MCP project. The testing suite includes 3,336 tests split between unit and integration tests, benchmarks, and a complete CI/CD pipeline ensuring code quality and reliability.
|
||||
|
||||
### Test Suite Statistics (from CI Run #41)
|
||||
### Test Suite Statistics (October 2025)
|
||||
|
||||
- **Total Tests**: 1,182 tests
|
||||
- **Unit Tests**: 933 tests (932 passed, 1 skipped)
|
||||
- **Integration Tests**: 249 tests (245 passed, 4 skipped)
|
||||
- **Test Files**:
|
||||
- 30 unit test files
|
||||
- 14 integration test files
|
||||
- **Test Execution Time**:
|
||||
- **Total Tests**: 3,336 tests
|
||||
- **Unit Tests**: 2,766 tests - Isolated component testing with mocks
|
||||
- **Integration Tests**: 570 tests - Full system behavior validation
|
||||
- n8n API Integration: 172 tests (all 18 MCP handler tools)
|
||||
- MCP Protocol: 119 tests (protocol compliance, session management)
|
||||
- Database: 226 tests (repository operations, transactions, FTS5)
|
||||
- Templates: 35 tests (fetching, storage, metadata)
|
||||
- Docker: 18 tests (configuration, security)
|
||||
- **Test Files**:
|
||||
- 106 unit test files
|
||||
- 41 integration test files
|
||||
- Total: 147 test files
|
||||
- **Test Execution Time**:
|
||||
- Unit tests: ~2 minutes with coverage
|
||||
- Integration tests: ~23 seconds
|
||||
- Total CI time: ~2.5 minutes
|
||||
- **Success Rate**: 99.5% (only 5 tests skipped, 0 failures)
|
||||
- Integration tests: ~30 seconds
|
||||
- Total CI time: ~3 minutes
|
||||
- **Success Rate**: 100% (all tests passing in CI)
|
||||
- **CI/CD Pipeline**: Fully automated with GitHub Actions
|
||||
- **Test Artifacts**: JUnit XML, coverage reports, benchmark results
|
||||
- **Parallel Execution**: Configurable with thread pool
|
||||
@@ -66,13 +72,20 @@ export default defineConfig({
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests with mocks (933 tests, 30 files)
|
||||
├── unit/ # Unit tests with mocks (2,766 tests, 106 files)
|
||||
│ ├── __mocks__/ # Mock implementations
|
||||
│ │ └── n8n-nodes-base.test.ts
|
||||
│ ├── database/ # Database layer tests
|
||||
│ │ ├── database-adapter-unit.test.ts
|
||||
│ │ ├── node-repository-core.test.ts
|
||||
│ │ └── template-repository-core.test.ts
|
||||
│ ├── docker/ # Docker configuration tests
|
||||
│ │ ├── config-security.test.ts
|
||||
│ │ ├── edge-cases.test.ts
|
||||
│ │ ├── parse-config.test.ts
|
||||
│ │ └── serve-command.test.ts
|
||||
│ ├── http-server/ # HTTP server tests
|
||||
│ │ └── multi-tenant-support.test.ts
|
||||
│ ├── loaders/ # Node loader tests
|
||||
│ │ └── node-loader.test.ts
|
||||
│ ├── mappers/ # Data mapper tests
|
||||
@@ -86,6 +99,8 @@ tests/
|
||||
│ │ ├── node-parser.test.ts
|
||||
│ │ ├── property-extractor.test.ts
|
||||
│ │ └── simple-parser.test.ts
|
||||
│ ├── scripts/ # Script tests
|
||||
│ │ └── fetch-templates-extraction.test.ts
|
||||
│ ├── services/ # Service layer tests (largest test suite)
|
||||
│ │ ├── config-validator.test.ts
|
||||
│ │ ├── enhanced-config-validator.test.ts
|
||||
@@ -100,22 +115,56 @@ tests/
|
||||
│ │ ├── workflow-diff-engine.test.ts
|
||||
│ │ ├── workflow-validator-comprehensive.test.ts
|
||||
│ │ └── workflow-validator.test.ts
|
||||
│ ├── telemetry/ # Telemetry tests
|
||||
│ │ └── telemetry-manager.test.ts
|
||||
│ └── utils/ # Utility function tests
|
||||
│ ├── cache-utils.test.ts
|
||||
│ └── database-utils.test.ts
|
||||
├── integration/ # Integration tests (249 tests, 14 files)
|
||||
│ ├── database/ # Database integration tests
|
||||
├── integration/ # Integration tests (570 tests, 41 files)
|
||||
│ ├── n8n-api/ # n8n API integration tests (172 tests, 18 files)
|
||||
│ │ ├── executions/ # Execution management tests
|
||||
│ │ │ ├── get-execution.test.ts
|
||||
│ │ │ └── list-executions.test.ts
|
||||
│ │ ├── system/ # System tool tests
|
||||
│ │ │ ├── diagnostic.test.ts
|
||||
│ │ │ ├── health-check.test.ts
|
||||
│ │ │ └── list-tools.test.ts
|
||||
│ │ ├── utils/ # Test utilities
|
||||
│ │ │ ├── mcp-context.ts
|
||||
│ │ │ └── response-types.ts
|
||||
│ │ └── workflows/ # Workflow management tests
|
||||
│ │ ├── 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
|
||||
│ │ ├── update-full-workflow.test.ts
|
||||
│ │ ├── update-partial-workflow.test.ts
|
||||
│ │ └── validate-workflow.test.ts
|
||||
│ ├── database/ # Database integration tests (226 tests)
|
||||
│ │ ├── connection-management.test.ts
|
||||
│ │ ├── fts5-search.test.ts
|
||||
│ │ ├── node-repository.test.ts
|
||||
│ │ ├── performance.test.ts
|
||||
│ │ ├── template-node-configs.test.ts
|
||||
│ │ ├── template-repository.test.ts
|
||||
│ │ └── transactions.test.ts
|
||||
│ ├── mcp-protocol/ # MCP protocol tests
|
||||
│ ├── docker/ # Docker integration tests (18 tests)
|
||||
│ │ ├── docker-config.test.ts
|
||||
│ │ └── docker-entrypoint.test.ts
|
||||
│ ├── mcp-protocol/ # MCP protocol tests (119 tests)
|
||||
│ │ ├── basic-connection.test.ts
|
||||
│ │ ├── error-handling.test.ts
|
||||
│ │ ├── performance.test.ts
|
||||
│ │ ├── protocol-compliance.test.ts
|
||||
│ │ ├── session-management.test.ts
|
||||
│ │ └── tool-invocation.test.ts
|
||||
│ │ ├── tool-invocation.test.ts
|
||||
│ │ └── workflow-error-validation.test.ts
|
||||
│ ├── templates/ # Template tests (35 tests)
|
||||
│ │ └── metadata-operations.test.ts
|
||||
│ └── setup/ # Integration test setup
|
||||
│ ├── integration-setup.ts
|
||||
│ └── msw-test-server.ts
|
||||
@@ -368,9 +417,54 @@ describe('n8n-nodes-base mock', () => {
|
||||
|
||||
## Integration Testing
|
||||
|
||||
Our integration tests verify the complete system behavior:
|
||||
Our integration tests verify the complete system behavior across 570 tests in four major categories:
|
||||
|
||||
### MCP Protocol Testing
|
||||
### n8n API Integration Testing (172 tests)
|
||||
|
||||
The n8n API integration tests verify all 18 MCP handler tools against a real n8n instance. These tests ensure our product layer (MCP handlers) work correctly end-to-end, not just the raw API client.
|
||||
|
||||
**Test Organization:**
|
||||
- **Workflows** (11 handlers): Create, read, update (full/partial), delete, list, validate, autofix
|
||||
- **Executions** (2 handlers): Get execution details, list executions
|
||||
- **System** (3 handlers): Health check, list available tools, diagnostics
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// tests/integration/n8n-api/workflows/create-workflow.test.ts
|
||||
describe('Integration: handleCreateWorkflow', () => {
|
||||
it('should create a simple two-node workflow', async () => {
|
||||
const response = await handleCreateWorkflow(
|
||||
{
|
||||
params: {
|
||||
arguments: {
|
||||
name: 'Test Workflow',
|
||||
nodes: [webhook, setNode],
|
||||
connections: { Webhook: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } }
|
||||
}
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const workflow = response.data as WorkflowData;
|
||||
expect(workflow.id).toBeDefined();
|
||||
expect(workflow.nodes).toHaveLength(2);
|
||||
|
||||
// Cleanup
|
||||
await handleDeleteWorkflow({ params: { arguments: { id: workflow.id } } }, mcpContext);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Features Tested:**
|
||||
- Real workflow creation, modification, deletion with cleanup
|
||||
- TypeScript type safety with response interfaces
|
||||
- Complete coverage of all 18 n8n API tools
|
||||
- Proper error handling and edge cases
|
||||
- Response format validation
|
||||
|
||||
### MCP Protocol Testing (119 tests)
|
||||
|
||||
```typescript
|
||||
// tests/integration/mcp-protocol/tool-invocation.test.ts
|
||||
@@ -381,20 +475,20 @@ describe('MCP Tool Invocation', () => {
|
||||
beforeEach(async () => {
|
||||
mcpServer = new TestableN8NMCPServer();
|
||||
await mcpServer.initialize();
|
||||
|
||||
|
||||
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.connectToTransport(serverTransport);
|
||||
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, {});
|
||||
await client.connect(clientTransport);
|
||||
});
|
||||
|
||||
it('should list nodes with filtering', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'list_nodes',
|
||||
arguments: { category: 'trigger', limit: 10 }
|
||||
const response = await client.callTool({
|
||||
name: 'list_nodes',
|
||||
arguments: { category: 'trigger', limit: 10 }
|
||||
});
|
||||
|
||||
|
||||
expectValidMCPResponse(response);
|
||||
const result = JSON.parse(response.content[0].text);
|
||||
expect(result.nodes).toHaveLength(10);
|
||||
@@ -403,65 +497,104 @@ describe('MCP Tool Invocation', () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Database Integration Testing
|
||||
### Database Integration Testing (226 tests)
|
||||
|
||||
```typescript
|
||||
// tests/integration/database/fts5-search.test.ts
|
||||
describe('FTS5 Search Integration', () => {
|
||||
it('should perform fuzzy search', async () => {
|
||||
const results = await nodeRepo.searchNodes('HTT', 'FUZZY');
|
||||
|
||||
|
||||
expect(results.some(n => n.nodeType.includes('httpRequest'))).toBe(true);
|
||||
expect(results.some(n => n.displayName.includes('HTTP'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle complex boolean queries', async () => {
|
||||
const results = await nodeRepo.searchNodes('webhook OR http', 'OR');
|
||||
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some(n =>
|
||||
n.description?.includes('webhook') ||
|
||||
expect(results.some(n =>
|
||||
n.description?.includes('webhook') ||
|
||||
n.description?.includes('http')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Template Integration Testing (35 tests)
|
||||
|
||||
Tests template fetching, storage, and metadata operations against the n8n.io API and local database.
|
||||
|
||||
### Docker Integration Testing (18 tests)
|
||||
|
||||
Tests Docker configuration parsing, entrypoint script, and security validation.
|
||||
|
||||
## Test Distribution and Coverage
|
||||
|
||||
### Test Distribution by Component
|
||||
|
||||
Based on our 1,182 tests:
|
||||
Based on our 3,336 tests:
|
||||
|
||||
1. **Services Layer** (~450 tests)
|
||||
**Integration Tests (570 tests):**
|
||||
1. **n8n API Integration** (172 tests)
|
||||
- Workflow management handlers: 11 tools with comprehensive scenarios
|
||||
- Execution management handlers: 2 tools
|
||||
- System tool handlers: 3 tools
|
||||
- TypeScript type safety with response interfaces
|
||||
|
||||
2. **Database Integration** (226 tests)
|
||||
- Repository operations and transactions
|
||||
- FTS5 full-text search with fuzzy matching
|
||||
- Performance and concurrent access tests
|
||||
- Template node configurations
|
||||
|
||||
3. **MCP Protocol** (119 tests)
|
||||
- Protocol compliance and session management
|
||||
- Tool invocation and error handling
|
||||
- Performance and stress testing
|
||||
- Workflow error validation
|
||||
|
||||
4. **Templates & Docker** (53 tests)
|
||||
- Template fetching and metadata operations
|
||||
- Docker configuration and security validation
|
||||
|
||||
**Unit Tests (2,766 tests):**
|
||||
1. **Services Layer** (largest suite)
|
||||
- `workflow-validator-comprehensive.test.ts`: 150+ tests
|
||||
- `node-specific-validators.test.ts`: 120+ tests
|
||||
- `n8n-validation.test.ts`: 80+ tests
|
||||
- `n8n-api-client.test.ts`: 60+ tests
|
||||
- `enhanced-config-validator.test.ts`: 120+ tests
|
||||
- `node-specific-validators.test.ts`: 100+ tests
|
||||
- `n8n-api-client.test.ts`: 80+ tests
|
||||
- Config validation, property filtering, workflow diff engine
|
||||
|
||||
2. **Parsers** (~200 tests)
|
||||
- `simple-parser.test.ts`: 80+ tests
|
||||
- `property-extractor.test.ts`: 70+ tests
|
||||
- `node-parser.test.ts`: 50+ tests
|
||||
- Node parsing with version support
|
||||
- Property extraction and documentation mapping
|
||||
- Simple parser for basic node information
|
||||
|
||||
3. **MCP Integration** (~150 tests)
|
||||
- `tool-invocation.test.ts`: 50+ tests
|
||||
- `error-handling.test.ts`: 40+ tests
|
||||
- `session-management.test.ts`: 30+ tests
|
||||
3. **Database Layer** (~150 tests)
|
||||
- Repository core functionality with mocks
|
||||
- Database adapter unit tests
|
||||
- Template repository operations
|
||||
|
||||
4. **Database** (~300 tests)
|
||||
- Unit tests for repositories: 100+ tests
|
||||
- Integration tests for FTS5 search: 80+ tests
|
||||
- Transaction tests: 60+ tests
|
||||
- Performance tests: 60+ tests
|
||||
4. **MCP Tools & HTTP Server** (~300 tests)
|
||||
- Tool definitions and documentation system
|
||||
- Multi-tenant support and security
|
||||
- Configuration validation
|
||||
|
||||
5. **Utils, Docker, Scripts, Telemetry** (remaining tests)
|
||||
- Cache utilities, database helpers
|
||||
- Docker config security and parsing
|
||||
- Template extraction scripts
|
||||
- Telemetry tracking
|
||||
|
||||
### Test Execution Performance
|
||||
|
||||
From our CI runs:
|
||||
- **Fastest tests**: Unit tests with mocks (<1ms each)
|
||||
- **Slowest tests**: Integration tests with real database (100-5000ms)
|
||||
- **Slowest tests**: Integration tests with real database and n8n API (100-5000ms)
|
||||
- **Average test time**: ~20ms per test
|
||||
- **Total suite execution**: Under 3 minutes in CI
|
||||
- **Total suite execution**: ~3 minutes in CI (with coverage)
|
||||
- **Parallel execution**: Configurable thread pool for optimal performance
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.15.0",
|
||||
"version": "2.15.5",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -31,6 +31,8 @@
|
||||
"test:watch": "vitest watch",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run --config vitest.config.integration.ts",
|
||||
"test:integration:n8n": "vitest run tests/integration/n8n-api",
|
||||
"test:cleanup:orphans": "tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.15.0",
|
||||
"version": "2.15.1",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
41
scripts/export-webhook-workflows.ts
Normal file
41
scripts/export-webhook-workflows.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Export Webhook Workflow JSONs
|
||||
*
|
||||
* Generates the 4 webhook workflow JSON files needed for integration testing.
|
||||
* These workflows must be imported into n8n and activated manually.
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { exportAllWebhookWorkflows } from '../tests/integration/n8n-api/utils/webhook-workflows';
|
||||
|
||||
const OUTPUT_DIR = join(process.cwd(), 'workflows-for-import');
|
||||
|
||||
// Create output directory
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// Generate all workflow JSONs
|
||||
const workflows = exportAllWebhookWorkflows();
|
||||
|
||||
// Write each workflow to a separate file
|
||||
Object.entries(workflows).forEach(([method, workflow]) => {
|
||||
const filename = `webhook-${method.toLowerCase()}.json`;
|
||||
const filepath = join(OUTPUT_DIR, filename);
|
||||
|
||||
writeFileSync(filepath, JSON.stringify(workflow, null, 2), 'utf-8');
|
||||
|
||||
console.log(`✓ Generated: ${filename}`);
|
||||
});
|
||||
|
||||
console.log(`\n✓ All workflow JSONs written to: ${OUTPUT_DIR}`);
|
||||
console.log('\nNext steps:');
|
||||
console.log('1. Import each JSON file into your n8n instance');
|
||||
console.log('2. Activate each workflow in the n8n UI');
|
||||
console.log('3. Copy the webhook URLs from each workflow (open workflow → Webhook node → copy URL)');
|
||||
console.log('4. Add them to your .env file:');
|
||||
console.log(' N8N_TEST_WEBHOOK_GET_URL=https://your-n8n.com/webhook/mcp-test-get');
|
||||
console.log(' N8N_TEST_WEBHOOK_POST_URL=https://your-n8n.com/webhook/mcp-test-post');
|
||||
console.log(' N8N_TEST_WEBHOOK_PUT_URL=https://your-n8n.com/webhook/mcp-test-put');
|
||||
console.log(' N8N_TEST_WEBHOOK_DELETE_URL=https://your-n8n.com/webhook/mcp-test-delete');
|
||||
58
scripts/test-error-message-tracking.ts
Normal file
58
scripts/test-error-message-tracking.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Test script to verify error message tracking is working
|
||||
*/
|
||||
|
||||
import { telemetry } from '../src/telemetry';
|
||||
|
||||
async function testErrorTracking() {
|
||||
console.log('=== Testing Error Message Tracking ===\n');
|
||||
|
||||
// Track session first
|
||||
console.log('1. Starting session...');
|
||||
telemetry.trackSessionStart();
|
||||
|
||||
// Track an error WITH a message
|
||||
console.log('\n2. Tracking error WITH message:');
|
||||
const testErrorMessage = 'This is a test error message with sensitive data: password=secret123 and test@example.com';
|
||||
telemetry.trackError(
|
||||
'TypeError',
|
||||
'tool_execution',
|
||||
'test_tool',
|
||||
testErrorMessage
|
||||
);
|
||||
console.log(` Original message: "${testErrorMessage}"`);
|
||||
|
||||
// Track an error WITHOUT a message
|
||||
console.log('\n3. Tracking error WITHOUT message:');
|
||||
telemetry.trackError(
|
||||
'Error',
|
||||
'tool_execution',
|
||||
'test_tool2'
|
||||
);
|
||||
|
||||
// Check the event queue
|
||||
const metrics = telemetry.getMetrics();
|
||||
console.log('\n4. Telemetry metrics:');
|
||||
console.log(' Status:', metrics.status);
|
||||
console.log(' Events queued:', metrics.tracking.eventsQueued);
|
||||
|
||||
// Get raw event queue to inspect
|
||||
const eventTracker = (telemetry as any).eventTracker;
|
||||
const queue = eventTracker.getEventQueue();
|
||||
|
||||
console.log('\n5. Event queue contents:');
|
||||
queue.forEach((event, i) => {
|
||||
console.log(`\n Event ${i + 1}:`);
|
||||
console.log(` - Type: ${event.event}`);
|
||||
console.log(` - Properties:`, JSON.stringify(event.properties, null, 6));
|
||||
});
|
||||
|
||||
// Flush to database
|
||||
console.log('\n6. Flushing to database...');
|
||||
await telemetry.flush();
|
||||
|
||||
console.log('\n7. Done! Check Supabase for error events with "error" field.');
|
||||
console.log(' Query: SELECT * FROM telemetry_events WHERE event = \'error_occurred\' ORDER BY created_at DESC LIMIT 5;');
|
||||
}
|
||||
|
||||
testErrorTracking().catch(console.error);
|
||||
@@ -23,7 +23,7 @@ async function testIntegration() {
|
||||
|
||||
// Track errors
|
||||
console.log('Tracking errors...');
|
||||
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow');
|
||||
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow', 'Required field missing: nodes array is empty');
|
||||
|
||||
// Track a test workflow
|
||||
console.log('Tracking workflow creation...');
|
||||
|
||||
@@ -284,14 +284,37 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = createWorkflowSchema.parse(args);
|
||||
|
||||
// Normalize all node types before validation
|
||||
const normalizedInput = NodeTypeNormalizer.normalizeWorkflowNodeTypes(input);
|
||||
// Proactively detect SHORT form node types (common mistake)
|
||||
const shortFormErrors: string[] = [];
|
||||
input.nodes?.forEach((node: any, index: number) => {
|
||||
if (node.type?.startsWith('nodes-base.') || node.type?.startsWith('nodes-langchain.')) {
|
||||
const fullForm = node.type.startsWith('nodes-base.')
|
||||
? node.type.replace('nodes-base.', 'n8n-nodes-base.')
|
||||
: node.type.replace('nodes-langchain.', '@n8n/n8n-nodes-langchain.');
|
||||
shortFormErrors.push(
|
||||
`Node ${index} ("${node.name}") uses SHORT form "${node.type}". ` +
|
||||
`The n8n API requires FULL form. Change to "${fullForm}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate workflow structure
|
||||
const errors = validateWorkflowStructure(normalizedInput);
|
||||
if (shortFormErrors.length > 0) {
|
||||
telemetry.trackWorkflowCreation(input, false);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Node type format error: n8n API requires FULL form node types',
|
||||
details: {
|
||||
errors: shortFormErrors,
|
||||
hint: 'Use n8n-nodes-base.* instead of nodes-base.* for standard nodes'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*)
|
||||
const errors = validateWorkflowStructure(input);
|
||||
if (errors.length > 0) {
|
||||
// Track validation failure
|
||||
telemetry.trackWorkflowCreation(normalizedInput, false);
|
||||
telemetry.trackWorkflowCreation(input, false);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
@@ -300,8 +323,8 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
|
||||
};
|
||||
}
|
||||
|
||||
// Create workflow with normalized node types
|
||||
const workflow = await client.createWorkflow(normalizedInput);
|
||||
// Create workflow (n8n API expects node types in FULL form)
|
||||
const workflow = await client.createWorkflow(input);
|
||||
|
||||
// Track successful workflow creation
|
||||
telemetry.trackWorkflowCreation(workflow, true);
|
||||
@@ -529,21 +552,15 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
|
||||
// If nodes/connections are being updated, validate the structure
|
||||
if (updateData.nodes || updateData.connections) {
|
||||
// Fetch current workflow if only partial update
|
||||
let fullWorkflow = updateData as Partial<Workflow>;
|
||||
// Always fetch current workflow for validation (need all fields like name)
|
||||
const current = await client.getWorkflow(id);
|
||||
const fullWorkflow = {
|
||||
...current,
|
||||
...updateData
|
||||
};
|
||||
|
||||
if (!updateData.nodes || !updateData.connections) {
|
||||
const current = await client.getWorkflow(id);
|
||||
fullWorkflow = {
|
||||
...current,
|
||||
...updateData
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize all node types before validation
|
||||
const normalizedWorkflow = NodeTypeNormalizer.normalizeWorkflowNodeTypes(fullWorkflow);
|
||||
|
||||
const errors = validateWorkflowStructure(normalizedWorkflow);
|
||||
// Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*)
|
||||
const errors = validateWorkflowStructure(fullWorkflow);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -551,11 +568,6 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
details: { errors }
|
||||
};
|
||||
}
|
||||
|
||||
// Update updateData with normalized nodes if they were modified
|
||||
if (updateData.nodes) {
|
||||
updateData.nodes = normalizedWorkflow.nodes;
|
||||
}
|
||||
}
|
||||
|
||||
// Update workflow
|
||||
@@ -595,11 +607,12 @@ export async function handleDeleteWorkflow(args: unknown, context?: InstanceCont
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = z.object({ id: z.string() }).parse(args);
|
||||
|
||||
await client.deleteWorkflow(id);
|
||||
|
||||
|
||||
const deleted = await client.deleteWorkflow(id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: deleted,
|
||||
message: `Workflow ${id} deleted successfully`
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -630,12 +643,17 @@ export async function handleListWorkflows(args: unknown, context?: InstanceConte
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = listWorkflowsSchema.parse(args || {});
|
||||
|
||||
|
||||
// Convert tags array to comma-separated string (n8n API format)
|
||||
const tagsParam = input.tags && input.tags.length > 0
|
||||
? input.tags.join(',')
|
||||
: undefined;
|
||||
|
||||
const response = await client.listWorkflows({
|
||||
limit: input.limit || 100,
|
||||
cursor: input.cursor,
|
||||
active: input.active,
|
||||
tags: input.tags,
|
||||
tags: tagsParam as any, // API expects string, not array
|
||||
projectId: input.projectId,
|
||||
excludePinnedData: input.excludePinnedData ?? true
|
||||
});
|
||||
|
||||
@@ -398,7 +398,8 @@ export class N8NDocumentationMCPServer {
|
||||
telemetry.trackError(
|
||||
error instanceof Error ? error.constructor.name : 'UnknownError',
|
||||
`tool_execution`,
|
||||
name
|
||||
name,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// Track tool sequence even for errors
|
||||
|
||||
@@ -63,12 +63,16 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
||||
},
|
||||
returns: 'Updated workflow object or validation results if validateOnly=true',
|
||||
examples: [
|
||||
'// Clean up stale connections after node renames/deletions\nn8n_update_partial_workflow({id: "abc", operations: [{type: "cleanStaleConnections"}]})',
|
||||
'// Remove connection gracefully (no error if it doesn\'t exist)\nn8n_update_partial_workflow({id: "xyz", operations: [{type: "removeConnection", source: "Old Node", target: "Target", ignoreErrors: true}]})',
|
||||
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "123", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
|
||||
'// Replace entire connections object\nn8n_update_partial_workflow({id: "456", operations: [{type: "replaceConnections", connections: {"Webhook": {"main": [[{node: "Slack", type: "main", index: 0}]]}}}]})',
|
||||
'// Update node parameter (classic atomic mode)\nn8n_update_partial_workflow({id: "789", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "012", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})'
|
||||
'// Add a basic node (minimal configuration)\nn8n_update_partial_workflow({id: "abc", operations: [{type: "addNode", node: {name: "Process Data", type: "n8n-nodes-base.set", position: [400, 300], parameters: {}}}]})',
|
||||
'// Add node with full configuration\nn8n_update_partial_workflow({id: "def", operations: [{type: "addNode", node: {name: "Send Slack Alert", type: "n8n-nodes-base.slack", position: [600, 300], typeVersion: 2, parameters: {resource: "message", operation: "post", channel: "#alerts", text: "Success!"}}}]})',
|
||||
'// Add node AND connect it (common pattern)\nn8n_update_partial_workflow({id: "ghi", operations: [\n {type: "addNode", node: {name: "HTTP Request", type: "n8n-nodes-base.httpRequest", position: [400, 300], parameters: {url: "https://api.example.com", method: "GET"}}},\n {type: "addConnection", source: "Webhook", target: "HTTP Request"}\n]})',
|
||||
'// Add multiple nodes in batch\nn8n_update_partial_workflow({id: "jkl", operations: [\n {type: "addNode", node: {name: "Filter", type: "n8n-nodes-base.filter", position: [400, 300], parameters: {}}},\n {type: "addNode", node: {name: "Transform", type: "n8n-nodes-base.set", position: [600, 300], parameters: {}}},\n {type: "addConnection", source: "Filter", target: "Transform"}\n]})',
|
||||
'// Clean up stale connections after node renames/deletions\nn8n_update_partial_workflow({id: "mno", operations: [{type: "cleanStaleConnections"}]})',
|
||||
'// Remove connection gracefully (no error if it doesn\'t exist)\nn8n_update_partial_workflow({id: "pqr", operations: [{type: "removeConnection", source: "Old Node", target: "Target", ignoreErrors: true}]})',
|
||||
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "stu", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
|
||||
'// Replace entire connections object\nn8n_update_partial_workflow({id: "vwx", operations: [{type: "replaceConnections", connections: {"Webhook": {"main": [[{node: "Slack", type: "main", index: 0}]]}}}]})',
|
||||
'// Update node parameter (classic atomic mode)\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})'
|
||||
],
|
||||
useCases: [
|
||||
'Clean up broken workflows after node renames/deletions',
|
||||
|
||||
@@ -161,9 +161,10 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWorkflow(id: string): Promise<void> {
|
||||
async deleteWorkflow(id: string): Promise<Workflow> {
|
||||
try {
|
||||
await this.client.delete(`/workflows/${id}`);
|
||||
const response = await this.client.delete(`/workflows/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
|
||||
@@ -139,18 +139,44 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
// PROBLEM:
|
||||
// - Some versions reject updates with settings properties (community forum reports)
|
||||
// - Cloud versions REQUIRE settings property to be present (n8n.estyl.team)
|
||||
// - Properties like callerPolicy and executionOrder cause "additional properties" errors
|
||||
// - Properties like callerPolicy cause "additional properties" errors
|
||||
//
|
||||
// SOLUTION:
|
||||
// - ALWAYS set settings to empty object {}, regardless of whether it exists
|
||||
// - Filter settings to only include whitelisted properties (OpenAPI spec)
|
||||
// - If no settings provided, use empty object {} for safety
|
||||
// - Empty object satisfies "required property" validation (cloud API)
|
||||
// - Empty object has no "additional properties" to trigger errors (self-hosted)
|
||||
// - n8n API interprets empty settings as "no changes" and preserves existing settings
|
||||
// - Whitelisted properties prevent "additional properties" errors
|
||||
//
|
||||
// References:
|
||||
// - https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
// - OpenAPI spec: workflowSettings schema
|
||||
// - Tested on n8n.estyl.team (cloud) and localhost (self-hosted)
|
||||
cleanedWorkflow.settings = {};
|
||||
|
||||
// Whitelisted settings properties from n8n OpenAPI spec
|
||||
const safeSettingsProperties = [
|
||||
'saveExecutionProgress',
|
||||
'saveManualExecutions',
|
||||
'saveDataErrorExecution',
|
||||
'saveDataSuccessExecution',
|
||||
'executionTimeout',
|
||||
'errorWorkflow',
|
||||
'timezone',
|
||||
'executionOrder'
|
||||
];
|
||||
|
||||
if (cleanedWorkflow.settings && typeof cleanedWorkflow.settings === 'object') {
|
||||
// Filter to only safe properties
|
||||
const filteredSettings: any = {};
|
||||
for (const key of safeSettingsProperties) {
|
||||
if (key in cleanedWorkflow.settings) {
|
||||
filteredSettings[key] = (cleanedWorkflow.settings as any)[key];
|
||||
}
|
||||
}
|
||||
cleanedWorkflow.settings = filteredSettings;
|
||||
} else {
|
||||
// No settings provided - use empty object for safety
|
||||
cleanedWorkflow.settings = {};
|
||||
}
|
||||
|
||||
return cleanedWorkflow;
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export class TelemetryEventTracker {
|
||||
/**
|
||||
* Track an error event
|
||||
*/
|
||||
trackError(errorType: string, context: string, toolName?: string): void {
|
||||
trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void {
|
||||
if (!this.isEnabled()) return;
|
||||
|
||||
// Don't rate limit error tracking - we want to see all errors
|
||||
@@ -135,6 +135,7 @@ export class TelemetryEventTracker {
|
||||
errorType: this.sanitizeErrorType(errorType),
|
||||
context: this.sanitizeContext(context),
|
||||
tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined,
|
||||
error: errorMessage ? this.sanitizeErrorMessage(errorMessage) : undefined,
|
||||
}, false); // Skip rate limiting for errors
|
||||
}
|
||||
|
||||
@@ -428,4 +429,56 @@ export class TelemetryEventTracker {
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error message
|
||||
*/
|
||||
private sanitizeErrorMessage(errorMessage: string): string {
|
||||
try {
|
||||
// Early truncate to prevent ReDoS and performance issues
|
||||
const maxLength = 1500;
|
||||
const trimmed = errorMessage.length > maxLength
|
||||
? errorMessage.substring(0, maxLength)
|
||||
: errorMessage;
|
||||
|
||||
// Handle stack traces - keep only first 3 lines (message + top stack frames)
|
||||
const lines = trimmed.split('\n');
|
||||
let sanitized = lines.slice(0, 3).join('\n');
|
||||
|
||||
// Sanitize sensitive data in correct order to prevent leakage
|
||||
// 1. URLs first (most encompassing) - fully redact to prevent path leakage
|
||||
sanitized = sanitized.replace(/https?:\/\/\S+/gi, '[URL]');
|
||||
|
||||
// 2. Specific credential patterns (before generic patterns)
|
||||
sanitized = sanitized
|
||||
.replace(/AKIA[A-Z0-9]{16}/g, '[AWS_KEY]')
|
||||
.replace(/ghp_[a-zA-Z0-9]{36,}/g, '[GITHUB_TOKEN]')
|
||||
.replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[JWT]')
|
||||
.replace(/Bearer\s+[^\s]+/gi, 'Bearer [TOKEN]');
|
||||
|
||||
// 3. Emails (after URLs to avoid partial matches)
|
||||
sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
|
||||
|
||||
// 4. Long keys and quoted tokens
|
||||
sanitized = sanitized
|
||||
.replace(/\b[a-zA-Z0-9_-]{32,}\b/g, '[KEY]')
|
||||
.replace(/(['"])[a-zA-Z0-9_-]{16,}\1/g, '$1[TOKEN]$1');
|
||||
|
||||
// 5. Generic credential patterns (after specific ones to avoid conflicts)
|
||||
sanitized = sanitized
|
||||
.replace(/password\s*[=:]\s*\S+/gi, 'password=[REDACTED]')
|
||||
.replace(/api[_-]?key\s*[=:]\s*\S+/gi, 'api_key=[REDACTED]')
|
||||
.replace(/(?<!Bearer\s)token\s*[=:]\s*\S+/gi, 'token=[REDACTED]'); // Negative lookbehind to avoid Bearer tokens
|
||||
|
||||
// Final truncate to 500 chars
|
||||
if (sanitized.length > 500) {
|
||||
sanitized = sanitized.substring(0, 500) + '...';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
} catch (error) {
|
||||
logger.debug('Error message sanitization failed:', error);
|
||||
return '[SANITIZATION_FAILED]';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,9 +152,9 @@ export class TelemetryManager {
|
||||
/**
|
||||
* Track an error event
|
||||
*/
|
||||
trackError(errorType: string, context: string, toolName?: string): void {
|
||||
trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void {
|
||||
this.ensureInitialized();
|
||||
this.eventTracker.trackError(errorType, context, toolName);
|
||||
this.eventTracker.trackError(errorType, context, toolName, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -625,7 +625,65 @@ export class TemplateRepository {
|
||||
|
||||
return { total, withMetadata, withoutMetadata, outdated };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build WHERE conditions for metadata filtering
|
||||
* @private
|
||||
* @returns Object containing SQL conditions array and parameter values array
|
||||
*/
|
||||
private buildMetadataFilterConditions(filters: {
|
||||
category?: string;
|
||||
complexity?: 'simple' | 'medium' | 'complex';
|
||||
maxSetupMinutes?: number;
|
||||
minSetupMinutes?: number;
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}): { conditions: string[], params: any[] } {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search templates by metadata fields
|
||||
*/
|
||||
@@ -637,60 +695,72 @@ export class TemplateRepository {
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}, limit: number = 20, offset: number = 0): StoredTemplate[] {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build WHERE conditions based on filters with proper parameterization
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT * FROM templates
|
||||
const startTime = Date.now();
|
||||
|
||||
// Build WHERE conditions using shared helper
|
||||
const { conditions, params } = this.buildMetadataFilterConditions(filters);
|
||||
|
||||
// Performance optimization: Use two-phase query to avoid loading large compressed workflows
|
||||
// during metadata filtering. This prevents timeout when no filters are provided.
|
||||
// Phase 1: Get IDs only with metadata filtering (fast - no workflow data)
|
||||
// Add id to ORDER BY to ensure stable ordering
|
||||
const idsQuery = `
|
||||
SELECT id FROM templates
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY views DESC, created_at DESC
|
||||
ORDER BY views DESC, created_at DESC, id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
|
||||
params.push(limit, offset);
|
||||
const results = this.db.prepare(query).all(...params) as StoredTemplate[];
|
||||
|
||||
logger.debug(`Metadata search found ${results.length} results`, { filters, count: results.length });
|
||||
const ids = this.db.prepare(idsQuery).all(...params) as { id: number }[];
|
||||
|
||||
const phase1Time = Date.now() - startTime;
|
||||
|
||||
if (ids.length === 0) {
|
||||
logger.debug('Metadata search found 0 results', { filters, phase1Ms: phase1Time });
|
||||
return [];
|
||||
}
|
||||
|
||||
// Defensive validation: ensure all IDs are valid positive integers
|
||||
const idValues = ids.map(r => r.id).filter(id => typeof id === 'number' && id > 0 && Number.isInteger(id));
|
||||
|
||||
if (idValues.length === 0) {
|
||||
logger.warn('No valid IDs after filtering', { filters, originalCount: ids.length });
|
||||
return [];
|
||||
}
|
||||
|
||||
if (idValues.length !== ids.length) {
|
||||
logger.warn('Some IDs were filtered out as invalid', {
|
||||
original: ids.length,
|
||||
valid: idValues.length,
|
||||
filtered: ids.length - idValues.length
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Fetch full records preserving exact order from Phase 1
|
||||
// Use CTE with VALUES to maintain ordering without depending on SQLite's IN clause behavior
|
||||
const phase2Start = Date.now();
|
||||
const orderedQuery = `
|
||||
WITH ordered_ids(id, sort_order) AS (
|
||||
VALUES ${idValues.map((id, idx) => `(${id}, ${idx})`).join(', ')}
|
||||
)
|
||||
SELECT t.* FROM templates t
|
||||
INNER JOIN ordered_ids o ON t.id = o.id
|
||||
ORDER BY o.sort_order
|
||||
`;
|
||||
|
||||
const results = this.db.prepare(orderedQuery).all() as StoredTemplate[];
|
||||
const phase2Time = Date.now() - phase2Start;
|
||||
|
||||
logger.debug(`Metadata search found ${results.length} results`, {
|
||||
filters,
|
||||
count: results.length,
|
||||
phase1Ms: phase1Time,
|
||||
phase2Ms: phase2Time,
|
||||
totalMs: Date.now() - startTime,
|
||||
optimization: 'two-phase-with-ordering'
|
||||
});
|
||||
|
||||
return results.map(t => this.decompressWorkflow(t));
|
||||
}
|
||||
|
||||
@@ -705,48 +775,12 @@ export class TemplateRepository {
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}): number {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
// Build WHERE conditions using shared helper
|
||||
const { conditions, params } = this.buildMetadataFilterConditions(filters);
|
||||
|
||||
const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions.join(' AND ')}`;
|
||||
const result = this.db.prepare(query).get(...params) as { count: number };
|
||||
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export interface WorkflowListParams {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
active?: boolean;
|
||||
tags?: string[] | null;
|
||||
tags?: string | null; // Comma-separated string per n8n API spec
|
||||
projectId?: string;
|
||||
excludePinnedData?: boolean;
|
||||
instance?: string;
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
/**
|
||||
* Universal Node Type Normalizer
|
||||
* Universal Node Type Normalizer - FOR DATABASE OPERATIONS ONLY
|
||||
*
|
||||
* Converts ANY node type variation to the canonical SHORT form used by the database.
|
||||
* This fixes the critical issue where AI agents or external sources may produce
|
||||
* full-form node types (e.g., "n8n-nodes-base.webhook") which need to be normalized
|
||||
* to match the database storage format (e.g., "nodes-base.webhook").
|
||||
* ⚠️ WARNING: Do NOT use before n8n API calls!
|
||||
*
|
||||
* This class converts node types to SHORT form (database format).
|
||||
* The n8n API requires FULL form (n8n-nodes-base.*).
|
||||
*
|
||||
* **Use this ONLY when:**
|
||||
* - Querying the node database
|
||||
* - Searching for node information
|
||||
* - Looking up node metadata
|
||||
*
|
||||
* **Do NOT use before:**
|
||||
* - Creating workflows (n8n_create_workflow)
|
||||
* - Updating workflows (n8n_update_workflow)
|
||||
* - Any n8n API calls
|
||||
*
|
||||
* **IMPORTANT:** The n8n-mcp database stores nodes in SHORT form:
|
||||
* - n8n-nodes-base → nodes-base
|
||||
* - @n8n/n8n-nodes-langchain → nodes-langchain
|
||||
*
|
||||
* Handles:
|
||||
* - Full form → Short form (n8n-nodes-base.X → nodes-base.X)
|
||||
* - Already short form → Unchanged
|
||||
* - LangChain nodes → Proper short prefix
|
||||
* But the n8n API requires FULL form:
|
||||
* - nodes-base → n8n-nodes-base
|
||||
* - nodes-langchain → @n8n/n8n-nodes-langchain
|
||||
*
|
||||
* @example
|
||||
* NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook')
|
||||
* @example Database Lookup (CORRECT usage)
|
||||
* const dbType = NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook')
|
||||
* // → 'nodes-base.webhook'
|
||||
* const node = await repository.getNode(dbType)
|
||||
*
|
||||
* @example
|
||||
* NodeTypeNormalizer.normalizeToFullForm('nodes-base.webhook')
|
||||
* // → 'nodes-base.webhook' (unchanged)
|
||||
* @example API Call (INCORRECT - Do NOT do this!)
|
||||
* const workflow = { nodes: [{ type: 'n8n-nodes-base.webhook' }] }
|
||||
* const normalized = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow)
|
||||
* // ❌ WRONG! normalized has SHORT form, API needs FULL form
|
||||
* await client.createWorkflow(normalized) // FAILS!
|
||||
*
|
||||
* @example API Call (CORRECT)
|
||||
* const workflow = { nodes: [{ type: 'n8n-nodes-base.webhook' }] }
|
||||
* // ✅ Send as-is to API (FULL form required)
|
||||
* await client.createWorkflow(workflow) // WORKS!
|
||||
*/
|
||||
|
||||
export interface NodeTypeNormalizationResult {
|
||||
|
||||
@@ -643,6 +643,207 @@ describe('TemplateRepository Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTemplatesByMetadata - Two-Phase Optimization', () => {
|
||||
it('should use two-phase query pattern for performance', () => {
|
||||
// Setup: Create templates with metadata and different views for deterministic ordering
|
||||
const templates = [
|
||||
{ id: 1, complexity: 'simple', category: 'automation', views: 200 },
|
||||
{ id: 2, complexity: 'medium', category: 'integration', views: 300 },
|
||||
{ id: 3, complexity: 'simple', category: 'automation', views: 100 },
|
||||
{ id: 4, complexity: 'complex', category: 'data-processing', views: 400 }
|
||||
];
|
||||
|
||||
templates.forEach(({ id, complexity, category, views }) => {
|
||||
const template = createTemplateWorkflow({ id, name: `Template ${id}`, totalViews: views });
|
||||
const detail = createTemplateDetail({
|
||||
id,
|
||||
views,
|
||||
workflow: {
|
||||
id: id.toString(),
|
||||
name: `Template ${id}`,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
// Update views to match our test data
|
||||
db.prepare(`UPDATE templates SET views = ? WHERE workflow_id = ?`).run(views, id);
|
||||
|
||||
// Add metadata
|
||||
const metadata = {
|
||||
categories: [category],
|
||||
complexity,
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = ?
|
||||
`).run(JSON.stringify(metadata), id);
|
||||
});
|
||||
|
||||
// Test: Search with filter should return matching templates
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
// Verify results - Ordered by views DESC (200, 100), then created_at DESC, then id ASC
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].workflow_id).toBe(1); // 200 views
|
||||
expect(results[1].workflow_id).toBe(3); // 100 views
|
||||
});
|
||||
|
||||
it('should preserve exact ordering from Phase 1', () => {
|
||||
// Setup: Create templates with different view counts
|
||||
// Use unique views to ensure deterministic ordering
|
||||
const templates = [
|
||||
{ id: 1, views: 100 },
|
||||
{ id: 2, views: 500 },
|
||||
{ id: 3, views: 300 },
|
||||
{ id: 4, views: 400 },
|
||||
{ id: 5, views: 200 }
|
||||
];
|
||||
|
||||
templates.forEach(({ id, views }) => {
|
||||
const template = createTemplateWorkflow({ id, name: `Template ${id}`, totalViews: views });
|
||||
const detail = createTemplateDetail({
|
||||
id,
|
||||
views,
|
||||
workflow: {
|
||||
id: id.toString(),
|
||||
name: `Template ${id}`,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
// Update views in database to match our test data
|
||||
db.prepare(`UPDATE templates SET views = ? WHERE workflow_id = ?`).run(views, id);
|
||||
|
||||
// Add metadata
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'medium',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = ?
|
||||
`).run(JSON.stringify(metadata), id);
|
||||
});
|
||||
|
||||
// Test: Search should return templates in correct order
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'medium' }, 10, 0);
|
||||
|
||||
// Verify ordering: 500 views, 400 views, 300 views, 200 views, 100 views
|
||||
expect(results).toHaveLength(5);
|
||||
expect(results[0].workflow_id).toBe(2); // 500 views
|
||||
expect(results[1].workflow_id).toBe(4); // 400 views
|
||||
expect(results[2].workflow_id).toBe(3); // 300 views
|
||||
expect(results[3].workflow_id).toBe(5); // 200 views
|
||||
expect(results[4].workflow_id).toBe(1); // 100 views
|
||||
});
|
||||
|
||||
it('should handle empty results efficiently', () => {
|
||||
// Setup: Create templates without the searched complexity
|
||||
const template = createTemplateWorkflow({ id: 1 });
|
||||
const detail = createTemplateDetail({
|
||||
id: 1,
|
||||
workflow: {
|
||||
id: '1',
|
||||
name: 'Template 1',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'simple',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = 1
|
||||
`).run(JSON.stringify(metadata));
|
||||
|
||||
// Test: Search for non-existent complexity
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'complex' }, 10, 0);
|
||||
|
||||
// Verify: Should return empty array without errors
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate IDs defensively', () => {
|
||||
// This test ensures the defensive ID validation works
|
||||
// Setup: Create a template
|
||||
const template = createTemplateWorkflow({ id: 1 });
|
||||
const detail = createTemplateDetail({
|
||||
id: 1,
|
||||
workflow: {
|
||||
id: '1',
|
||||
name: 'Template 1',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'simple',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = 1
|
||||
`).run(JSON.stringify(metadata));
|
||||
|
||||
// Test: Normal search should work
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
// Verify: Should return the template
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].workflow_id).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
148
tests/integration/n8n-api/executions/delete-execution.test.ts
Normal file
148
tests/integration/n8n-api/executions/delete-execution.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Integration Tests: handleDeleteExecution
|
||||
*
|
||||
* Tests execution deletion against a real n8n instance.
|
||||
* Covers successful deletion, error handling, and cleanup verification.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, beforeAll } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleDeleteExecution, handleTriggerWebhookWorkflow, handleGetExecution } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { getN8nCredentials } from '../utils/credentials';
|
||||
|
||||
describe('Integration: handleDeleteExecution', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
let webhookUrl: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
const creds = getN8nCredentials();
|
||||
webhookUrl = creds.webhookUrls.get;
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Successful Deletion
|
||||
// ======================================================================
|
||||
|
||||
describe('Successful Deletion', () => {
|
||||
it('should delete an execution successfully', async () => {
|
||||
// First, create an execution to delete
|
||||
const triggerResponse = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl,
|
||||
httpMethod: 'GET',
|
||||
waitForResponse: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Try to extract execution ID
|
||||
let executionId: string | undefined;
|
||||
if (triggerResponse.success && triggerResponse.data) {
|
||||
const responseData = triggerResponse.data as any;
|
||||
executionId = responseData.executionId ||
|
||||
responseData.id ||
|
||||
responseData.execution?.id ||
|
||||
responseData.workflowData?.executionId;
|
||||
}
|
||||
|
||||
if (!executionId) {
|
||||
console.warn('Could not extract execution ID for deletion test');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the execution
|
||||
const response = await handleDeleteExecution(
|
||||
{ id: executionId },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
}, 30000);
|
||||
|
||||
it('should verify execution is actually deleted', async () => {
|
||||
// Create an execution
|
||||
const triggerResponse = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl,
|
||||
httpMethod: 'GET',
|
||||
waitForResponse: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
let executionId: string | undefined;
|
||||
if (triggerResponse.success && triggerResponse.data) {
|
||||
const responseData = triggerResponse.data as any;
|
||||
executionId = responseData.executionId ||
|
||||
responseData.id ||
|
||||
responseData.execution?.id ||
|
||||
responseData.workflowData?.executionId;
|
||||
}
|
||||
|
||||
if (!executionId) {
|
||||
console.warn('Could not extract execution ID for deletion verification test');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete it
|
||||
const deleteResponse = await handleDeleteExecution(
|
||||
{ id: executionId },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(deleteResponse.success).toBe(true);
|
||||
|
||||
// Try to fetch the deleted execution
|
||||
const getResponse = await handleGetExecution(
|
||||
{ id: executionId },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Should fail to find the deleted execution
|
||||
expect(getResponse.success).toBe(false);
|
||||
expect(getResponse.error).toBeDefined();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle non-existent execution ID', async () => {
|
||||
const response = await handleDeleteExecution(
|
||||
{ id: '99999999' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid execution ID format', async () => {
|
||||
const response = await handleDeleteExecution(
|
||||
{ id: 'invalid-id-format' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle missing execution ID', async () => {
|
||||
const response = await handleDeleteExecution(
|
||||
{} as any,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
428
tests/integration/n8n-api/executions/get-execution.test.ts
Normal file
428
tests/integration/n8n-api/executions/get-execution.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Integration Tests: handleGetExecution
|
||||
*
|
||||
* Tests execution retrieval against a real n8n instance.
|
||||
* Covers all retrieval modes, filtering options, and error handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleGetExecution, handleTriggerWebhookWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { getN8nCredentials } from '../utils/credentials';
|
||||
|
||||
describe('Integration: handleGetExecution', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
let executionId: string;
|
||||
let webhookUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpContext = createMcpContext();
|
||||
const creds = getN8nCredentials();
|
||||
webhookUrl = creds.webhookUrls.get;
|
||||
|
||||
// Trigger a webhook to create an execution for testing
|
||||
const triggerResponse = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl,
|
||||
httpMethod: 'GET',
|
||||
waitForResponse: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Extract execution ID from the response
|
||||
if (triggerResponse.success && triggerResponse.data) {
|
||||
const responseData = triggerResponse.data as any;
|
||||
// Try to get execution ID from various possible locations
|
||||
executionId = responseData.executionId ||
|
||||
responseData.id ||
|
||||
responseData.execution?.id ||
|
||||
responseData.workflowData?.executionId;
|
||||
|
||||
if (!executionId) {
|
||||
// If no execution ID in response, we'll use error handling tests
|
||||
console.warn('Could not extract execution ID from webhook response');
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// ======================================================================
|
||||
// Preview Mode
|
||||
// ======================================================================
|
||||
|
||||
describe('Preview Mode', () => {
|
||||
it('should get execution in preview mode (structure only)', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'preview'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Preview mode should return structure and counts
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
|
||||
// Should have basic execution info
|
||||
if (data.status) {
|
||||
expect(['success', 'error', 'running', 'waiting']).toContain(data.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Summary Mode (Default)
|
||||
// ======================================================================
|
||||
|
||||
describe('Summary Mode', () => {
|
||||
it('should get execution in summary mode (2 samples per node)', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'summary'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
|
||||
it('should default to summary mode when mode not specified', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Filtered Mode
|
||||
// ======================================================================
|
||||
|
||||
describe('Filtered Mode', () => {
|
||||
it('should get execution with custom items limit', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'filtered',
|
||||
itemsLimit: 5
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
|
||||
it('should get execution with itemsLimit 0 (structure only)', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'filtered',
|
||||
itemsLimit: 0
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
|
||||
it('should get execution with unlimited items (itemsLimit: -1)', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'filtered',
|
||||
itemsLimit: -1
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
|
||||
it('should get execution filtered by node names', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'filtered',
|
||||
nodeNames: ['Webhook']
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Full Mode
|
||||
// ======================================================================
|
||||
|
||||
describe('Full Mode', () => {
|
||||
it('should get complete execution data', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'full'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
|
||||
// Full mode should include complete execution data
|
||||
if (data.data) {
|
||||
expect(typeof data.data).toBe('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Input Data Inclusion
|
||||
// ======================================================================
|
||||
|
||||
describe('Input Data Inclusion', () => {
|
||||
it('should include input data when requested', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'summary',
|
||||
includeInputData: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
|
||||
it('should exclude input data by default', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'summary',
|
||||
includeInputData: false
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Legacy Parameter Compatibility
|
||||
// ======================================================================
|
||||
|
||||
describe('Legacy Parameter Compatibility', () => {
|
||||
it('should support legacy includeData parameter', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
includeData: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toBe(executionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle non-existent execution ID', async () => {
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: '99999999'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid execution ID format', async () => {
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: 'invalid-id-format'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle missing execution ID', async () => {
|
||||
const response = await handleGetExecution(
|
||||
{} as any,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid mode parameter', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'invalid-mode' as any
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete execution response structure', async () => {
|
||||
if (!executionId) {
|
||||
console.warn('Skipping test: No execution ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleGetExecution(
|
||||
{
|
||||
id: executionId,
|
||||
mode: 'summary'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as any;
|
||||
expect(data.id).toBeDefined();
|
||||
|
||||
// Should have execution metadata
|
||||
if (data.status) {
|
||||
expect(typeof data.status).toBe('string');
|
||||
}
|
||||
if (data.mode) {
|
||||
expect(typeof data.mode).toBe('string');
|
||||
}
|
||||
if (data.startedAt) {
|
||||
expect(typeof data.startedAt).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
263
tests/integration/n8n-api/executions/list-executions.test.ts
Normal file
263
tests/integration/n8n-api/executions/list-executions.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Integration Tests: handleListExecutions
|
||||
*
|
||||
* Tests execution listing against a real n8n instance.
|
||||
* Covers filtering, pagination, and various list parameters.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleListExecutions } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleListExecutions', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// No Filters
|
||||
// ======================================================================
|
||||
|
||||
describe('No Filters', () => {
|
||||
it('should list all executions without filters', async () => {
|
||||
const response = await handleListExecutions({}, mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as any;
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
expect(data).toHaveProperty('returned');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Filter by Status
|
||||
// ======================================================================
|
||||
|
||||
describe('Filter by Status', () => {
|
||||
it('should filter executions by success status', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ status: 'success' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
// All returned executions should have success status
|
||||
if (data.executions.length > 0) {
|
||||
data.executions.forEach((exec: any) => {
|
||||
expect(exec.status).toBe('success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter executions by error status', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ status: 'error' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
// All returned executions should have error status
|
||||
if (data.executions.length > 0) {
|
||||
data.executions.forEach((exec: any) => {
|
||||
expect(exec.status).toBe('error');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter executions by waiting status', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ status: 'waiting' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Pagination
|
||||
// ======================================================================
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should return first page with limit', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 10 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
expect(data.executions.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should handle pagination with cursor', async () => {
|
||||
// Get first page
|
||||
const firstPage = await handleListExecutions(
|
||||
{ limit: 5 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(firstPage.success).toBe(true);
|
||||
const firstData = firstPage.data as any;
|
||||
|
||||
// If there's a next cursor, get second page
|
||||
if (firstData.nextCursor) {
|
||||
const secondPage = await handleListExecutions(
|
||||
{ limit: 5, cursor: firstData.nextCursor },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(secondPage.success).toBe(true);
|
||||
const secondData = secondPage.data as any;
|
||||
|
||||
// Second page should have different executions
|
||||
const firstIds = new Set(firstData.executions.map((e: any) => e.id));
|
||||
const secondIds = secondData.executions.map((e: any) => e.id);
|
||||
|
||||
secondIds.forEach((id: string) => {
|
||||
expect(firstIds.has(id)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect limit=1', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 1 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.executions.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should respect limit=50', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 50 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.executions.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should respect limit=100 (max)', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 100 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.executions.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Include Execution Data
|
||||
// ======================================================================
|
||||
|
||||
describe('Include Execution Data', () => {
|
||||
it('should exclude execution data by default', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 5 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
// By default, should not include full execution data
|
||||
});
|
||||
|
||||
it('should include execution data when requested', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 5, includeData: true },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Empty Results
|
||||
// ======================================================================
|
||||
|
||||
describe('Empty Results', () => {
|
||||
it('should return empty array when no executions match filters', async () => {
|
||||
// Use a very restrictive workflowId that likely doesn't exist
|
||||
const response = await handleListExecutions(
|
||||
{ workflowId: '99999999' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
// May or may not be empty depending on actual data
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete list response structure', async () => {
|
||||
const response = await handleListExecutions(
|
||||
{ limit: 10 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Verify required fields
|
||||
expect(data).toHaveProperty('executions');
|
||||
expect(Array.isArray(data.executions)).toBe(true);
|
||||
expect(data).toHaveProperty('returned');
|
||||
expect(data).toHaveProperty('hasMore');
|
||||
|
||||
// Verify pagination fields when present
|
||||
if (data.nextCursor) {
|
||||
expect(typeof data.nextCursor).toBe('string');
|
||||
}
|
||||
|
||||
// Verify execution structure if any executions returned
|
||||
if (data.executions.length > 0) {
|
||||
const execution = data.executions[0];
|
||||
expect(execution).toHaveProperty('id');
|
||||
|
||||
if (execution.status) {
|
||||
expect(['success', 'error', 'running', 'waiting']).toContain(execution.status);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
375
tests/integration/n8n-api/executions/trigger-webhook.test.ts
Normal file
375
tests/integration/n8n-api/executions/trigger-webhook.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Integration Tests: handleTriggerWebhookWorkflow
|
||||
*
|
||||
* Tests webhook triggering against a real n8n instance.
|
||||
* Covers all HTTP methods, request data, headers, and error handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleTriggerWebhookWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { getN8nCredentials } from '../utils/credentials';
|
||||
|
||||
describe('Integration: handleTriggerWebhookWorkflow', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
let webhookUrls: {
|
||||
get: string;
|
||||
post: string;
|
||||
put: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
const creds = getN8nCredentials();
|
||||
webhookUrls = creds.webhookUrls;
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// GET Method Tests
|
||||
// ======================================================================
|
||||
|
||||
describe('GET Method', () => {
|
||||
it('should trigger GET webhook without data', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.get,
|
||||
httpMethod: 'GET'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.message).toContain('Webhook triggered successfully');
|
||||
});
|
||||
|
||||
it('should trigger GET webhook with query parameters', async () => {
|
||||
// GET method uses query parameters in URL
|
||||
const urlWithParams = `${webhookUrls.get}?testParam=value&number=42`;
|
||||
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: urlWithParams,
|
||||
httpMethod: 'GET'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger GET webhook with custom headers', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.get,
|
||||
httpMethod: 'GET',
|
||||
headers: {
|
||||
'X-Custom-Header': 'test-value',
|
||||
'X-Request-Id': '12345'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger GET webhook and wait for response', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.get,
|
||||
httpMethod: 'GET',
|
||||
waitForResponse: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
// Response should contain workflow execution data
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// POST Method Tests
|
||||
// ======================================================================
|
||||
|
||||
describe('POST Method', () => {
|
||||
it('should trigger POST webhook with JSON data', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.post,
|
||||
httpMethod: 'POST',
|
||||
data: {
|
||||
message: 'Test webhook trigger',
|
||||
timestamp: Date.now(),
|
||||
nested: {
|
||||
value: 'nested data'
|
||||
}
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger POST webhook without data', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.post,
|
||||
httpMethod: 'POST'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger POST webhook with custom headers', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.post,
|
||||
httpMethod: 'POST',
|
||||
data: { test: 'data' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': 'test-key'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger POST webhook without waiting for response', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.post,
|
||||
httpMethod: 'POST',
|
||||
data: { async: true },
|
||||
waitForResponse: false
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
// With waitForResponse: false, may return immediately
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// PUT Method Tests
|
||||
// ======================================================================
|
||||
|
||||
describe('PUT Method', () => {
|
||||
it('should trigger PUT webhook with update data', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.put,
|
||||
httpMethod: 'PUT',
|
||||
data: {
|
||||
id: '123',
|
||||
updatedField: 'new value',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger PUT webhook with custom headers', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.put,
|
||||
httpMethod: 'PUT',
|
||||
data: { update: true },
|
||||
headers: {
|
||||
'X-Update-Operation': 'modify',
|
||||
'If-Match': 'etag-value'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger PUT webhook without data', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.put,
|
||||
httpMethod: 'PUT'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// DELETE Method Tests
|
||||
// ======================================================================
|
||||
|
||||
describe('DELETE Method', () => {
|
||||
it('should trigger DELETE webhook with query parameters', async () => {
|
||||
const urlWithParams = `${webhookUrls.delete}?id=123&reason=test`;
|
||||
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: urlWithParams,
|
||||
httpMethod: 'DELETE'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger DELETE webhook with custom headers', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.delete,
|
||||
httpMethod: 'DELETE',
|
||||
headers: {
|
||||
'X-Delete-Reason': 'cleanup',
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should trigger DELETE webhook without parameters', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.delete,
|
||||
httpMethod: 'DELETE'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle invalid webhook URL', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: 'https://invalid-url.example.com/webhook/nonexistent',
|
||||
httpMethod: 'GET'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle malformed webhook URL', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: 'not-a-valid-url',
|
||||
httpMethod: 'GET'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle missing webhook URL', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
httpMethod: 'GET'
|
||||
} as any,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid HTTP method', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.get,
|
||||
httpMethod: 'INVALID' as any
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Default Method (POST)
|
||||
// ======================================================================
|
||||
|
||||
describe('Default Method Behavior', () => {
|
||||
it('should default to POST method when not specified', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.post,
|
||||
data: { defaultMethod: true }
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete webhook response structure', async () => {
|
||||
const response = await handleTriggerWebhookWorkflow(
|
||||
{
|
||||
webhookUrl: webhookUrls.get,
|
||||
httpMethod: 'GET',
|
||||
waitForResponse: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.message).toBeDefined();
|
||||
expect(response.message).toContain('Webhook triggered successfully');
|
||||
|
||||
// Response data should be defined (either workflow output or execution info)
|
||||
expect(typeof response.data).not.toBe('undefined');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
tests/integration/n8n-api/scripts/cleanup-orphans.ts
Normal file
43
tests/integration/n8n-api/scripts/cleanup-orphans.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Cleanup Orphaned Test Resources
|
||||
*
|
||||
* Standalone script to clean up orphaned workflows and executions
|
||||
* from failed test runs. Run this periodically in CI or manually
|
||||
* to maintain a clean test environment.
|
||||
*
|
||||
* Usage:
|
||||
* npm run test:cleanup:orphans
|
||||
* tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts
|
||||
*/
|
||||
|
||||
import { cleanupAllTestResources } from '../utils/cleanup-helpers';
|
||||
import { getN8nCredentials, validateCredentials } from '../utils/credentials';
|
||||
|
||||
async function main() {
|
||||
console.log('Starting cleanup of orphaned test resources...\n');
|
||||
|
||||
try {
|
||||
// Validate credentials
|
||||
const creds = getN8nCredentials();
|
||||
validateCredentials(creds);
|
||||
|
||||
console.log(`n8n Instance: ${creds.url}`);
|
||||
console.log(`Cleanup Tag: ${creds.cleanup.tag}`);
|
||||
console.log(`Cleanup Prefix: ${creds.cleanup.namePrefix}\n`);
|
||||
|
||||
// Run cleanup
|
||||
const result = await cleanupAllTestResources();
|
||||
|
||||
console.log('\n✅ Cleanup complete!');
|
||||
console.log(` Workflows deleted: ${result.workflows}`);
|
||||
console.log(` Executions deleted: ${result.executions}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Cleanup failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
270
tests/integration/n8n-api/system/diagnostic.test.ts
Normal file
270
tests/integration/n8n-api/system/diagnostic.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Integration Tests: handleDiagnostic
|
||||
*
|
||||
* Tests system diagnostic functionality.
|
||||
* Covers environment checks, API status, and verbose mode.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleDiagnostic } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { DiagnosticResponse } from '../utils/response-types';
|
||||
|
||||
describe('Integration: handleDiagnostic', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Basic Diagnostic
|
||||
// ======================================================================
|
||||
|
||||
describe('Basic Diagnostic', () => {
|
||||
it('should run basic diagnostic check', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
// Verify core diagnostic fields
|
||||
expect(data).toHaveProperty('timestamp');
|
||||
expect(data).toHaveProperty('environment');
|
||||
expect(data).toHaveProperty('apiConfiguration');
|
||||
expect(data).toHaveProperty('toolsAvailability');
|
||||
expect(data).toHaveProperty('troubleshooting');
|
||||
|
||||
// Verify timestamp format
|
||||
expect(typeof data.timestamp).toBe('string');
|
||||
const timestamp = new Date(data.timestamp);
|
||||
expect(timestamp.toString()).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('should include environment variables', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
expect(data.environment).toBeDefined();
|
||||
expect(data.environment).toHaveProperty('N8N_API_URL');
|
||||
expect(data.environment).toHaveProperty('N8N_API_KEY');
|
||||
expect(data.environment).toHaveProperty('NODE_ENV');
|
||||
expect(data.environment).toHaveProperty('MCP_MODE');
|
||||
|
||||
// API key should be masked
|
||||
if (data.environment.N8N_API_KEY) {
|
||||
expect(data.environment.N8N_API_KEY).toBe('***configured***');
|
||||
}
|
||||
});
|
||||
|
||||
it('should check API configuration and connectivity', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
expect(data.apiConfiguration).toBeDefined();
|
||||
expect(data.apiConfiguration).toHaveProperty('configured');
|
||||
expect(data.apiConfiguration).toHaveProperty('status');
|
||||
|
||||
// In test environment, API should be configured
|
||||
expect(data.apiConfiguration.configured).toBe(true);
|
||||
|
||||
// Verify API status
|
||||
const status = data.apiConfiguration.status;
|
||||
expect(status).toHaveProperty('configured');
|
||||
expect(status).toHaveProperty('connected');
|
||||
|
||||
// Should successfully connect to n8n API
|
||||
expect(status.connected).toBe(true);
|
||||
|
||||
// If connected, should have version info
|
||||
if (status.connected) {
|
||||
expect(status).toHaveProperty('version');
|
||||
}
|
||||
|
||||
// Config details should be present when configured
|
||||
if (data.apiConfiguration.configured) {
|
||||
expect(data.apiConfiguration).toHaveProperty('config');
|
||||
expect(data.apiConfiguration.config).toHaveProperty('baseUrl');
|
||||
expect(data.apiConfiguration.config).toHaveProperty('timeout');
|
||||
expect(data.apiConfiguration.config).toHaveProperty('maxRetries');
|
||||
}
|
||||
});
|
||||
|
||||
it('should report tools availability', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
expect(data.toolsAvailability).toBeDefined();
|
||||
expect(data.toolsAvailability).toHaveProperty('documentationTools');
|
||||
expect(data.toolsAvailability).toHaveProperty('managementTools');
|
||||
expect(data.toolsAvailability).toHaveProperty('totalAvailable');
|
||||
|
||||
// Documentation tools should always be available
|
||||
const docTools = data.toolsAvailability.documentationTools;
|
||||
expect(docTools.count).toBeGreaterThan(0);
|
||||
expect(docTools.enabled).toBe(true);
|
||||
expect(docTools.description).toBeDefined();
|
||||
|
||||
// Management tools should be available when API configured
|
||||
const mgmtTools = data.toolsAvailability.managementTools;
|
||||
expect(mgmtTools).toHaveProperty('count');
|
||||
expect(mgmtTools).toHaveProperty('enabled');
|
||||
expect(mgmtTools).toHaveProperty('description');
|
||||
|
||||
// In test environment, management tools should be enabled
|
||||
expect(mgmtTools.enabled).toBe(true);
|
||||
expect(mgmtTools.count).toBeGreaterThan(0);
|
||||
|
||||
// Total should be sum of both
|
||||
expect(data.toolsAvailability.totalAvailable).toBe(
|
||||
docTools.count + mgmtTools.count
|
||||
);
|
||||
});
|
||||
|
||||
it('should include troubleshooting information', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
expect(data.troubleshooting).toBeDefined();
|
||||
expect(data.troubleshooting).toHaveProperty('steps');
|
||||
expect(data.troubleshooting).toHaveProperty('documentation');
|
||||
|
||||
// Troubleshooting steps should be an array
|
||||
expect(Array.isArray(data.troubleshooting.steps)).toBe(true);
|
||||
expect(data.troubleshooting.steps.length).toBeGreaterThan(0);
|
||||
|
||||
// Documentation link should be present
|
||||
expect(typeof data.troubleshooting.documentation).toBe('string');
|
||||
expect(data.troubleshooting.documentation).toContain('https://');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Verbose Mode
|
||||
// ======================================================================
|
||||
|
||||
describe('Verbose Mode', () => {
|
||||
it('should include additional debug info in verbose mode', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: { verbose: true } } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
// Verbose mode should add debug section
|
||||
expect(data).toHaveProperty('debug');
|
||||
expect(data.debug).toBeDefined();
|
||||
|
||||
// Verify debug information
|
||||
expect(data.debug).toBeDefined();
|
||||
expect(data.debug).toHaveProperty('processEnv');
|
||||
expect(data.debug).toHaveProperty('nodeVersion');
|
||||
expect(data.debug).toHaveProperty('platform');
|
||||
expect(data.debug).toHaveProperty('workingDirectory');
|
||||
|
||||
// Process env should list relevant environment variables
|
||||
expect(Array.isArray(data.debug?.processEnv)).toBe(true);
|
||||
|
||||
// Node version should be a string
|
||||
expect(typeof data.debug?.nodeVersion).toBe('string');
|
||||
expect(data.debug?.nodeVersion).toMatch(/^v\d+\.\d+\.\d+/);
|
||||
|
||||
// Platform should be a string (linux, darwin, win32, etc.)
|
||||
expect(typeof data.debug?.platform).toBe('string');
|
||||
expect(data.debug && data.debug.platform.length).toBeGreaterThan(0);
|
||||
|
||||
// Working directory should be a path
|
||||
expect(typeof data.debug?.workingDirectory).toBe('string');
|
||||
expect(data.debug && data.debug.workingDirectory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not include debug info when verbose is false', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: { verbose: false } } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
// Debug section should not be present
|
||||
expect(data.debug).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include debug info by default', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
// Debug section should not be present when verbose not specified
|
||||
expect(data.debug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete diagnostic response structure', async () => {
|
||||
const response = await handleDiagnostic(
|
||||
{ params: { arguments: {} } },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as DiagnosticResponse;
|
||||
|
||||
// Verify all required fields
|
||||
const requiredFields = [
|
||||
'timestamp',
|
||||
'environment',
|
||||
'apiConfiguration',
|
||||
'toolsAvailability',
|
||||
'troubleshooting'
|
||||
];
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
expect(data).toHaveProperty(field);
|
||||
expect(data[field]).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify data types
|
||||
expect(typeof data.timestamp).toBe('string');
|
||||
expect(typeof data.environment).toBe('object');
|
||||
expect(typeof data.apiConfiguration).toBe('object');
|
||||
expect(typeof data.toolsAvailability).toBe('object');
|
||||
expect(typeof data.troubleshooting).toBe('object');
|
||||
});
|
||||
});
|
||||
});
|
||||
110
tests/integration/n8n-api/system/health-check.test.ts
Normal file
110
tests/integration/n8n-api/system/health-check.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Integration Tests: handleHealthCheck
|
||||
*
|
||||
* Tests API health check against a real n8n instance.
|
||||
* Covers connectivity verification and feature availability.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleHealthCheck } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { HealthCheckResponse } from '../utils/response-types';
|
||||
|
||||
describe('Integration: handleHealthCheck', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Successful Health Check
|
||||
// ======================================================================
|
||||
|
||||
describe('API Available', () => {
|
||||
it('should successfully check n8n API health', async () => {
|
||||
const response = await handleHealthCheck(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as HealthCheckResponse;
|
||||
|
||||
// Verify required fields
|
||||
expect(data).toHaveProperty('status');
|
||||
expect(data).toHaveProperty('apiUrl');
|
||||
expect(data).toHaveProperty('mcpVersion');
|
||||
|
||||
// Status should be a string (e.g., "ok", "healthy")
|
||||
if (data.status) {
|
||||
expect(typeof data.status).toBe('string');
|
||||
}
|
||||
|
||||
// API URL should match configuration
|
||||
expect(data.apiUrl).toBeDefined();
|
||||
expect(typeof data.apiUrl).toBe('string');
|
||||
|
||||
// MCP version should be defined
|
||||
expect(data.mcpVersion).toBeDefined();
|
||||
expect(typeof data.mcpVersion).toBe('string');
|
||||
});
|
||||
|
||||
it('should include feature availability information', async () => {
|
||||
const response = await handleHealthCheck(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as HealthCheckResponse;
|
||||
|
||||
// Check for feature information
|
||||
// Note: Features may vary by n8n instance configuration
|
||||
if (data.features) {
|
||||
expect(typeof data.features).toBe('object');
|
||||
}
|
||||
|
||||
// Check for version information
|
||||
if (data.n8nVersion) {
|
||||
expect(typeof data.n8nVersion).toBe('string');
|
||||
}
|
||||
|
||||
if (data.supportedN8nVersion) {
|
||||
expect(typeof data.supportedN8nVersion).toBe('string');
|
||||
}
|
||||
|
||||
// Should include version note for AI agents
|
||||
if (data.versionNote) {
|
||||
expect(typeof data.versionNote).toBe('string');
|
||||
expect(data.versionNote).toContain('version');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete health check response structure', async () => {
|
||||
const response = await handleHealthCheck(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as HealthCheckResponse;
|
||||
|
||||
// Verify all expected fields are present
|
||||
const expectedFields = ['status', 'apiUrl', 'mcpVersion'];
|
||||
expectedFields.forEach(field => {
|
||||
expect(data).toHaveProperty(field);
|
||||
});
|
||||
|
||||
// Optional fields that may be present
|
||||
const optionalFields = ['instanceId', 'n8nVersion', 'features', 'supportedN8nVersion', 'versionNote'];
|
||||
optionalFields.forEach(field => {
|
||||
if (data[field] !== undefined) {
|
||||
expect(data[field]).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
208
tests/integration/n8n-api/system/list-tools.test.ts
Normal file
208
tests/integration/n8n-api/system/list-tools.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Integration Tests: handleListAvailableTools
|
||||
*
|
||||
* Tests tool listing functionality.
|
||||
* Covers tool discovery and configuration status.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleListAvailableTools } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { ListToolsResponse } from '../utils/response-types';
|
||||
|
||||
describe('Integration: handleListAvailableTools', () => {
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// List All Tools
|
||||
// ======================================================================
|
||||
|
||||
describe('Tool Listing', () => {
|
||||
it('should list all available tools organized by category', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
// Verify tools array exists
|
||||
expect(data).toHaveProperty('tools');
|
||||
expect(Array.isArray(data.tools)).toBe(true);
|
||||
expect(data.tools.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify tool categories
|
||||
const categories = data.tools.map((cat: any) => cat.category);
|
||||
expect(categories).toContain('Workflow Management');
|
||||
expect(categories).toContain('Execution Management');
|
||||
expect(categories).toContain('System');
|
||||
|
||||
// Verify each category has tools
|
||||
data.tools.forEach(category => {
|
||||
expect(category).toHaveProperty('category');
|
||||
expect(category).toHaveProperty('tools');
|
||||
expect(Array.isArray(category.tools)).toBe(true);
|
||||
expect(category.tools.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify each tool has required fields
|
||||
category.tools.forEach(tool => {
|
||||
expect(tool).toHaveProperty('name');
|
||||
expect(tool).toHaveProperty('description');
|
||||
expect(typeof tool.name).toBe('string');
|
||||
expect(typeof tool.description).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should include API configuration status', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
// Verify configuration status
|
||||
expect(data).toHaveProperty('apiConfigured');
|
||||
expect(typeof data.apiConfigured).toBe('boolean');
|
||||
|
||||
// Since tests run with API configured, should be true
|
||||
expect(data.apiConfigured).toBe(true);
|
||||
|
||||
// Verify configuration details are present when configured
|
||||
if (data.apiConfigured) {
|
||||
expect(data).toHaveProperty('configuration');
|
||||
expect(data.configuration).toBeDefined();
|
||||
expect(data.configuration).toHaveProperty('apiUrl');
|
||||
expect(data.configuration).toHaveProperty('timeout');
|
||||
expect(data.configuration).toHaveProperty('maxRetries');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include API limitations information', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
// Verify limitations are documented
|
||||
expect(data).toHaveProperty('limitations');
|
||||
expect(Array.isArray(data.limitations)).toBe(true);
|
||||
expect(data.limitations.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify limitations are informative strings
|
||||
data.limitations.forEach(limitation => {
|
||||
expect(typeof limitation).toBe('string');
|
||||
expect(limitation.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Common known limitations
|
||||
const limitationsText = data.limitations.join(' ');
|
||||
expect(limitationsText).toContain('Cannot activate');
|
||||
expect(limitationsText).toContain('Cannot execute workflows directly');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Workflow Management Tools
|
||||
// ======================================================================
|
||||
|
||||
describe('Workflow Management Tools', () => {
|
||||
it('should include all workflow management tools', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
const workflowCategory = data.tools.find(cat => cat.category === 'Workflow Management');
|
||||
expect(workflowCategory).toBeDefined();
|
||||
|
||||
const toolNames = workflowCategory!.tools.map(t => t.name);
|
||||
|
||||
// Core workflow tools
|
||||
expect(toolNames).toContain('n8n_create_workflow');
|
||||
expect(toolNames).toContain('n8n_get_workflow');
|
||||
expect(toolNames).toContain('n8n_update_workflow');
|
||||
expect(toolNames).toContain('n8n_delete_workflow');
|
||||
expect(toolNames).toContain('n8n_list_workflows');
|
||||
|
||||
// Enhanced workflow tools
|
||||
expect(toolNames).toContain('n8n_get_workflow_details');
|
||||
expect(toolNames).toContain('n8n_get_workflow_structure');
|
||||
expect(toolNames).toContain('n8n_get_workflow_minimal');
|
||||
expect(toolNames).toContain('n8n_validate_workflow');
|
||||
expect(toolNames).toContain('n8n_autofix_workflow');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Execution Management Tools
|
||||
// ======================================================================
|
||||
|
||||
describe('Execution Management Tools', () => {
|
||||
it('should include all execution management tools', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
const executionCategory = data.tools.find(cat => cat.category === 'Execution Management');
|
||||
expect(executionCategory).toBeDefined();
|
||||
|
||||
const toolNames = executionCategory!.tools.map(t => t.name);
|
||||
|
||||
expect(toolNames).toContain('n8n_trigger_webhook_workflow');
|
||||
expect(toolNames).toContain('n8n_get_execution');
|
||||
expect(toolNames).toContain('n8n_list_executions');
|
||||
expect(toolNames).toContain('n8n_delete_execution');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// System Tools
|
||||
// ======================================================================
|
||||
|
||||
describe('System Tools', () => {
|
||||
it('should include system tools', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
const systemCategory = data.tools.find(cat => cat.category === 'System');
|
||||
expect(systemCategory).toBeDefined();
|
||||
|
||||
const toolNames = systemCategory!.tools.map(t => t.name);
|
||||
|
||||
expect(toolNames).toContain('n8n_health_check');
|
||||
expect(toolNames).toContain('n8n_list_available_tools');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete tool list response structure', async () => {
|
||||
const response = await handleListAvailableTools(mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as ListToolsResponse;
|
||||
|
||||
// Verify all required fields
|
||||
expect(data).toHaveProperty('tools');
|
||||
expect(data).toHaveProperty('apiConfigured');
|
||||
expect(data).toHaveProperty('limitations');
|
||||
|
||||
// Verify optional configuration field
|
||||
if (data.apiConfigured) {
|
||||
expect(data).toHaveProperty('configuration');
|
||||
}
|
||||
|
||||
// Verify data types
|
||||
expect(Array.isArray(data.tools)).toBe(true);
|
||||
expect(typeof data.apiConfigured).toBe('boolean');
|
||||
expect(Array.isArray(data.limitations)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
tests/integration/n8n-api/test-connection.ts
Normal file
34
tests/integration/n8n-api/test-connection.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Quick test script to verify n8n API connection
|
||||
*/
|
||||
|
||||
import { getN8nCredentials } from './utils/credentials';
|
||||
import { getTestN8nClient } from './utils/n8n-client';
|
||||
|
||||
async function testConnection() {
|
||||
try {
|
||||
console.log('Loading credentials...');
|
||||
const creds = getN8nCredentials();
|
||||
console.log('Credentials loaded:', {
|
||||
url: creds.url,
|
||||
hasApiKey: !!creds.apiKey,
|
||||
apiKeyLength: creds.apiKey?.length
|
||||
});
|
||||
|
||||
console.log('\nCreating n8n client...');
|
||||
const client = getTestN8nClient();
|
||||
console.log('Client created successfully');
|
||||
|
||||
console.log('\nTesting health check...');
|
||||
const health = await client.healthCheck();
|
||||
console.log('Health check result:', health);
|
||||
|
||||
console.log('\n✅ Connection test passed!');
|
||||
} catch (error) {
|
||||
console.error('❌ Connection test failed:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
||||
72
tests/integration/n8n-api/types/mcp-responses.ts
Normal file
72
tests/integration/n8n-api/types/mcp-responses.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* TypeScript interfaces for MCP handler responses
|
||||
*
|
||||
* These interfaces provide type safety for integration tests,
|
||||
* replacing unsafe `as any` casts with proper type definitions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Workflow validation response from handleValidateWorkflow
|
||||
*/
|
||||
export interface ValidationResponse {
|
||||
valid: boolean;
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
summary: {
|
||||
totalNodes: number;
|
||||
enabledNodes: number;
|
||||
triggerNodes: number;
|
||||
validConnections?: number;
|
||||
invalidConnections?: number;
|
||||
expressionsValidated?: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
errors?: Array<{
|
||||
node: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}>;
|
||||
warnings?: Array<{
|
||||
node: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow autofix response from handleAutofixWorkflow
|
||||
*/
|
||||
export interface AutofixResponse {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
preview?: boolean;
|
||||
fixesAvailable?: number;
|
||||
fixesApplied?: number;
|
||||
fixes?: Array<{
|
||||
type: 'expression-format' | 'typeversion-correction' | 'error-output-config' | 'node-type-correction' | 'webhook-missing-path';
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
nodeName?: string;
|
||||
nodeId?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}>;
|
||||
summary?: {
|
||||
totalFixes: number;
|
||||
byType: Record<string, number>;
|
||||
byConfidence: Record<string, number>;
|
||||
};
|
||||
stats?: {
|
||||
expressionFormat?: number;
|
||||
typeVersionCorrection?: number;
|
||||
errorOutputConfig?: number;
|
||||
nodeTypeCorrection?: number;
|
||||
webhookMissingPath?: number;
|
||||
};
|
||||
message?: string;
|
||||
validationSummary?: {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
};
|
||||
}
|
||||
308
tests/integration/n8n-api/utils/cleanup-helpers.ts
Normal file
308
tests/integration/n8n-api/utils/cleanup-helpers.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Cleanup Helpers for Integration Tests
|
||||
*
|
||||
* Provides multi-level cleanup strategies for test resources:
|
||||
* - Orphaned workflows (from failed test runs)
|
||||
* - Old executions (older than 24 hours)
|
||||
* - Bulk cleanup by tag or name prefix
|
||||
*/
|
||||
|
||||
import { getTestN8nClient } from './n8n-client';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
import { Logger } from '../../../../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[Cleanup]' });
|
||||
|
||||
/**
|
||||
* Clean up orphaned test workflows
|
||||
*
|
||||
* Finds and deletes all workflows tagged with the test tag or
|
||||
* prefixed with the test name prefix. Run this periodically in CI
|
||||
* to clean up failed test runs.
|
||||
*
|
||||
* @returns Array of deleted workflow IDs
|
||||
*/
|
||||
export async function cleanupOrphanedWorkflows(): Promise<string[]> {
|
||||
const creds = getN8nCredentials();
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info('Searching for orphaned test workflows...');
|
||||
|
||||
let allWorkflows: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
// Fetch all workflows with pagination
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error('Pagination safety limit exceeded while fetching workflows');
|
||||
}
|
||||
|
||||
logger.debug(`Fetching workflows page ${pageCount}...`);
|
||||
|
||||
const response = await client.listWorkflows({
|
||||
cursor,
|
||||
limit: 100,
|
||||
excludePinnedData: true
|
||||
});
|
||||
|
||||
allWorkflows.push(...response.data);
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(`Found ${allWorkflows.length} total workflows across ${pageCount} page(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflows:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Pre-activated webhook workflow that should NOT be deleted
|
||||
// This is needed for webhook trigger integration tests
|
||||
// Note: Single webhook accepts all HTTP methods (GET, POST, PUT, DELETE)
|
||||
const preservedWorkflowNames = new Set([
|
||||
'[MCP-TEST] Webhook All Methods'
|
||||
]);
|
||||
|
||||
// Find test workflows but exclude pre-activated webhook workflows
|
||||
const testWorkflows = allWorkflows.filter(w => {
|
||||
const isTestWorkflow = w.tags?.includes(creds.cleanup.tag) || w.name?.startsWith(creds.cleanup.namePrefix);
|
||||
const isPreserved = preservedWorkflowNames.has(w.name);
|
||||
|
||||
return isTestWorkflow && !isPreserved;
|
||||
});
|
||||
|
||||
logger.info(`Found ${testWorkflows.length} orphaned test workflow(s) (excluding ${preservedWorkflowNames.size} preserved webhook workflow)`);
|
||||
|
||||
if (testWorkflows.length === 0) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Delete them
|
||||
for (const workflow of testWorkflows) {
|
||||
try {
|
||||
await client.deleteWorkflow(workflow.id);
|
||||
deleted.push(workflow.id);
|
||||
logger.debug(`Deleted orphaned workflow: ${workflow.name} (${workflow.id})`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete workflow ${workflow.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} orphaned workflow(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old executions
|
||||
*
|
||||
* Deletes executions older than the specified age.
|
||||
*
|
||||
* @param maxAgeMs - Maximum age in milliseconds (default: 24 hours)
|
||||
* @returns Array of deleted execution IDs
|
||||
*/
|
||||
export async function cleanupOldExecutions(
|
||||
maxAgeMs: number = 24 * 60 * 60 * 1000
|
||||
): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for executions older than ${maxAgeMs}ms...`);
|
||||
|
||||
let allExecutions: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
// Fetch all executions
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error('Pagination safety limit exceeded while fetching executions');
|
||||
}
|
||||
|
||||
logger.debug(`Fetching executions page ${pageCount}...`);
|
||||
|
||||
const response = await client.listExecutions({
|
||||
cursor,
|
||||
limit: 100,
|
||||
includeData: false
|
||||
});
|
||||
|
||||
allExecutions.push(...response.data);
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(`Found ${allExecutions.length} total executions across ${pageCount} page(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch executions:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - maxAgeMs;
|
||||
const oldExecutions = allExecutions.filter(e => {
|
||||
const executionTime = new Date(e.startedAt).getTime();
|
||||
return executionTime < cutoffTime;
|
||||
});
|
||||
|
||||
logger.info(`Found ${oldExecutions.length} old execution(s)`);
|
||||
|
||||
if (oldExecutions.length === 0) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
for (const execution of oldExecutions) {
|
||||
try {
|
||||
await client.deleteExecution(execution.id);
|
||||
deleted.push(execution.id);
|
||||
logger.debug(`Deleted old execution: ${execution.id}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete execution ${execution.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} old execution(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all test resources
|
||||
*
|
||||
* Combines cleanupOrphanedWorkflows and cleanupOldExecutions.
|
||||
* Use this as a comprehensive cleanup in CI.
|
||||
*
|
||||
* @returns Object with counts of deleted resources
|
||||
*/
|
||||
export async function cleanupAllTestResources(): Promise<{
|
||||
workflows: number;
|
||||
executions: number;
|
||||
}> {
|
||||
logger.info('Starting comprehensive test resource cleanup...');
|
||||
|
||||
const [workflowIds, executionIds] = await Promise.all([
|
||||
cleanupOrphanedWorkflows(),
|
||||
cleanupOldExecutions()
|
||||
]);
|
||||
|
||||
logger.info(
|
||||
`Cleanup complete: ${workflowIds.length} workflows, ${executionIds.length} executions`
|
||||
);
|
||||
|
||||
return {
|
||||
workflows: workflowIds.length,
|
||||
executions: executionIds.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflows by tag
|
||||
*
|
||||
* Deletes all workflows with the specified tag.
|
||||
*
|
||||
* @param tag - Tag to match
|
||||
* @returns Array of deleted workflow IDs
|
||||
*/
|
||||
export async function cleanupWorkflowsByTag(tag: string): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for workflows with tag: ${tag}`);
|
||||
|
||||
try {
|
||||
const response = await client.listWorkflows({
|
||||
tags: tag || undefined,
|
||||
limit: 100,
|
||||
excludePinnedData: true
|
||||
});
|
||||
|
||||
const workflows = response.data;
|
||||
logger.info(`Found ${workflows.length} workflow(s) with tag: ${tag}`);
|
||||
|
||||
for (const workflow of workflows) {
|
||||
if (!workflow.id) continue;
|
||||
|
||||
try {
|
||||
await client.deleteWorkflow(workflow.id);
|
||||
deleted.push(workflow.id);
|
||||
logger.debug(`Deleted workflow: ${workflow.name} (${workflow.id})`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete workflow ${workflow.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} workflow(s)`);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup workflows by tag: ${tag}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete executions for a specific workflow
|
||||
*
|
||||
* @param workflowId - Workflow ID
|
||||
* @returns Array of deleted execution IDs
|
||||
*/
|
||||
export async function cleanupExecutionsByWorkflow(
|
||||
workflowId: string
|
||||
): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for executions of workflow: ${workflowId}`);
|
||||
|
||||
let cursor: string | undefined;
|
||||
let totalCount = 0;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error(`Pagination safety limit exceeded while fetching executions for workflow ${workflowId}`);
|
||||
}
|
||||
|
||||
const response = await client.listExecutions({
|
||||
workflowId,
|
||||
cursor,
|
||||
limit: 100,
|
||||
includeData: false
|
||||
});
|
||||
|
||||
const executions = response.data;
|
||||
totalCount += executions.length;
|
||||
|
||||
for (const execution of executions) {
|
||||
try {
|
||||
await client.deleteExecution(execution.id);
|
||||
deleted.push(execution.id);
|
||||
logger.debug(`Deleted execution: ${execution.id}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete execution ${execution.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(
|
||||
`Successfully deleted ${deleted.length}/${totalCount} execution(s) for workflow ${workflowId}`
|
||||
);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup executions for workflow: ${workflowId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
195
tests/integration/n8n-api/utils/credentials.ts
Normal file
195
tests/integration/n8n-api/utils/credentials.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Integration Test Credentials Management
|
||||
*
|
||||
* Provides environment-aware credential loading for integration tests.
|
||||
* - Local development: Reads from .env file
|
||||
* - CI/GitHub Actions: Uses GitHub secrets from process.env
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env file for local development
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
export interface N8nTestCredentials {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
webhookUrls: {
|
||||
get: string;
|
||||
post: string;
|
||||
put: string;
|
||||
delete: string;
|
||||
};
|
||||
cleanup: {
|
||||
enabled: boolean;
|
||||
tag: string;
|
||||
namePrefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get n8n credentials for integration tests
|
||||
*
|
||||
* Automatically detects environment (local vs CI) and loads
|
||||
* credentials from the appropriate source.
|
||||
*
|
||||
* @returns N8nTestCredentials
|
||||
* @throws Error if required credentials are missing
|
||||
*/
|
||||
export function getN8nCredentials(): N8nTestCredentials {
|
||||
if (process.env.CI) {
|
||||
// CI: Use GitHub secrets - validate required variables first
|
||||
const url = process.env.N8N_API_URL;
|
||||
const apiKey = process.env.N8N_API_KEY;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
throw new Error(
|
||||
'Missing required CI credentials:\n' +
|
||||
` N8N_API_URL: ${url ? 'set' : 'MISSING'}\n` +
|
||||
` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n` +
|
||||
'Please configure GitHub secrets for integration tests.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
apiKey,
|
||||
webhookUrls: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_URL || '',
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_URL || '',
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '',
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || ''
|
||||
},
|
||||
cleanup: {
|
||||
enabled: true,
|
||||
tag: 'mcp-integration-test',
|
||||
namePrefix: '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Local: Use .env file - validate required variables first
|
||||
const url = process.env.N8N_API_URL;
|
||||
const apiKey = process.env.N8N_API_KEY;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
throw new Error(
|
||||
'Missing required credentials in .env:\n' +
|
||||
` N8N_API_URL: ${url ? 'set' : 'MISSING'}\n` +
|
||||
` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n\n` +
|
||||
'Please add these to your .env file.\n' +
|
||||
'See .env.example for configuration details.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
apiKey,
|
||||
webhookUrls: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_URL || '',
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_URL || '',
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '',
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || ''
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.N8N_TEST_CLEANUP_ENABLED !== 'false',
|
||||
tag: process.env.N8N_TEST_TAG || 'mcp-integration-test',
|
||||
namePrefix: process.env.N8N_TEST_NAME_PREFIX || '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required credentials are present
|
||||
*
|
||||
* @param creds - Credentials to validate
|
||||
* @throws Error if required credentials are missing
|
||||
*/
|
||||
export function validateCredentials(creds: N8nTestCredentials): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!creds.url) {
|
||||
missing.push(process.env.CI ? 'N8N_URL' : 'N8N_API_URL');
|
||||
}
|
||||
if (!creds.apiKey) {
|
||||
missing.push('N8N_API_KEY');
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required n8n credentials: ${missing.join(', ')}\n\n` +
|
||||
`Please set the following environment variables:\n` +
|
||||
missing.map(v => ` ${v}`).join('\n') + '\n\n' +
|
||||
`See .env.example for configuration details.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that webhook URLs are configured
|
||||
*
|
||||
* @param creds - Credentials to validate
|
||||
* @throws Error with setup instructions if webhook URLs are missing
|
||||
*/
|
||||
export function validateWebhookUrls(creds: N8nTestCredentials): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!creds.webhookUrls.get) missing.push('GET');
|
||||
if (!creds.webhookUrls.post) missing.push('POST');
|
||||
if (!creds.webhookUrls.put) missing.push('PUT');
|
||||
if (!creds.webhookUrls.delete) missing.push('DELETE');
|
||||
|
||||
if (missing.length > 0) {
|
||||
const envVars = missing.map(m => `N8N_TEST_WEBHOOK_${m}_URL`);
|
||||
|
||||
throw new Error(
|
||||
`Missing webhook URLs for HTTP methods: ${missing.join(', ')}\n\n` +
|
||||
`Webhook testing requires pre-activated workflows in n8n.\n` +
|
||||
`n8n API doesn't support workflow activation, so these must be created manually.\n\n` +
|
||||
`Setup Instructions:\n` +
|
||||
`1. Create ${missing.length} workflow(s) in your n8n instance\n` +
|
||||
`2. Each workflow should have a single Webhook node\n` +
|
||||
`3. Configure webhook paths:\n` +
|
||||
missing.map(m => ` - ${m}: mcp-test-${m.toLowerCase()}`).join('\n') + '\n' +
|
||||
`4. ACTIVATE each workflow in n8n UI\n` +
|
||||
`5. Set the following environment variables with full webhook URLs:\n` +
|
||||
envVars.map(v => ` ${v}=<full-webhook-url>`).join('\n') + '\n\n' +
|
||||
`Example: N8N_TEST_WEBHOOK_GET_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-get\n\n` +
|
||||
`See docs/local/integration-testing-plan.md for detailed instructions.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials are configured (non-throwing version)
|
||||
*
|
||||
* @returns true if basic credentials are available
|
||||
*/
|
||||
export function hasCredentials(): boolean {
|
||||
try {
|
||||
const creds = getN8nCredentials();
|
||||
return !!(creds.url && creds.apiKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if webhook URLs are configured (non-throwing version)
|
||||
*
|
||||
* @returns true if all webhook URLs are available
|
||||
*/
|
||||
export function hasWebhookUrls(): boolean {
|
||||
try {
|
||||
const creds = getN8nCredentials();
|
||||
return !!(
|
||||
creds.webhookUrls.get &&
|
||||
creds.webhookUrls.post &&
|
||||
creds.webhookUrls.put &&
|
||||
creds.webhookUrls.delete
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
326
tests/integration/n8n-api/utils/factories.ts
Normal file
326
tests/integration/n8n-api/utils/factories.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Test Data Factories
|
||||
*
|
||||
* Provides factory functions for generating test data dynamically.
|
||||
* Useful for creating variations of workflows, nodes, and parameters.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
import { createTestWorkflowName } from './test-context';
|
||||
|
||||
/**
|
||||
* Create a webhook node with custom parameters
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createWebhookNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path?: string;
|
||||
position?: [number, number];
|
||||
responseMode?: 'onReceived' | 'lastNode';
|
||||
}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `webhook-${Date.now()}`,
|
||||
name: options.name || 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: options.position || [250, 300],
|
||||
parameters: {
|
||||
httpMethod: options.httpMethod || 'GET',
|
||||
path: options.path || `test-${Date.now()}`,
|
||||
responseMode: options.responseMode || 'lastNode'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP Request node with custom parameters
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createHttpRequestNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
position?: [number, number];
|
||||
authentication?: string;
|
||||
}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `http-${Date.now()}`,
|
||||
name: options.name || 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: options.position || [450, 300],
|
||||
parameters: {
|
||||
url: options.url || 'https://httpbin.org/get',
|
||||
method: options.method || 'GET',
|
||||
authentication: options.authentication || 'none'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Set node with custom assignments
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createSetNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
position?: [number, number];
|
||||
assignments?: Array<{
|
||||
name: string;
|
||||
value: any;
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
}>;
|
||||
}): WorkflowNode {
|
||||
const assignments = options.assignments || [
|
||||
{ name: 'key', value: 'value', type: 'string' as const }
|
||||
];
|
||||
|
||||
return {
|
||||
id: options.id || `set-${Date.now()}`,
|
||||
name: options.name || 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: options.position || [450, 300],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: assignments.map((a, idx) => ({
|
||||
id: `assign-${idx}`,
|
||||
name: a.name,
|
||||
value: a.value,
|
||||
type: a.type || 'string'
|
||||
}))
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Manual Trigger node
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createManualTriggerNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
position?: [number, number];
|
||||
} = {}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `manual-${Date.now()}`,
|
||||
name: options.name || 'When clicking "Test workflow"',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: options.position || [250, 300],
|
||||
parameters: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple connection between two nodes
|
||||
*
|
||||
* @param from - Source node name
|
||||
* @param to - Target node name
|
||||
* @param options - Connection options
|
||||
* @returns Connection object
|
||||
*/
|
||||
export function createConnection(
|
||||
from: string,
|
||||
to: string,
|
||||
options: {
|
||||
sourceOutput?: string;
|
||||
targetInput?: string;
|
||||
sourceIndex?: number;
|
||||
targetIndex?: number;
|
||||
} = {}
|
||||
): Record<string, any> {
|
||||
const sourceOutput = options.sourceOutput || 'main';
|
||||
const targetInput = options.targetInput || 'main';
|
||||
const sourceIndex = options.sourceIndex || 0;
|
||||
const targetIndex = options.targetIndex || 0;
|
||||
|
||||
return {
|
||||
[from]: {
|
||||
[sourceOutput]: [
|
||||
[
|
||||
{
|
||||
node: to,
|
||||
type: targetInput,
|
||||
index: targetIndex
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow from nodes with automatic connections
|
||||
*
|
||||
* Connects nodes in sequence: node1 -> node2 -> node3, etc.
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param nodes - Array of nodes
|
||||
* @returns Partial workflow
|
||||
*/
|
||||
export function createSequentialWorkflow(
|
||||
name: string,
|
||||
nodes: WorkflowNode[]
|
||||
): Partial<Workflow> {
|
||||
const connections: Record<string, any> = {};
|
||||
|
||||
// Create connections between sequential nodes
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
const currentNode = nodes[i];
|
||||
const nextNode = nodes[i + 1];
|
||||
|
||||
connections[currentNode.name] = {
|
||||
main: [[{ node: nextNode.name, type: 'main', index: 0 }]]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes,
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow with parallel branches
|
||||
*
|
||||
* Creates a workflow with one trigger node that splits into multiple
|
||||
* parallel execution paths.
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param trigger - Trigger node
|
||||
* @param branches - Array of branch nodes
|
||||
* @returns Partial workflow
|
||||
*/
|
||||
export function createParallelWorkflow(
|
||||
name: string,
|
||||
trigger: WorkflowNode,
|
||||
branches: WorkflowNode[]
|
||||
): Partial<Workflow> {
|
||||
const connections: Record<string, any> = {
|
||||
[trigger.name]: {
|
||||
main: [branches.map(node => ({ node: node.name, type: 'main', index: 0 }))]
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes: [trigger, ...branches],
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string for test data
|
||||
*
|
||||
* @param length - String length (default: 8)
|
||||
* @returns Random string
|
||||
*/
|
||||
export function randomString(length: number = 8): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for testing
|
||||
*
|
||||
* @param prefix - Optional prefix
|
||||
* @returns Unique ID
|
||||
*/
|
||||
export function uniqueId(prefix: string = 'test'): string {
|
||||
return `${prefix}-${Date.now()}-${randomString(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow with error handling
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param mainNode - Main processing node
|
||||
* @param errorNode - Error handling node
|
||||
* @returns Partial workflow with error handling configured
|
||||
*/
|
||||
export function createErrorHandlingWorkflow(
|
||||
name: string,
|
||||
mainNode: WorkflowNode,
|
||||
errorNode: WorkflowNode
|
||||
): Partial<Workflow> {
|
||||
const trigger = createWebhookNode({
|
||||
name: 'Trigger',
|
||||
position: [250, 300]
|
||||
});
|
||||
|
||||
// Configure main node for error handling
|
||||
const mainNodeWithError = {
|
||||
...mainNode,
|
||||
continueOnFail: true,
|
||||
onError: 'continueErrorOutput' as const
|
||||
};
|
||||
|
||||
const connections: Record<string, any> = {
|
||||
[trigger.name]: {
|
||||
main: [[{ node: mainNode.name, type: 'main', index: 0 }]]
|
||||
},
|
||||
[mainNode.name]: {
|
||||
error: [[{ node: errorNode.name, type: 'main', index: 0 }]]
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes: [trigger, mainNodeWithError, errorNode],
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test workflow tags
|
||||
*
|
||||
* @param additional - Additional tags to include
|
||||
* @returns Array of tags for test workflows
|
||||
*/
|
||||
export function createTestTags(additional: string[] = []): string[] {
|
||||
return ['mcp-integration-test', ...additional];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow settings with common test configurations
|
||||
*
|
||||
* @param overrides - Settings to override
|
||||
* @returns Workflow settings object
|
||||
*/
|
||||
export function createWorkflowSettings(overrides: Record<string, any> = {}): Record<string, any> {
|
||||
return {
|
||||
executionOrder: 'v1',
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
saveManualExecutions: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
375
tests/integration/n8n-api/utils/fixtures.ts
Normal file
375
tests/integration/n8n-api/utils/fixtures.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Workflow Fixtures for Integration Tests
|
||||
*
|
||||
* Provides reusable workflow templates for testing.
|
||||
* All fixtures use FULL node type format (n8n-nodes-base.*)
|
||||
* as required by the n8n API.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
|
||||
/**
|
||||
* Simple webhook workflow with a single Webhook node
|
||||
*
|
||||
* Use this for basic workflow creation tests.
|
||||
*/
|
||||
export const SIMPLE_WEBHOOK_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test-webhook'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple HTTP request workflow
|
||||
*
|
||||
* Contains a Webhook trigger and an HTTP Request node.
|
||||
* Tests basic workflow connections.
|
||||
*/
|
||||
export const SIMPLE_HTTP_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'trigger'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://httpbin.org/get',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-node workflow with branching
|
||||
*
|
||||
* Tests complex connections and multiple execution paths.
|
||||
*/
|
||||
export const MULTI_NODE_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'multi-node'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set 1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 200],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-1',
|
||||
name: 'branch',
|
||||
value: 'top',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-2',
|
||||
name: 'Set 2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 400],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-2',
|
||||
name: 'branch',
|
||||
value: 'bottom',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'merge-1',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [650, 300],
|
||||
parameters: {
|
||||
mode: 'append',
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Set 1', type: 'main', index: 0 },
|
||||
{ node: 'Set 2', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
},
|
||||
'Set 1': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Set 2': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow with error handling
|
||||
*
|
||||
* Tests error output configuration and error workflows.
|
||||
*/
|
||||
export const ERROR_HANDLING_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'error-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://httpbin.org/status/500',
|
||||
method: 'GET'
|
||||
},
|
||||
continueOnFail: true,
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: 'set-error',
|
||||
name: 'Handle Error',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [650, 400],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'error-assign',
|
||||
name: 'error_handled',
|
||||
value: 'true',
|
||||
type: 'boolean'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
},
|
||||
'HTTP Request': {
|
||||
main: [[{ node: 'Handle Error', type: 'main', index: 0 }]],
|
||||
error: [[{ node: 'Handle Error', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* AI Agent workflow (langchain nodes)
|
||||
*
|
||||
* Tests langchain node support.
|
||||
*/
|
||||
export const AI_AGENT_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'When clicking "Test workflow"',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.7,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: '={{ $json.input }}',
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'When clicking "Test workflow"': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow with n8n expressions
|
||||
*
|
||||
* Tests expression validation.
|
||||
*/
|
||||
export const EXPRESSION_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set Variables',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'expr-1',
|
||||
name: 'timestamp',
|
||||
value: '={{ $now }}',
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
id: 'expr-2',
|
||||
name: 'item_count',
|
||||
value: '={{ $json.items.length }}',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 'expr-3',
|
||||
name: 'first_item',
|
||||
value: '={{ $node["Manual Trigger"].json }}',
|
||||
type: 'object'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Set Variables', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a fixture by name
|
||||
*
|
||||
* @param name - Fixture name
|
||||
* @returns Workflow fixture
|
||||
*/
|
||||
export function getFixture(
|
||||
name:
|
||||
| 'simple-webhook'
|
||||
| 'simple-http'
|
||||
| 'multi-node'
|
||||
| 'error-handling'
|
||||
| 'ai-agent'
|
||||
| 'expression'
|
||||
): Partial<Workflow> {
|
||||
const fixtures = {
|
||||
'simple-webhook': SIMPLE_WEBHOOK_WORKFLOW,
|
||||
'simple-http': SIMPLE_HTTP_WORKFLOW,
|
||||
'multi-node': MULTI_NODE_WORKFLOW,
|
||||
'error-handling': ERROR_HANDLING_WORKFLOW,
|
||||
'ai-agent': AI_AGENT_WORKFLOW,
|
||||
expression: EXPRESSION_WORKFLOW
|
||||
};
|
||||
|
||||
return JSON.parse(JSON.stringify(fixtures[name])); // Deep clone
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal workflow with custom nodes
|
||||
*
|
||||
* @param nodes - Array of workflow nodes
|
||||
* @param connections - Optional connections object
|
||||
* @returns Workflow fixture
|
||||
*/
|
||||
export function createCustomWorkflow(
|
||||
nodes: WorkflowNode[],
|
||||
connections: Record<string, any> = {}
|
||||
): Partial<Workflow> {
|
||||
return {
|
||||
nodes,
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
14
tests/integration/n8n-api/utils/mcp-context.ts
Normal file
14
tests/integration/n8n-api/utils/mcp-context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
|
||||
/**
|
||||
* Creates MCP context for testing MCP handlers against real n8n instance
|
||||
* This is what gets passed to MCP handlers (handleCreateWorkflow, etc.)
|
||||
*/
|
||||
export function createMcpContext(): InstanceContext {
|
||||
const creds = getN8nCredentials();
|
||||
return {
|
||||
n8nApiUrl: creds.url,
|
||||
n8nApiKey: creds.apiKey
|
||||
};
|
||||
}
|
||||
65
tests/integration/n8n-api/utils/n8n-client.ts
Normal file
65
tests/integration/n8n-api/utils/n8n-client.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pre-configured n8n API Client for Integration Tests
|
||||
*
|
||||
* Provides a singleton API client instance configured with test credentials.
|
||||
* Automatically loads credentials from .env (local) or GitHub secrets (CI).
|
||||
*/
|
||||
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { getN8nCredentials, validateCredentials } from './credentials';
|
||||
|
||||
let client: N8nApiClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the test n8n API client
|
||||
*
|
||||
* Creates a singleton instance configured with credentials from
|
||||
* the environment. Validates that required credentials are present.
|
||||
*
|
||||
* @returns Configured N8nApiClient instance
|
||||
* @throws Error if credentials are missing or invalid
|
||||
*
|
||||
* @example
|
||||
* const client = getTestN8nClient();
|
||||
* const workflow = await client.createWorkflow({ ... });
|
||||
*/
|
||||
export function getTestN8nClient(): N8nApiClient {
|
||||
if (!client) {
|
||||
const creds = getN8nCredentials();
|
||||
validateCredentials(creds);
|
||||
client = new N8nApiClient({
|
||||
baseUrl: creds.url,
|
||||
apiKey: creds.apiKey,
|
||||
timeout: 30000,
|
||||
maxRetries: 3
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the test client instance
|
||||
*
|
||||
* Forces recreation of the client on next call to getTestN8nClient().
|
||||
* Useful for testing or when credentials change.
|
||||
*/
|
||||
export function resetTestN8nClient(): void {
|
||||
client = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the n8n API is accessible
|
||||
*
|
||||
* Performs a health check to verify API connectivity.
|
||||
*
|
||||
* @returns true if API is accessible, false otherwise
|
||||
*/
|
||||
export async function isN8nApiAccessible(): Promise<boolean> {
|
||||
try {
|
||||
const client = getTestN8nClient();
|
||||
await client.healthCheck();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
65
tests/integration/n8n-api/utils/node-repository.ts
Normal file
65
tests/integration/n8n-api/utils/node-repository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Node Repository Utility for Integration Tests
|
||||
*
|
||||
* Provides a singleton NodeRepository instance for integration tests
|
||||
* that require validation or autofix functionality.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { createDatabaseAdapter, DatabaseAdapter } from '../../../../src/database/database-adapter';
|
||||
import { NodeRepository } from '../../../../src/database/node-repository';
|
||||
|
||||
let repositoryInstance: NodeRepository | null = null;
|
||||
let dbInstance: DatabaseAdapter | null = null;
|
||||
|
||||
/**
|
||||
* Get or create NodeRepository instance
|
||||
*
|
||||
* Uses the production nodes.db database (data/nodes.db).
|
||||
*
|
||||
* @returns Singleton NodeRepository instance
|
||||
* @throws {Error} If database file cannot be found or opened
|
||||
*
|
||||
* @example
|
||||
* const repository = await getNodeRepository();
|
||||
* const nodeInfo = await repository.getNodeByType('n8n-nodes-base.webhook');
|
||||
*/
|
||||
export async function getNodeRepository(): Promise<NodeRepository> {
|
||||
if (repositoryInstance) {
|
||||
return repositoryInstance;
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data/nodes.db');
|
||||
dbInstance = await createDatabaseAdapter(dbPath);
|
||||
repositoryInstance = new NodeRepository(dbInstance);
|
||||
|
||||
return repositoryInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database and reset repository instance
|
||||
*
|
||||
* Should be called in test cleanup (afterAll) to prevent resource leaks.
|
||||
* Properly closes the database connection and resets the singleton.
|
||||
*
|
||||
* @example
|
||||
* afterAll(async () => {
|
||||
* await closeNodeRepository();
|
||||
* });
|
||||
*/
|
||||
export async function closeNodeRepository(): Promise<void> {
|
||||
if (dbInstance && typeof dbInstance.close === 'function') {
|
||||
await dbInstance.close();
|
||||
}
|
||||
dbInstance = null;
|
||||
repositoryInstance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset repository instance (useful for test cleanup)
|
||||
*
|
||||
* @deprecated Use closeNodeRepository() instead to properly close database connections
|
||||
*/
|
||||
export function resetNodeRepository(): void {
|
||||
repositoryInstance = null;
|
||||
}
|
||||
202
tests/integration/n8n-api/utils/response-types.ts
Normal file
202
tests/integration/n8n-api/utils/response-types.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* TypeScript interfaces for n8n API and MCP handler responses
|
||||
* Used in integration tests to provide type safety
|
||||
*/
|
||||
|
||||
// ======================================================================
|
||||
// System Tool Response Types
|
||||
// ======================================================================
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
status: string;
|
||||
instanceId?: string;
|
||||
n8nVersion?: string;
|
||||
features?: Record<string, any>;
|
||||
apiUrl: string;
|
||||
mcpVersion: string;
|
||||
supportedN8nVersion?: string;
|
||||
versionNote?: string;
|
||||
[key: string]: any; // Allow dynamic property access for optional field checks
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ToolCategory {
|
||||
category: string;
|
||||
tools: ToolDefinition[];
|
||||
}
|
||||
|
||||
export interface ApiConfiguration {
|
||||
apiUrl: string;
|
||||
timeout: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export interface ListToolsResponse {
|
||||
tools: ToolCategory[];
|
||||
apiConfigured: boolean;
|
||||
configuration?: ApiConfiguration | null;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface ApiStatus {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
error?: string | null;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
export interface ToolsAvailability {
|
||||
documentationTools: {
|
||||
count: number;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
};
|
||||
managementTools: {
|
||||
count: number;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
};
|
||||
totalAvailable: number;
|
||||
}
|
||||
|
||||
export interface DebugInfo {
|
||||
processEnv: string[];
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticResponse {
|
||||
timestamp: string;
|
||||
environment: {
|
||||
N8N_API_URL: string | null;
|
||||
N8N_API_KEY: string | null;
|
||||
NODE_ENV: string;
|
||||
MCP_MODE: string;
|
||||
};
|
||||
apiConfiguration: {
|
||||
configured: boolean;
|
||||
status: ApiStatus;
|
||||
config?: {
|
||||
baseUrl: string;
|
||||
timeout: number;
|
||||
maxRetries: number;
|
||||
} | null;
|
||||
};
|
||||
toolsAvailability: ToolsAvailability;
|
||||
troubleshooting: {
|
||||
steps: string[];
|
||||
documentation: string;
|
||||
};
|
||||
debug?: DebugInfo;
|
||||
[key: string]: any; // Allow dynamic property access for optional field checks
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Execution Response Types
|
||||
// ======================================================================
|
||||
|
||||
export interface ExecutionData {
|
||||
id: string;
|
||||
status?: 'success' | 'error' | 'running' | 'waiting';
|
||||
mode?: string;
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
workflowId?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface ListExecutionsResponse {
|
||||
executions: ExecutionData[];
|
||||
returned: number;
|
||||
nextCursor?: string;
|
||||
hasMore: boolean;
|
||||
_note?: string;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Workflow Response Types
|
||||
// ======================================================================
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
typeVersion: number;
|
||||
position: [number, number];
|
||||
parameters: Record<string, any>;
|
||||
credentials?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowConnections {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
nodes: WorkflowNode[];
|
||||
connections: WorkflowConnections;
|
||||
settings?: Record<string, any>;
|
||||
staticData?: Record<string, any>;
|
||||
tags?: string[];
|
||||
versionId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
message: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ValidateWorkflowResponse {
|
||||
valid: boolean;
|
||||
errors?: ValidationError[];
|
||||
warnings?: ValidationWarning[];
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface AutofixChange {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
field: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AutofixSuggestion {
|
||||
fixType: string;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
description: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
changes: AutofixChange[];
|
||||
}
|
||||
|
||||
export interface AutofixResponse {
|
||||
appliedFixes?: number;
|
||||
suggestions?: AutofixSuggestion[];
|
||||
workflow?: WorkflowData;
|
||||
summary?: string;
|
||||
preview?: boolean;
|
||||
}
|
||||
177
tests/integration/n8n-api/utils/test-context.ts
Normal file
177
tests/integration/n8n-api/utils/test-context.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Test Context for Resource Tracking and Cleanup
|
||||
*
|
||||
* Tracks resources created during tests (workflows, executions) and
|
||||
* provides automatic cleanup functionality.
|
||||
*/
|
||||
|
||||
import { getTestN8nClient } from './n8n-client';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
import { Logger } from '../../../../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[TestContext]' });
|
||||
|
||||
export interface TestContext {
|
||||
/** Workflow IDs created during the test */
|
||||
workflowIds: string[];
|
||||
|
||||
/** Execution IDs created during the test */
|
||||
executionIds: string[];
|
||||
|
||||
/** Clean up all tracked resources */
|
||||
cleanup: () => Promise<void>;
|
||||
|
||||
/** Track a workflow for cleanup */
|
||||
trackWorkflow: (id: string) => void;
|
||||
|
||||
/** Track an execution for cleanup */
|
||||
trackExecution: (id: string) => void;
|
||||
|
||||
/** Remove a workflow from tracking (e.g., already deleted) */
|
||||
untrackWorkflow: (id: string) => void;
|
||||
|
||||
/** Remove an execution from tracking (e.g., already deleted) */
|
||||
untrackExecution: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test context for tracking and cleaning up resources
|
||||
*
|
||||
* Use this in test setup to create a context that tracks all
|
||||
* workflows and executions created during the test. Call cleanup()
|
||||
* in afterEach or afterAll to remove test resources.
|
||||
*
|
||||
* @returns TestContext
|
||||
*
|
||||
* @example
|
||||
* describe('Workflow tests', () => {
|
||||
* let context: TestContext;
|
||||
*
|
||||
* beforeEach(() => {
|
||||
* context = createTestContext();
|
||||
* });
|
||||
*
|
||||
* afterEach(async () => {
|
||||
* await context.cleanup();
|
||||
* });
|
||||
*
|
||||
* it('creates a workflow', async () => {
|
||||
* const workflow = await client.createWorkflow({ ... });
|
||||
* context.trackWorkflow(workflow.id);
|
||||
* // Test runs, then cleanup() automatically deletes the workflow
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
export function createTestContext(): TestContext {
|
||||
const context: TestContext = {
|
||||
workflowIds: [],
|
||||
executionIds: [],
|
||||
|
||||
trackWorkflow(id: string) {
|
||||
if (!this.workflowIds.includes(id)) {
|
||||
this.workflowIds.push(id);
|
||||
logger.debug(`Tracking workflow for cleanup: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
trackExecution(id: string) {
|
||||
if (!this.executionIds.includes(id)) {
|
||||
this.executionIds.push(id);
|
||||
logger.debug(`Tracking execution for cleanup: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
untrackWorkflow(id: string) {
|
||||
const index = this.workflowIds.indexOf(id);
|
||||
if (index > -1) {
|
||||
this.workflowIds.splice(index, 1);
|
||||
logger.debug(`Untracked workflow: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
untrackExecution(id: string) {
|
||||
const index = this.executionIds.indexOf(id);
|
||||
if (index > -1) {
|
||||
this.executionIds.splice(index, 1);
|
||||
logger.debug(`Untracked execution: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
async cleanup() {
|
||||
const creds = getN8nCredentials();
|
||||
|
||||
// Skip cleanup if disabled
|
||||
if (!creds.cleanup.enabled) {
|
||||
logger.info('Cleanup disabled, skipping resource cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getTestN8nClient();
|
||||
|
||||
// Delete executions first (they reference workflows)
|
||||
if (this.executionIds.length > 0) {
|
||||
logger.info(`Cleaning up ${this.executionIds.length} execution(s)`);
|
||||
|
||||
for (const id of this.executionIds) {
|
||||
try {
|
||||
await client.deleteExecution(id);
|
||||
logger.debug(`Deleted execution: ${id}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - execution might already be deleted
|
||||
logger.warn(`Failed to delete execution ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.executionIds = [];
|
||||
}
|
||||
|
||||
// Then delete workflows
|
||||
if (this.workflowIds.length > 0) {
|
||||
logger.info(`Cleaning up ${this.workflowIds.length} workflow(s)`);
|
||||
|
||||
for (const id of this.workflowIds) {
|
||||
try {
|
||||
await client.deleteWorkflow(id);
|
||||
logger.debug(`Deleted workflow: ${id}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - workflow might already be deleted
|
||||
logger.warn(`Failed to delete workflow ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.workflowIds = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test workflow name with prefix and timestamp
|
||||
*
|
||||
* Generates a unique workflow name for testing that follows
|
||||
* the configured naming convention.
|
||||
*
|
||||
* @param baseName - Base name for the workflow
|
||||
* @returns Prefixed workflow name with timestamp
|
||||
*
|
||||
* @example
|
||||
* const name = createTestWorkflowName('Simple HTTP Request');
|
||||
* // Returns: "[MCP-TEST] Simple HTTP Request 1704067200000"
|
||||
*/
|
||||
export function createTestWorkflowName(baseName: string): string {
|
||||
const creds = getN8nCredentials();
|
||||
const timestamp = Date.now();
|
||||
return `${creds.cleanup.namePrefix} ${baseName} ${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured test tag
|
||||
*
|
||||
* @returns Tag to apply to test workflows
|
||||
*/
|
||||
export function getTestTag(): string {
|
||||
const creds = getN8nCredentials();
|
||||
return creds.cleanup.tag;
|
||||
}
|
||||
289
tests/integration/n8n-api/utils/webhook-workflows.ts
Normal file
289
tests/integration/n8n-api/utils/webhook-workflows.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Webhook Workflow Configuration
|
||||
*
|
||||
* Provides configuration and setup instructions for webhook workflows
|
||||
* required for integration testing.
|
||||
*
|
||||
* These workflows must be created manually in n8n and activated because
|
||||
* the n8n API doesn't support workflow activation.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
|
||||
export interface WebhookWorkflowConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
nodes: Array<Partial<WorkflowNode>>;
|
||||
connections: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for required webhook workflows
|
||||
*/
|
||||
export const WEBHOOK_WORKFLOW_CONFIGS: Record<string, WebhookWorkflowConfig> = {
|
||||
GET: {
|
||||
name: '[MCP-TEST] Webhook GET',
|
||||
description: 'Pre-activated webhook for GET method testing',
|
||||
httpMethod: 'GET',
|
||||
path: 'mcp-test-get',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'mcp-test-get',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
POST: {
|
||||
name: '[MCP-TEST] Webhook POST',
|
||||
description: 'Pre-activated webhook for POST method testing',
|
||||
httpMethod: 'POST',
|
||||
path: 'mcp-test-post',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'mcp-test-post',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
PUT: {
|
||||
name: '[MCP-TEST] Webhook PUT',
|
||||
description: 'Pre-activated webhook for PUT method testing',
|
||||
httpMethod: 'PUT',
|
||||
path: 'mcp-test-put',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'PUT',
|
||||
path: 'mcp-test-put',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
DELETE: {
|
||||
name: '[MCP-TEST] Webhook DELETE',
|
||||
description: 'Pre-activated webhook for DELETE method testing',
|
||||
httpMethod: 'DELETE',
|
||||
path: 'mcp-test-delete',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'DELETE',
|
||||
path: 'mcp-test-delete',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Print setup instructions for webhook workflows
|
||||
*/
|
||||
export function printSetupInstructions(): void {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ WEBHOOK WORKFLOW SETUP REQUIRED ║
|
||||
╠════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Integration tests require 4 pre-activated webhook workflows: ║
|
||||
║ ║
|
||||
║ 1. Create workflows manually in n8n UI ║
|
||||
║ 2. Use the configurations shown below ║
|
||||
║ 3. ACTIVATE each workflow in n8n UI ║
|
||||
║ 4. Copy workflow IDs to .env file ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Required workflows:
|
||||
`);
|
||||
|
||||
Object.entries(WEBHOOK_WORKFLOW_CONFIGS).forEach(([method, config]) => {
|
||||
console.log(`
|
||||
${method} Method:
|
||||
Name: ${config.name}
|
||||
Path: ${config.path}
|
||||
.env variable: N8N_TEST_WEBHOOK_${method}_ID
|
||||
|
||||
Workflow Structure:
|
||||
1. Webhook node (${method} method, path: ${config.path})
|
||||
2. Respond to Webhook node
|
||||
|
||||
After creating:
|
||||
1. Save the workflow
|
||||
2. ACTIVATE the workflow (toggle in UI)
|
||||
3. Copy the workflow ID
|
||||
4. Add to .env: N8N_TEST_WEBHOOK_${method}_ID=<workflow-id>
|
||||
`);
|
||||
});
|
||||
|
||||
console.log(`
|
||||
See docs/local/integration-testing-plan.md for detailed instructions.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate workflow JSON for a webhook workflow
|
||||
*
|
||||
* @param method - HTTP method
|
||||
* @returns Partial workflow ready to create
|
||||
*/
|
||||
export function generateWebhookWorkflowJson(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): Partial<Workflow> {
|
||||
const config = WEBHOOK_WORKFLOW_CONFIGS[method];
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
nodes: config.nodes as any,
|
||||
connections: config.connections,
|
||||
active: false, // Will need to be activated manually
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
},
|
||||
tags: ['mcp-integration-test', 'webhook-test']
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all webhook workflow JSONs
|
||||
*
|
||||
* Returns an object with all 4 webhook workflow configurations
|
||||
* ready to be created in n8n.
|
||||
*
|
||||
* @returns Object with workflow configurations
|
||||
*/
|
||||
export function exportAllWebhookWorkflows(): Record<string, Partial<Workflow>> {
|
||||
return {
|
||||
GET: generateWebhookWorkflowJson('GET'),
|
||||
POST: generateWebhookWorkflowJson('POST'),
|
||||
PUT: generateWebhookWorkflowJson('PUT'),
|
||||
DELETE: generateWebhookWorkflowJson('DELETE')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook URL for a given n8n instance and HTTP method
|
||||
*
|
||||
* @param n8nUrl - n8n instance URL
|
||||
* @param method - HTTP method
|
||||
* @returns Webhook URL
|
||||
*/
|
||||
export function getWebhookUrl(
|
||||
n8nUrl: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): string {
|
||||
const config = WEBHOOK_WORKFLOW_CONFIGS[method];
|
||||
const baseUrl = n8nUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
return `${baseUrl}/webhook/${config.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate webhook workflow structure
|
||||
*
|
||||
* Checks if a workflow matches the expected webhook workflow structure.
|
||||
*
|
||||
* @param workflow - Workflow to validate
|
||||
* @param method - Expected HTTP method
|
||||
* @returns true if valid
|
||||
*/
|
||||
export function isValidWebhookWorkflow(
|
||||
workflow: Partial<Workflow>,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): boolean {
|
||||
if (!workflow.nodes || workflow.nodes.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webhookNode = workflow.nodes.find(n => n.type === 'n8n-nodes-base.webhook');
|
||||
if (!webhookNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = webhookNode.parameters as any;
|
||||
return params.httpMethod === method;
|
||||
}
|
||||
855
tests/integration/n8n-api/workflows/autofix-workflow.test.ts
Normal file
855
tests/integration/n8n-api/workflows/autofix-workflow.test.ts
Normal file
@@ -0,0 +1,855 @@
|
||||
/**
|
||||
* Integration Tests: handleAutofixWorkflow
|
||||
*
|
||||
* Tests workflow autofix against a real n8n instance.
|
||||
* Covers fix types, confidence levels, preview/apply modes, and error handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleAutofixWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { getNodeRepository, closeNodeRepository } from '../utils/node-repository';
|
||||
import { NodeRepository } from '../../../../src/database/node-repository';
|
||||
import { AutofixResponse } from '../types/mcp-responses';
|
||||
|
||||
describe('Integration: handleAutofixWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
let repository: NodeRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
repository = await getNodeRepository();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeNodeRepository();
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Preview Mode (applyFixes: false)
|
||||
// ======================================================================
|
||||
|
||||
describe('Preview Mode', () => {
|
||||
it('should preview fixes without applying them (expression-format)', async () => {
|
||||
// Create workflow with expression format issues
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Preview Expression'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
// Bad expression format (missing {{}})
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'value',
|
||||
value: '$json.data', // Should be {{ $json.data }}
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Preview fixes (applyFixes: false)
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as AutofixResponse;
|
||||
|
||||
// If fixes are available, should be in preview mode
|
||||
if (data.fixesAvailable && data.fixesAvailable > 0) {
|
||||
expect(data.preview).toBe(true);
|
||||
expect(data.fixes).toBeDefined();
|
||||
expect(Array.isArray(data.fixes)).toBe(true);
|
||||
expect(data.summary).toBeDefined();
|
||||
expect(data.stats).toBeDefined();
|
||||
|
||||
// Verify workflow not modified (fetch it back)
|
||||
const fetched = await client.getWorkflow(created.id!);
|
||||
const params = fetched.nodes[1].parameters as { assignments: { assignments: Array<{ value: string }> } };
|
||||
expect(params.assignments.assignments[0].value).toBe('$json.data');
|
||||
} else {
|
||||
// No fixes available - that's also a valid result
|
||||
expect(data.message).toContain('No automatic fixes available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should preview multiple fix types', async () => {
|
||||
// Create workflow with multiple issues
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Preview Multiple'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1, // Old typeVersion
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET'
|
||||
// Missing path parameter
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.preview).toBe(true);
|
||||
expect(data.fixesAvailable).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Apply Mode (applyFixes: true)
|
||||
// ======================================================================
|
||||
|
||||
describe('Apply Mode', () => {
|
||||
it('should apply expression-format fixes', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Apply Expression'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'value',
|
||||
value: '$json.data', // Bad format
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Apply fixes
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: true,
|
||||
fixTypes: ['expression-format']
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// If fixes were applied
|
||||
if (data.fixesApplied && data.fixesApplied > 0) {
|
||||
expect(data.fixes).toBeDefined();
|
||||
expect(data.preview).toBeUndefined();
|
||||
|
||||
// Verify workflow was actually modified
|
||||
const fetched = await client.getWorkflow(created.id!);
|
||||
const params = fetched.nodes[1].parameters as { assignments: { assignments: Array<{ value: unknown }> } };
|
||||
const setValue = params.assignments.assignments[0].value;
|
||||
// Expression format should be fixed (depends on what fixes were available)
|
||||
expect(setValue).toBeDefined();
|
||||
} else {
|
||||
// No fixes available or applied - that's also valid
|
||||
expect(data.message).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply webhook-missing-path fixes', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Apply Webhook Path'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET'
|
||||
// Missing path
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: true,
|
||||
fixTypes: ['webhook-missing-path']
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
if (data.fixesApplied > 0) {
|
||||
// Verify path was added
|
||||
const fetched = await client.getWorkflow(created.id!);
|
||||
expect(fetched.nodes[0].parameters.path).toBeDefined();
|
||||
expect(fetched.nodes[0].parameters.path).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Fix Type Filtering
|
||||
// ======================================================================
|
||||
|
||||
describe('Fix Type Filtering', () => {
|
||||
it('should only apply specified fix types', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Filter Fix Types'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1, // Old typeVersion
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET'
|
||||
// Missing path
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Only request webhook-missing-path fixes (ignore typeversion issues)
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
fixTypes: ['webhook-missing-path']
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Should only show webhook-missing-path fixes
|
||||
if (data.fixes && data.fixes.length > 0) {
|
||||
data.fixes.forEach((fix: any) => {
|
||||
expect(fix.type).toBe('webhook-missing-path');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple fix types filter', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Multiple Filter'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
fixTypes: ['expression-format', 'webhook-missing-path']
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Confidence Threshold
|
||||
// ======================================================================
|
||||
|
||||
describe('Confidence Threshold', () => {
|
||||
it('should filter fixes by high confidence threshold', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - High Confidence'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
confidenceThreshold: 'high'
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// All fixes should be high confidence
|
||||
if (data.fixes && data.fixes.length > 0) {
|
||||
data.fixes.forEach((fix: any) => {
|
||||
expect(fix.confidence).toBe('high');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should include medium and high confidence with medium threshold', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Medium Confidence'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
confidenceThreshold: 'medium'
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Fixes should be medium or high confidence
|
||||
if (data.fixes && data.fixes.length > 0) {
|
||||
data.fixes.forEach((fix: any) => {
|
||||
expect(['high', 'medium']).toContain(fix.confidence);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should include all confidence levels with low threshold', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Low Confidence'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
confidenceThreshold: 'low'
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Max Fixes Parameter
|
||||
// ======================================================================
|
||||
|
||||
describe('Max Fixes Parameter', () => {
|
||||
it('should limit fixes to maxFixes parameter', async () => {
|
||||
// Create workflow with multiple issues
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Max Fixes'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set 1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: '1', name: 'val1', value: '$json.a', type: 'string' },
|
||||
{ id: '2', name: 'val2', value: '$json.b', type: 'string' },
|
||||
{ id: '3', name: 'val3', value: '$json.c', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Set 1', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Limit to 1 fix
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
maxFixes: 1
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Should have at most 1 fix
|
||||
if (data.fixes) {
|
||||
expect(data.fixes.length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// No Fixes Available
|
||||
// ======================================================================
|
||||
|
||||
describe('No Fixes Available', () => {
|
||||
it('should handle workflow with no fixable issues', async () => {
|
||||
// Create valid workflow
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - No Issues'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test-webhook'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.message).toContain('No automatic fixes available');
|
||||
expect(data.validationSummary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle non-existent workflow ID', async () => {
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: '99999999',
|
||||
applyFixes: false
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid fixTypes parameter', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Invalid Param'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
fixTypes: ['invalid-fix-type'] as any
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Should either fail validation or ignore invalid type
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid confidence threshold', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Invalid Confidence'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false,
|
||||
confidenceThreshold: 'invalid' as any
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete autofix response structure (preview)', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Response Format Preview'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET'
|
||||
// Missing path to trigger fixes
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: false
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Verify required fields
|
||||
expect(data).toHaveProperty('workflowId');
|
||||
expect(data).toHaveProperty('workflowName');
|
||||
|
||||
// Preview mode specific fields
|
||||
if (data.fixesAvailable > 0) {
|
||||
expect(data).toHaveProperty('preview');
|
||||
expect(data.preview).toBe(true);
|
||||
expect(data).toHaveProperty('fixesAvailable');
|
||||
expect(data).toHaveProperty('fixes');
|
||||
expect(data).toHaveProperty('summary');
|
||||
expect(data).toHaveProperty('stats');
|
||||
expect(data).toHaveProperty('message');
|
||||
|
||||
// Verify fixes structure
|
||||
expect(Array.isArray(data.fixes)).toBe(true);
|
||||
if (data.fixes.length > 0) {
|
||||
const fix = data.fixes[0];
|
||||
expect(fix).toHaveProperty('type');
|
||||
expect(fix).toHaveProperty('confidence');
|
||||
expect(fix).toHaveProperty('description');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return complete autofix response structure (apply)', async () => {
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Autofix - Response Format Apply'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET'
|
||||
// Missing path
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleAutofixWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
applyFixes: true
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data).toHaveProperty('workflowId');
|
||||
expect(data).toHaveProperty('workflowName');
|
||||
|
||||
// Apply mode specific fields
|
||||
if (data.fixesApplied > 0) {
|
||||
expect(data).toHaveProperty('fixesApplied');
|
||||
expect(data).toHaveProperty('fixes');
|
||||
expect(data).toHaveProperty('summary');
|
||||
expect(data).toHaveProperty('stats');
|
||||
expect(data).toHaveProperty('message');
|
||||
expect(data.preview).toBeUndefined();
|
||||
|
||||
// Verify types
|
||||
expect(typeof data.fixesApplied).toBe('number');
|
||||
expect(Array.isArray(data.fixes)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
544
tests/integration/n8n-api/workflows/create-workflow.test.ts
Normal file
544
tests/integration/n8n-api/workflows/create-workflow.test.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Integration Tests: handleCreateWorkflow
|
||||
*
|
||||
* Tests workflow creation against a real n8n instance.
|
||||
* Verifies the P0 bug fix (FULL vs SHORT node type formats)
|
||||
* and covers all major workflow creation scenarios.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import {
|
||||
SIMPLE_WEBHOOK_WORKFLOW,
|
||||
SIMPLE_HTTP_WORKFLOW,
|
||||
MULTI_NODE_WORKFLOW,
|
||||
ERROR_HANDLING_WORKFLOW,
|
||||
AI_AGENT_WORKFLOW,
|
||||
EXPRESSION_WORKFLOW,
|
||||
getFixture
|
||||
} from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleCreateWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleCreateWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
// Global cleanup after all tests to catch any orphaned workflows
|
||||
// (e.g., from test retries or failures)
|
||||
// IMPORTANT: Skip cleanup in CI to preserve shared n8n instance workflows
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// P0: Critical Bug Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('P0: Node Type Format Bug Fix', () => {
|
||||
it('should create workflow with webhook node using FULL node type format', async () => {
|
||||
// This test verifies the P0 bug fix where SHORT node type format
|
||||
// (e.g., "webhook") was incorrectly normalized to FULL format
|
||||
// causing workflow creation failures.
|
||||
//
|
||||
// The fix ensures FULL format (e.g., "n8n-nodes-base.webhook")
|
||||
// is preserved and passed to n8n API correctly.
|
||||
|
||||
const workflowName = createTestWorkflowName('P0 Bug Verification - Webhook Node');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('simple-webhook')
|
||||
};
|
||||
|
||||
// Create workflow using MCP handler
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
// Verify workflow created successfully
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.name).toBe(workflowName);
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
|
||||
// Critical: Verify FULL node type format is preserved
|
||||
expect(result.nodes[0].type).toBe('n8n-nodes-base.webhook');
|
||||
expect(result.nodes[0].name).toBe('Webhook');
|
||||
expect(result.nodes[0].parameters).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// P1: Base Nodes (High Priority)
|
||||
// ======================================================================
|
||||
|
||||
describe('P1: Base n8n Nodes', () => {
|
||||
it('should create workflow with HTTP Request node', async () => {
|
||||
const workflowName = createTestWorkflowName('HTTP Request Node');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('simple-http')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.name).toBe(workflowName);
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
|
||||
// Verify both nodes created with FULL type format
|
||||
const webhookNode = result.nodes.find((n: any) => n.name === 'Webhook');
|
||||
const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request');
|
||||
|
||||
expect(webhookNode).toBeDefined();
|
||||
expect(webhookNode!.type).toBe('n8n-nodes-base.webhook');
|
||||
|
||||
expect(httpNode).toBeDefined();
|
||||
expect(httpNode!.type).toBe('n8n-nodes-base.httpRequest');
|
||||
|
||||
// Verify connections
|
||||
expect(result.connections).toBeDefined();
|
||||
expect(result.connections.Webhook).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create workflow with langchain agent node', async () => {
|
||||
const workflowName = createTestWorkflowName('Langchain Agent Node');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('ai-agent')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.name).toBe(workflowName);
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
|
||||
// Verify langchain node type format
|
||||
const agentNode = result.nodes.find((n: any) => n.name === 'AI Agent');
|
||||
expect(agentNode).toBeDefined();
|
||||
expect(agentNode!.type).toBe('@n8n/n8n-nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should create complex multi-node workflow', async () => {
|
||||
const workflowName = createTestWorkflowName('Multi-Node Workflow');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('multi-node')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.name).toBe(workflowName);
|
||||
expect(result.nodes).toHaveLength(4);
|
||||
|
||||
// Verify all node types preserved
|
||||
const nodeTypes = result.nodes.map((n: any) => n.type);
|
||||
expect(nodeTypes).toContain('n8n-nodes-base.webhook');
|
||||
expect(nodeTypes).toContain('n8n-nodes-base.set');
|
||||
expect(nodeTypes).toContain('n8n-nodes-base.merge');
|
||||
|
||||
// Verify complex connections
|
||||
expect(result.connections.Webhook.main[0]).toHaveLength(2); // Branches to 2 nodes
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// P2: Advanced Features (Medium Priority)
|
||||
// ======================================================================
|
||||
|
||||
describe('P2: Advanced Workflow Features', () => {
|
||||
it('should create workflow with complex connections and branching', async () => {
|
||||
const workflowName = createTestWorkflowName('Complex Connections');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('multi-node')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.connections).toBeDefined();
|
||||
|
||||
// Verify branching: Webhook -> Set 1 and Set 2
|
||||
const webhookConnections = result.connections.Webhook.main[0];
|
||||
expect(webhookConnections).toHaveLength(2);
|
||||
|
||||
// Verify merging: Set 1 -> Merge (port 0), Set 2 -> Merge (port 1)
|
||||
const set1Connections = result.connections['Set 1'].main[0];
|
||||
const set2Connections = result.connections['Set 2'].main[0];
|
||||
|
||||
expect(set1Connections[0].node).toBe('Merge');
|
||||
expect(set1Connections[0].index).toBe(0);
|
||||
|
||||
expect(set2Connections[0].node).toBe('Merge');
|
||||
expect(set2Connections[0].index).toBe(1);
|
||||
});
|
||||
|
||||
it('should create workflow with custom settings', async () => {
|
||||
const workflowName = createTestWorkflowName('Custom Settings');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('error-handling'),
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
timezone: 'America/New_York',
|
||||
saveDataErrorExecution: 'all' as const,
|
||||
saveDataSuccessExecution: 'all' as const,
|
||||
saveExecutionProgress: true
|
||||
}
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.settings).toBeDefined();
|
||||
expect(result.settings!.executionOrder).toBe('v1');
|
||||
});
|
||||
|
||||
it('should create workflow with n8n expressions', async () => {
|
||||
const workflowName = createTestWorkflowName('n8n Expressions');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('expression')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
|
||||
// Verify Set node with expressions
|
||||
const setNode = result.nodes.find((n: any) => n.name === 'Set Variables');
|
||||
expect(setNode).toBeDefined();
|
||||
expect(setNode!.parameters.assignments).toBeDefined();
|
||||
|
||||
// Verify expressions are preserved
|
||||
const assignmentsData = setNode!.parameters.assignments as { assignments: Array<{ value: string }> };
|
||||
expect(assignmentsData.assignments).toHaveLength(3);
|
||||
expect(assignmentsData.assignments[0].value).toContain('$now');
|
||||
expect(assignmentsData.assignments[1].value).toContain('$json');
|
||||
expect(assignmentsData.assignments[2].value).toContain('$node');
|
||||
});
|
||||
|
||||
it('should create workflow with error handling configuration', async () => {
|
||||
const workflowName = createTestWorkflowName('Error Handling');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
...getFixture('error-handling')
|
||||
};
|
||||
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const result = response.data as Workflow;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
if (!result.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(result.id);
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
|
||||
// Verify HTTP node with error handling
|
||||
const httpNode = result.nodes.find((n: any) => n.name === 'HTTP Request');
|
||||
expect(httpNode).toBeDefined();
|
||||
expect(httpNode!.continueOnFail).toBe(true);
|
||||
expect(httpNode!.onError).toBe('continueErrorOutput');
|
||||
|
||||
// Verify error connection
|
||||
expect(result.connections['HTTP Request'].error).toBeDefined();
|
||||
expect(result.connections['HTTP Request'].error[0][0].node).toBe('Handle Error');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Scenarios (P1 Priority)
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Scenarios', () => {
|
||||
it('should reject workflow with invalid node type (MCP validation)', async () => {
|
||||
// MCP handler correctly validates workflows before sending to n8n API.
|
||||
// Invalid node types are caught during MCP validation.
|
||||
//
|
||||
// Note: Raw n8n API would accept this and only fail at execution time,
|
||||
// but MCP handler does proper pre-validation (correct behavior).
|
||||
|
||||
const workflowName = createTestWorkflowName('Invalid Node Type');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'invalid-1',
|
||||
name: 'Invalid Node',
|
||||
type: 'n8n-nodes-base.nonexistentnode',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects invalid workflows (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
expect(response.error).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject workflow with missing required node parameters (MCP validation)', async () => {
|
||||
// MCP handler validates required parameters before sending to n8n API.
|
||||
//
|
||||
// Note: Raw n8n API would accept this and only fail at execution time,
|
||||
// but MCP handler does proper pre-validation (correct behavior).
|
||||
|
||||
const workflowName = createTestWorkflowName('Missing Parameters');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
// Missing required 'url' parameter
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects workflows with validation errors (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject workflow with duplicate node names (MCP validation)', async () => {
|
||||
// MCP handler validates that node names are unique.
|
||||
//
|
||||
// Note: Raw n8n API might auto-rename duplicates, but MCP handler
|
||||
// enforces unique names upfront (correct behavior).
|
||||
|
||||
const workflowName = createTestWorkflowName('Duplicate Node Names');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: { assignments: [] },
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-2',
|
||||
name: 'Set', // Duplicate name
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: { assignments: [] },
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects workflows with validation errors (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject workflow with invalid connection references (MCP validation)', async () => {
|
||||
// MCP handler validates that connection references point to existing nodes.
|
||||
//
|
||||
// Note: Raw n8n API would accept this and only fail at execution time,
|
||||
// but MCP handler does proper connection validation (correct behavior).
|
||||
|
||||
const workflowName = createTestWorkflowName('Invalid Connections');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
// Connection references non-existent node
|
||||
Webhook: {
|
||||
main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects workflows with invalid connections (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
expect(response.error).toContain('validation');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Additional Edge Cases
|
||||
// ======================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should reject single-node non-webhook workflow (MCP validation)', async () => {
|
||||
// MCP handler enforces that single-node workflows are only valid for webhooks.
|
||||
// This is a best practice validation.
|
||||
|
||||
const workflowName = createTestWorkflowName('Minimal Single Node');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects single-node non-webhook workflows (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
expect(response.error).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject single-node non-trigger workflow (MCP validation)', async () => {
|
||||
// MCP handler enforces workflow best practices.
|
||||
// Single isolated nodes without connections are rejected.
|
||||
|
||||
const workflowName = createTestWorkflowName('Empty Connections');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: { assignments: [] },
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}, // Explicitly empty
|
||||
settings: { executionOrder: 'v1' as const }
|
||||
};
|
||||
|
||||
// MCP handler rejects single-node workflows (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject single-node workflow without settings (MCP validation)', async () => {
|
||||
// MCP handler enforces workflow best practices.
|
||||
// Single-node non-webhook workflows are rejected.
|
||||
|
||||
const workflowName = createTestWorkflowName('No Settings');
|
||||
const workflow = {
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
// No settings property
|
||||
};
|
||||
|
||||
// MCP handler rejects single-node workflows (correct behavior)
|
||||
const response = await handleCreateWorkflow({ ...workflow }, mcpContext);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
tests/integration/n8n-api/workflows/delete-workflow.test.ts
Normal file
132
tests/integration/n8n-api/workflows/delete-workflow.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration Tests: handleDeleteWorkflow
|
||||
*
|
||||
* Tests workflow deletion against a real n8n instance.
|
||||
* Covers successful deletion, error handling, and cleanup verification.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleDeleteWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleDeleteWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Successful Deletion
|
||||
// ======================================================================
|
||||
|
||||
describe('Successful Deletion', () => {
|
||||
it('should delete an existing workflow', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Delete - Success'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
|
||||
// Do NOT track workflow since we're testing deletion
|
||||
// context.trackWorkflow(created.id);
|
||||
|
||||
// Delete using MCP handler
|
||||
const response = await handleDeleteWorkflow(
|
||||
{ id: created.id },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Verify MCP response
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
// Verify workflow is actually deleted
|
||||
await expect(async () => {
|
||||
await client.getWorkflow(created.id!);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should return error for non-existent workflow ID', async () => {
|
||||
const response = await handleDeleteWorkflow(
|
||||
{ id: '99999999' },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Cleanup Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Cleanup Verification', () => {
|
||||
it('should verify workflow is actually deleted from n8n', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Delete - Cleanup Check'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
|
||||
// Verify workflow exists
|
||||
const beforeDelete = await client.getWorkflow(created.id);
|
||||
expect(beforeDelete.id).toBe(created.id);
|
||||
|
||||
// Delete workflow
|
||||
const deleteResponse = await handleDeleteWorkflow(
|
||||
{ id: created.id },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(deleteResponse.success).toBe(true);
|
||||
|
||||
// Verify workflow no longer exists
|
||||
try {
|
||||
await client.getWorkflow(created.id);
|
||||
// If we reach here, workflow wasn't deleted
|
||||
throw new Error('Workflow should have been deleted but still exists');
|
||||
} catch (error: any) {
|
||||
// Expected: workflow should not be found
|
||||
expect(error.message).toMatch(/not found|404/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
210
tests/integration/n8n-api/workflows/get-workflow-details.test.ts
Normal file
210
tests/integration/n8n-api/workflows/get-workflow-details.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Integration Tests: handleGetWorkflowDetails
|
||||
*
|
||||
* Tests workflow details retrieval against a real n8n instance.
|
||||
* Covers basic workflows, metadata, version history, and execution stats.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleGetWorkflowDetails } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleGetWorkflowDetails', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Basic Workflow Details
|
||||
// ======================================================================
|
||||
|
||||
describe('Basic Workflow', () => {
|
||||
it('should retrieve workflow with basic details', async () => {
|
||||
// Create a simple workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Details - Basic'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve detailed workflow information using MCP handler
|
||||
const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext);
|
||||
|
||||
// Verify MCP response structure
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
// handleGetWorkflowDetails returns { workflow, executionStats, hasWebhookTrigger, webhookPath }
|
||||
const details = (response.data as any).workflow;
|
||||
|
||||
// Verify basic details
|
||||
expect(details).toBeDefined();
|
||||
expect(details.id).toBe(created.id);
|
||||
expect(details.name).toBe(workflow.name);
|
||||
expect(details.createdAt).toBeDefined();
|
||||
expect(details.updatedAt).toBeDefined();
|
||||
expect(details.active).toBeDefined();
|
||||
|
||||
// Verify metadata fields
|
||||
expect(details.versionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Workflow with Metadata
|
||||
// ======================================================================
|
||||
|
||||
describe('Workflow with Metadata', () => {
|
||||
it('should retrieve workflow with tags and settings metadata', async () => {
|
||||
// Create workflow with rich metadata
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Details - With Metadata'),
|
||||
tags: [
|
||||
'mcp-integration-test',
|
||||
'test-category',
|
||||
'integration'
|
||||
],
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve workflow details using MCP handler
|
||||
const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const details = (response.data as any).workflow;
|
||||
|
||||
// Verify metadata is present (tags may be undefined in API response)
|
||||
// Note: n8n API behavior for tags varies - they may not be returned
|
||||
// in GET requests even if set during creation
|
||||
if (details.tags) {
|
||||
expect(details.tags.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// Verify settings
|
||||
expect(details.settings).toBeDefined();
|
||||
expect(details.settings!.executionOrder).toBe('v1');
|
||||
expect(details.settings!.timezone).toBe('America/New_York');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Version History
|
||||
// ======================================================================
|
||||
|
||||
describe('Version History', () => {
|
||||
it('should track version changes after updates', async () => {
|
||||
// Create initial workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Details - Version History'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Get initial version using MCP handler
|
||||
const initialResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext);
|
||||
expect(initialResponse.success).toBe(true);
|
||||
const initialDetails = (initialResponse.data as any).workflow;
|
||||
const initialVersionId = initialDetails.versionId;
|
||||
const initialUpdatedAt = initialDetails.updatedAt;
|
||||
|
||||
// Update the workflow
|
||||
await client.updateWorkflow(created.id, {
|
||||
name: createTestWorkflowName('Get Details - Version History (Updated)'),
|
||||
nodes: workflow.nodes,
|
||||
connections: workflow.connections
|
||||
});
|
||||
|
||||
// Get updated details using MCP handler
|
||||
const updatedResponse = await handleGetWorkflowDetails({ id: created.id }, mcpContext);
|
||||
expect(updatedResponse.success).toBe(true);
|
||||
const updatedDetails = (updatedResponse.data as any).workflow;
|
||||
|
||||
// Verify version changed
|
||||
expect(updatedDetails.versionId).toBeDefined();
|
||||
expect(updatedDetails.updatedAt).not.toBe(initialUpdatedAt);
|
||||
|
||||
// Version ID should have changed after update
|
||||
expect(updatedDetails.versionId).not.toBe(initialVersionId);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Execution Statistics
|
||||
// ======================================================================
|
||||
|
||||
describe('Execution Statistics', () => {
|
||||
it('should include execution-related fields in details', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Details - Execution Stats'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve workflow details using MCP handler
|
||||
const response = await handleGetWorkflowDetails({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const details = (response.data as any).workflow;
|
||||
|
||||
// Verify execution-related fields exist
|
||||
// Note: New workflows won't have executions, but fields should be present
|
||||
expect(details).toHaveProperty('active');
|
||||
|
||||
// The workflow should start inactive
|
||||
expect(details.active).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
tests/integration/n8n-api/workflows/get-workflow-minimal.test.ts
Normal file
137
tests/integration/n8n-api/workflows/get-workflow-minimal.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Integration Tests: handleGetWorkflowMinimal
|
||||
*
|
||||
* Tests minimal workflow data retrieval against a real n8n instance.
|
||||
* Returns only ID, name, active status, and tags for fast listing operations.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleGetWorkflowMinimal } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleGetWorkflowMinimal', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Inactive Workflow
|
||||
// ======================================================================
|
||||
|
||||
describe('Inactive Workflow', () => {
|
||||
it('should retrieve minimal data for inactive workflow', async () => {
|
||||
// Create workflow (starts inactive by default)
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Minimal - Inactive'),
|
||||
tags: [
|
||||
'mcp-integration-test',
|
||||
'minimal-test'
|
||||
]
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve minimal workflow data
|
||||
const response = await handleGetWorkflowMinimal({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const minimal = response.data as any;
|
||||
|
||||
// Verify only minimal fields are present
|
||||
expect(minimal).toBeDefined();
|
||||
expect(minimal.id).toBe(created.id);
|
||||
expect(minimal.name).toBe(workflow.name);
|
||||
expect(minimal.active).toBe(false);
|
||||
|
||||
// Verify tags field (may be undefined in API response)
|
||||
// Note: n8n API may not return tags in minimal workflow view
|
||||
if (minimal.tags) {
|
||||
expect(minimal.tags.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// Verify nodes and connections are NOT included (minimal response)
|
||||
// Note: Some implementations may include these fields. This test
|
||||
// documents the actual API behavior.
|
||||
if (minimal.nodes !== undefined) {
|
||||
// If nodes are included, it's acceptable - just verify structure
|
||||
expect(Array.isArray(minimal.nodes)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Active Workflow
|
||||
// ======================================================================
|
||||
|
||||
describe('Active Workflow', () => {
|
||||
it('should retrieve minimal data showing active status', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Minimal - Active'),
|
||||
tags: [
|
||||
'mcp-integration-test',
|
||||
'minimal-test-active'
|
||||
]
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Note: n8n API doesn't support workflow activation via API
|
||||
// So we can only test inactive workflows in automated tests
|
||||
// The active field should still be present and set to false
|
||||
|
||||
// Retrieve minimal workflow data
|
||||
const response = await handleGetWorkflowMinimal({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const minimal = response.data as any;
|
||||
|
||||
// Verify minimal fields
|
||||
expect(minimal).toBeDefined();
|
||||
expect(minimal.id).toBe(created.id);
|
||||
expect(minimal.name).toBe(workflow.name);
|
||||
|
||||
// Verify active field exists
|
||||
expect(minimal).toHaveProperty('active');
|
||||
|
||||
// New workflows are inactive by default (can't be activated via API)
|
||||
expect(minimal.active).toBe(false);
|
||||
|
||||
// This test documents the limitation: we can verify the field exists
|
||||
// and correctly shows inactive status, but can't test active workflows
|
||||
// without manual intervention in the n8n UI.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Integration Tests: handleGetWorkflowStructure
|
||||
*
|
||||
* Tests workflow structure retrieval against a real n8n instance.
|
||||
* Verifies that only nodes and connections are returned (no parameter data).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW, MULTI_NODE_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleGetWorkflowStructure } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleGetWorkflowStructure', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Simple Workflow Structure
|
||||
// ======================================================================
|
||||
|
||||
describe('Simple Workflow', () => {
|
||||
it('should retrieve workflow structure with nodes and connections', async () => {
|
||||
// Create a simple workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Structure - Simple'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve workflow structure
|
||||
const response = await handleGetWorkflowStructure({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const structure = response.data as any;
|
||||
|
||||
// Verify structure contains basic info
|
||||
expect(structure).toBeDefined();
|
||||
expect(structure.id).toBe(created.id);
|
||||
expect(structure.name).toBe(workflow.name);
|
||||
|
||||
// Verify nodes are present
|
||||
expect(structure.nodes).toBeDefined();
|
||||
expect(structure.nodes).toHaveLength(workflow.nodes!.length);
|
||||
|
||||
// Verify connections are present
|
||||
expect(structure.connections).toBeDefined();
|
||||
|
||||
// Verify node structure (names and types should be present)
|
||||
const node = structure.nodes[0];
|
||||
expect(node.id).toBeDefined();
|
||||
expect(node.name).toBeDefined();
|
||||
expect(node.type).toBeDefined();
|
||||
expect(node.position).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Complex Workflow Structure
|
||||
// ======================================================================
|
||||
|
||||
describe('Complex Workflow', () => {
|
||||
it('should retrieve complex workflow structure without exposing sensitive parameter data', async () => {
|
||||
// Create a complex workflow with multiple nodes
|
||||
const workflow = {
|
||||
...MULTI_NODE_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Structure - Complex'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve workflow structure
|
||||
const response = await handleGetWorkflowStructure({ id: created.id }, mcpContext);
|
||||
expect(response.success).toBe(true);
|
||||
const structure = response.data as any;
|
||||
|
||||
// Verify structure contains all nodes
|
||||
expect(structure.nodes).toBeDefined();
|
||||
expect(structure.nodes).toHaveLength(workflow.nodes!.length);
|
||||
|
||||
// Verify all connections are present
|
||||
expect(structure.connections).toBeDefined();
|
||||
expect(Object.keys(structure.connections).length).toBeGreaterThan(0);
|
||||
|
||||
// Verify each node has basic structure
|
||||
structure.nodes.forEach((node: any) => {
|
||||
expect(node.id).toBeDefined();
|
||||
expect(node.name).toBeDefined();
|
||||
expect(node.type).toBeDefined();
|
||||
expect(node.position).toBeDefined();
|
||||
// typeVersion may be undefined depending on API behavior
|
||||
if (node.typeVersion !== undefined) {
|
||||
expect(typeof node.typeVersion).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
// Note: The actual n8n API's getWorkflowStructure endpoint behavior
|
||||
// may vary. Some implementations return minimal data, others return
|
||||
// full workflow data. This test documents the actual behavior.
|
||||
//
|
||||
// If parameters are included, it's acceptable (not all APIs have
|
||||
// a dedicated "structure-only" endpoint). The test verifies that
|
||||
// the essential structural information is present.
|
||||
});
|
||||
});
|
||||
});
|
||||
114
tests/integration/n8n-api/workflows/get-workflow.test.ts
Normal file
114
tests/integration/n8n-api/workflows/get-workflow.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Integration Tests: handleGetWorkflow
|
||||
*
|
||||
* Tests workflow retrieval against a real n8n instance.
|
||||
* Covers successful retrieval and error handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleGetWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleGetWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Successful Retrieval
|
||||
// ======================================================================
|
||||
|
||||
describe('Successful Retrieval', () => {
|
||||
it('should retrieve complete workflow data', async () => {
|
||||
// Create a workflow first
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Get Workflow - Complete Data'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.id).toBeTruthy();
|
||||
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Retrieve the workflow using MCP handler
|
||||
const response = await handleGetWorkflow({ id: created.id }, mcpContext);
|
||||
|
||||
// Verify MCP response structure
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const retrieved = response.data as Workflow;
|
||||
|
||||
// Verify all expected fields are present
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved.id).toBe(created.id);
|
||||
expect(retrieved.name).toBe(workflow.name);
|
||||
expect(retrieved.nodes).toBeDefined();
|
||||
expect(retrieved.nodes).toHaveLength(workflow.nodes!.length);
|
||||
expect(retrieved.connections).toBeDefined();
|
||||
expect(retrieved.active).toBeDefined();
|
||||
expect(retrieved.createdAt).toBeDefined();
|
||||
expect(retrieved.updatedAt).toBeDefined();
|
||||
|
||||
// Verify node data integrity
|
||||
const retrievedNode = retrieved.nodes[0];
|
||||
const originalNode = workflow.nodes![0];
|
||||
expect(retrievedNode.name).toBe(originalNode.name);
|
||||
expect(retrievedNode.type).toBe(originalNode.type);
|
||||
expect(retrievedNode.parameters).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should return error for non-existent workflow (invalid ID)', async () => {
|
||||
const invalidId = '99999999';
|
||||
|
||||
const response = await handleGetWorkflow({ id: invalidId }, mcpContext);
|
||||
|
||||
// MCP handlers return success: false on error
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error for malformed workflow ID', async () => {
|
||||
const malformedId = 'not-a-valid-id-format';
|
||||
|
||||
const response = await handleGetWorkflow({ id: malformedId }, mcpContext);
|
||||
|
||||
// MCP handlers return success: false on error
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
438
tests/integration/n8n-api/workflows/list-workflows.test.ts
Normal file
438
tests/integration/n8n-api/workflows/list-workflows.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Integration Tests: handleListWorkflows
|
||||
*
|
||||
* Tests workflow listing against a real n8n instance.
|
||||
* Covers filtering, pagination, and various list parameters.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW, SIMPLE_HTTP_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleListWorkflows } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleListWorkflows', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// No Filters
|
||||
// ======================================================================
|
||||
|
||||
describe('No Filters', () => {
|
||||
it('should list all workflows without filters', async () => {
|
||||
// Create test workflows
|
||||
const workflow1 = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - All 1'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const workflow2 = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('List - All 2'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created1 = await client.createWorkflow(workflow1);
|
||||
const created2 = await client.createWorkflow(workflow2);
|
||||
context.trackWorkflow(created1.id!);
|
||||
context.trackWorkflow(created2.id!);
|
||||
|
||||
// List workflows without filters
|
||||
const response = await handleListWorkflows({}, mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const data = response.data as any;
|
||||
expect(Array.isArray(data.workflows)).toBe(true);
|
||||
expect(data.workflows.length).toBeGreaterThan(0);
|
||||
|
||||
// Our workflows should be in the list
|
||||
const workflow1Found = data.workflows.find((w: any) => w.id === created1.id);
|
||||
const workflow2Found = data.workflows.find((w: any) => w.id === created2.id);
|
||||
expect(workflow1Found).toBeDefined();
|
||||
expect(workflow2Found).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Filter by Active Status
|
||||
// ======================================================================
|
||||
|
||||
describe('Filter by Active Status', () => {
|
||||
it('should filter workflows by active=true', async () => {
|
||||
// Create active workflow
|
||||
const activeWorkflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - Active'),
|
||||
active: true,
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(activeWorkflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Activate workflow
|
||||
await client.updateWorkflow(created.id!, {
|
||||
...activeWorkflow,
|
||||
active: true
|
||||
});
|
||||
|
||||
// List active workflows
|
||||
const response = await handleListWorkflows(
|
||||
{ active: true },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// All returned workflows should be active
|
||||
data.workflows.forEach((w: any) => {
|
||||
expect(w.active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter workflows by active=false', async () => {
|
||||
// Create inactive workflow
|
||||
const inactiveWorkflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - Inactive'),
|
||||
active: false,
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(inactiveWorkflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// List inactive workflows
|
||||
const response = await handleListWorkflows(
|
||||
{ active: false },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// All returned workflows should be inactive
|
||||
data.workflows.forEach((w: any) => {
|
||||
expect(w.active).toBe(false);
|
||||
});
|
||||
|
||||
// Our workflow should be in the list
|
||||
const found = data.workflows.find((w: any) => w.id === created.id);
|
||||
expect(found).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Filter by Tags
|
||||
// ======================================================================
|
||||
|
||||
describe('Filter by Tags', () => {
|
||||
it('should filter workflows by name instead of tags', async () => {
|
||||
// Note: Tags filtering requires tag IDs, not names, and tags are readonly in workflow creation
|
||||
// This test filters by name instead, which is more reliable for integration testing
|
||||
const uniqueName = createTestWorkflowName('List - Name Filter Test');
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: uniqueName,
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// List all workflows and verify ours is included
|
||||
const response = await handleListWorkflows({}, mcpContext);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Our workflow should be in the list
|
||||
const found = data.workflows.find((w: any) => w.id === created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.name).toBe(uniqueName);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Pagination
|
||||
// ======================================================================
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should return first page with limit', async () => {
|
||||
// Create multiple workflows
|
||||
const workflows = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName(`List - Page ${i}`),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
workflows.push(created);
|
||||
}
|
||||
|
||||
// List first page with limit
|
||||
const response = await handleListWorkflows(
|
||||
{ limit: 2 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.workflows.length).toBeLessThanOrEqual(2);
|
||||
expect(data.hasMore).toBeDefined();
|
||||
expect(data.nextCursor).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle pagination with cursor', async () => {
|
||||
// Create multiple workflows
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName(`List - Cursor ${i}`),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const firstPage = await handleListWorkflows(
|
||||
{ limit: 2 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(firstPage.success).toBe(true);
|
||||
const firstData = firstPage.data as any;
|
||||
|
||||
if (firstData.hasMore && firstData.nextCursor) {
|
||||
// Get second page using cursor
|
||||
const secondPage = await handleListWorkflows(
|
||||
{ limit: 2, cursor: firstData.nextCursor },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(secondPage.success).toBe(true);
|
||||
const secondData = secondPage.data as any;
|
||||
|
||||
// Second page should have different workflows
|
||||
const firstIds = new Set(firstData.workflows.map((w: any) => w.id));
|
||||
const secondIds = secondData.workflows.map((w: any) => w.id);
|
||||
|
||||
secondIds.forEach((id: string) => {
|
||||
expect(firstIds.has(id)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle last page (no more results)', async () => {
|
||||
// Create single workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - Last Page'),
|
||||
tags: ['mcp-integration-test', 'unique-last-page-tag']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// List with high limit and unique tag
|
||||
const response = await handleListWorkflows(
|
||||
{
|
||||
tags: ['unique-last-page-tag'],
|
||||
limit: 100
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Should not have more results
|
||||
expect(data.hasMore).toBe(false);
|
||||
expect(data.workflows.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Limit Variations
|
||||
// ======================================================================
|
||||
|
||||
describe('Limit Variations', () => {
|
||||
it('should respect limit=1', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - Limit 1'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// List with limit=1
|
||||
const response = await handleListWorkflows(
|
||||
{ limit: 1 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.workflows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect limit=50', async () => {
|
||||
// List with limit=50
|
||||
const response = await handleListWorkflows(
|
||||
{ limit: 50 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.workflows.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should respect limit=100 (max)', async () => {
|
||||
// List with limit=100
|
||||
const response = await handleListWorkflows(
|
||||
{ limit: 100 },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(data.workflows.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Exclude Pinned Data
|
||||
// ======================================================================
|
||||
|
||||
describe('Exclude Pinned Data', () => {
|
||||
it('should exclude pinned data when requested', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('List - No Pinned Data'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// List with excludePinnedData=true
|
||||
const response = await handleListWorkflows(
|
||||
{ excludePinnedData: true },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Verify response doesn't include pinned data
|
||||
data.workflows.forEach((w: any) => {
|
||||
expect(w.pinData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Empty Results
|
||||
// ======================================================================
|
||||
|
||||
describe('Empty Results', () => {
|
||||
it('should return empty array when no workflows match filters', async () => {
|
||||
// List with non-existent tag
|
||||
const response = await handleListWorkflows(
|
||||
{ tags: ['non-existent-tag-xyz-12345'] },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
expect(Array.isArray(data.workflows)).toBe(true);
|
||||
expect(data.workflows.length).toBe(0);
|
||||
expect(data.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Sort Order Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Sort Order', () => {
|
||||
it('should return workflows in consistent order', async () => {
|
||||
// Create multiple workflows
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName(`List - Sort ${i}`),
|
||||
tags: ['mcp-integration-test', 'sort-test']
|
||||
};
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// List workflows twice
|
||||
const response1 = await handleListWorkflows(
|
||||
{ tags: ['sort-test'] },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
const response2 = await handleListWorkflows(
|
||||
{ tags: ['sort-test'] },
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response1.success).toBe(true);
|
||||
expect(response2.success).toBe(true);
|
||||
|
||||
const data1 = response1.data as any;
|
||||
const data2 = response2.data as any;
|
||||
|
||||
// Same workflows should be returned in same order
|
||||
expect(data1.workflows.length).toBe(data2.workflows.length);
|
||||
|
||||
const ids1 = data1.workflows.map((w: any) => w.id);
|
||||
const ids2 = data2.workflows.map((w: any) => w.id);
|
||||
|
||||
expect(ids1).toEqual(ids2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,870 @@
|
||||
/**
|
||||
* Integration Tests: handleUpdatePartialWorkflow
|
||||
*
|
||||
* Tests diff-based partial workflow updates against a real n8n instance.
|
||||
* Covers all 15 operation types: node operations (6), connection operations (5),
|
||||
* and metadata operations (4).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW, SIMPLE_HTTP_WORKFLOW, MULTI_NODE_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleUpdatePartialWorkflow } from '../../../../src/mcp/handlers-workflow-diff';
|
||||
|
||||
describe('Integration: handleUpdatePartialWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// NODE OPERATIONS (6 operations)
|
||||
// ======================================================================
|
||||
|
||||
describe('Node Operations', () => {
|
||||
describe('addNode', () => {
|
||||
it('should add a new node to workflow', async () => {
|
||||
// Create simple workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Add Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Add a Set node
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-1',
|
||||
name: 'test',
|
||||
value: 'value',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.nodes).toHaveLength(2);
|
||||
expect(updated.nodes.find((n: any) => n.name === 'Set')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error for duplicate node name', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Duplicate Node Name'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Try to add node with same name as existing
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Webhook', // Duplicate name
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300],
|
||||
parameters: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNode', () => {
|
||||
it('should remove node by name', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Remove Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Remove HTTP Request node by name
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeNode',
|
||||
nodeName: 'HTTP Request'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.nodes).toHaveLength(1);
|
||||
expect(updated.nodes.find((n: any) => n.name === 'HTTP Request')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return error for non-existent node', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Remove Non-existent'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeNode',
|
||||
nodeName: 'NonExistentNode'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNode', () => {
|
||||
it('should update node parameters', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Update Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Update webhook path
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateNode',
|
||||
nodeName: 'Webhook',
|
||||
updates: {
|
||||
'parameters.path': 'updated-path'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
const webhookNode = updated.nodes.find((n: any) => n.name === 'Webhook');
|
||||
expect(webhookNode.parameters.path).toBe('updated-path');
|
||||
});
|
||||
|
||||
it('should update nested parameters', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Update Nested'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateNode',
|
||||
nodeName: 'Webhook',
|
||||
updates: {
|
||||
'parameters.httpMethod': 'POST',
|
||||
'parameters.path': 'new-path'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
const webhookNode = updated.nodes.find((n: any) => n.name === 'Webhook');
|
||||
expect(webhookNode.parameters.httpMethod).toBe('POST');
|
||||
expect(webhookNode.parameters.path).toBe('new-path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveNode', () => {
|
||||
it('should move node to new position', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Move Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const newPosition: [number, number] = [500, 500];
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'moveNode',
|
||||
nodeName: 'Webhook',
|
||||
position: newPosition
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
const webhookNode = updated.nodes.find((n: any) => n.name === 'Webhook');
|
||||
expect(webhookNode.position).toEqual(newPosition);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableNode / disableNode', () => {
|
||||
it('should disable a node', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Disable Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'disableNode',
|
||||
nodeName: 'Webhook'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
const webhookNode = updated.nodes.find((n: any) => n.name === 'Webhook');
|
||||
expect(webhookNode.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable a disabled node', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Enable Node'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// First disable the node
|
||||
await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [{ type: 'disableNode', nodeName: 'Webhook' }]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Then enable it
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'enableNode',
|
||||
nodeName: 'Webhook'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
const webhookNode = updated.nodes.find((n: any) => n.name === 'Webhook');
|
||||
// After enabling, disabled should be false or undefined (both mean enabled)
|
||||
expect(webhookNode.disabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// CONNECTION OPERATIONS (5 operations)
|
||||
// ======================================================================
|
||||
|
||||
describe('Connection Operations', () => {
|
||||
describe('addConnection', () => {
|
||||
it('should add connection between nodes', async () => {
|
||||
// Start with workflow without connections
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Add Connection'),
|
||||
tags: ['mcp-integration-test'],
|
||||
connections: {} // Start with no connections
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Add connection
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'Webhook',
|
||||
target: 'HTTP Request'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.connections).toBeDefined();
|
||||
expect(updated.connections.Webhook).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add connection with custom ports', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Add Connection Ports'),
|
||||
tags: ['mcp-integration-test'],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'Webhook',
|
||||
target: 'HTTP Request',
|
||||
sourceOutput: 'main',
|
||||
targetInput: 'main',
|
||||
sourceIndex: 0,
|
||||
targetIndex: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeConnection', () => {
|
||||
it('should remove connection between nodes', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Remove Connection'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Webhook',
|
||||
target: 'HTTP Request'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(Object.keys(updated.connections || {})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should ignore error for non-existent connection with ignoreErrors flag', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Remove Connection Ignore'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Webhook',
|
||||
target: 'NonExistent',
|
||||
ignoreErrors: true
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Should succeed because ignoreErrors is true
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceConnections', () => {
|
||||
it('should replace all connections', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Replace Connections'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Replace with empty connections
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'replaceConnections',
|
||||
connections: {}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(Object.keys(updated.connections || {})).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanStaleConnections', () => {
|
||||
it('should remove stale connections in dry run mode', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Clean Stale Dry Run'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Remove HTTP Request node to create stale connection
|
||||
await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [{ type: 'removeNode', nodeName: 'HTTP Request' }]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Clean stale connections in dry run
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'cleanStaleConnections',
|
||||
dryRun: true
|
||||
}
|
||||
],
|
||||
validateOnly: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// METADATA OPERATIONS (4 operations)
|
||||
// ======================================================================
|
||||
|
||||
describe('Metadata Operations', () => {
|
||||
describe('updateSettings', () => {
|
||||
it('should update workflow settings', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Update Settings'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateSettings',
|
||||
settings: {
|
||||
timezone: 'America/New_York',
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
|
||||
// Note: n8n API may not return all settings in response
|
||||
// The operation should succeed even if settings aren't reflected in the response
|
||||
expect(updated.settings).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateName', () => {
|
||||
it('should update workflow name', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Update Name Original'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const newName = createTestWorkflowName('Partial - Update Name Modified');
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateName',
|
||||
name: newName
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.name).toBe(newName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTag / removeTag', () => {
|
||||
it('should add tag to workflow', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Add Tag'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addTag',
|
||||
tag: 'new-tag'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
|
||||
// Note: n8n API tag behavior may vary
|
||||
if (updated.tags) {
|
||||
expect(updated.tags).toContain('new-tag');
|
||||
}
|
||||
});
|
||||
|
||||
it('should remove tag from workflow', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Remove Tag'),
|
||||
tags: ['mcp-integration-test', 'to-remove']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeTag',
|
||||
tag: 'to-remove'
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
|
||||
if (updated.tags) {
|
||||
expect(updated.tags).not.toContain('to-remove');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// ADVANCED SCENARIOS
|
||||
// ======================================================================
|
||||
|
||||
describe('Advanced Scenarios', () => {
|
||||
it('should apply multiple operations in sequence', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Multiple Ops'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
assignments: { assignments: [] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'Webhook',
|
||||
target: 'Set'
|
||||
},
|
||||
{
|
||||
type: 'updateName',
|
||||
name: createTestWorkflowName('Partial - Multiple Ops Updated')
|
||||
}
|
||||
]
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.nodes).toHaveLength(2);
|
||||
expect(updated.connections.Webhook).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate operations without applying (validateOnly mode)', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Validate Only'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateName',
|
||||
name: 'New Name'
|
||||
}
|
||||
],
|
||||
validateOnly: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toHaveProperty('valid', true);
|
||||
|
||||
// Verify workflow was NOT actually updated
|
||||
const current = await client.getWorkflow(created.id);
|
||||
expect(current.name).not.toBe('New Name');
|
||||
});
|
||||
|
||||
it('should handle continueOnError mode with partial failures', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Partial - Continue On Error'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Mix valid and invalid operations
|
||||
const response = await handleUpdatePartialWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'updateName',
|
||||
name: createTestWorkflowName('Partial - Continue On Error Updated')
|
||||
},
|
||||
{
|
||||
type: 'removeNode',
|
||||
nodeName: 'NonExistentNode' // This will fail
|
||||
},
|
||||
{
|
||||
type: 'addTag',
|
||||
tag: 'new-tag'
|
||||
}
|
||||
],
|
||||
continueOnError: true
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Should succeed with partial results
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.details?.applied).toBeDefined();
|
||||
expect(response.details?.failed).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
338
tests/integration/n8n-api/workflows/update-workflow.test.ts
Normal file
338
tests/integration/n8n-api/workflows/update-workflow.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Integration Tests: handleUpdateWorkflow
|
||||
*
|
||||
* Tests full workflow updates against a real n8n instance.
|
||||
* Covers various update scenarios including nodes, connections, settings, and tags.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW, SIMPLE_HTTP_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleUpdateWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
|
||||
describe('Integration: handleUpdateWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Full Workflow Replacement
|
||||
// ======================================================================
|
||||
|
||||
describe('Full Workflow Replacement', () => {
|
||||
it('should replace entire workflow with new nodes and connections', async () => {
|
||||
// Create initial simple workflow
|
||||
const initialWorkflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Full Replacement'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(initialWorkflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Replace with HTTP workflow (completely different structure)
|
||||
const replacement = {
|
||||
...SIMPLE_HTTP_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Full Replacement (Updated)')
|
||||
};
|
||||
|
||||
// Update using MCP handler
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
name: replacement.name,
|
||||
nodes: replacement.nodes,
|
||||
connections: replacement.connections
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Verify MCP response
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toBeDefined();
|
||||
|
||||
const updated = response.data as any;
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.name).toBe(replacement.name);
|
||||
expect(updated.nodes).toHaveLength(2); // HTTP workflow has 2 nodes
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Update Nodes
|
||||
// ======================================================================
|
||||
|
||||
describe('Update Nodes', () => {
|
||||
it('should update workflow nodes while preserving other properties', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Nodes Only'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Update nodes - add a second node
|
||||
const updatedNodes = [
|
||||
...workflow.nodes!,
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-1',
|
||||
name: 'test',
|
||||
value: 'value',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const updatedConnections = {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Set', type: 'main' as const, index: 0 }]]
|
||||
}
|
||||
};
|
||||
|
||||
// Update using MCP handler (n8n API requires name, nodes, connections)
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
name: workflow.name, // Required by n8n API
|
||||
nodes: updatedNodes,
|
||||
connections: updatedConnections
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.nodes).toHaveLength(2);
|
||||
expect(updated.nodes.find((n: any) => n.name === 'Set')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Update Settings
|
||||
// ======================================================================
|
||||
// Note: "Update Connections" test removed - empty connections invalid for multi-node workflows
|
||||
// Connection modifications are tested in update-partial-workflow.test.ts
|
||||
|
||||
describe('Update Settings', () => {
|
||||
it('should update workflow settings without affecting nodes', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Settings'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Fetch current workflow (n8n API requires name, nodes, connections)
|
||||
const current = await client.getWorkflow(created.id);
|
||||
|
||||
// Update settings
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
name: current.name, // Required by n8n API
|
||||
nodes: current.nodes, // Required by n8n API
|
||||
connections: current.connections, // Required by n8n API
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
timezone: 'Europe/London'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
// Note: n8n API may not return settings in response
|
||||
expect(updated.nodes).toHaveLength(1); // Nodes unchanged
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// Validation Errors
|
||||
// ======================================================================
|
||||
|
||||
describe('Validation Errors', () => {
|
||||
it('should return error for invalid node types', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Invalid Node Type'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
// Try to update with invalid node type
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
nodes: [
|
||||
{
|
||||
id: 'invalid-1',
|
||||
name: 'Invalid',
|
||||
type: 'invalid-node-type',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Validation should fail
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error for non-existent workflow ID', async () => {
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: '99999999',
|
||||
name: 'Should Fail'
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Update Name Only
|
||||
// ======================================================================
|
||||
|
||||
describe('Update Name', () => {
|
||||
it('should update workflow name without affecting structure', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Name Original'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const newName = createTestWorkflowName('Update - Name Modified');
|
||||
|
||||
// Fetch current workflow to get required fields
|
||||
const current = await client.getWorkflow(created.id);
|
||||
|
||||
// Update name (n8n API requires nodes and connections too)
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
name: newName,
|
||||
nodes: current.nodes, // Required by n8n API
|
||||
connections: current.connections // Required by n8n API
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.name).toBe(newName);
|
||||
expect(updated.nodes).toHaveLength(1); // Structure unchanged
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Multiple Properties Update
|
||||
// ======================================================================
|
||||
|
||||
describe('Multiple Properties', () => {
|
||||
it('should update name and settings together', async () => {
|
||||
// Create workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Update - Multiple Props'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
expect(created.id).toBeTruthy();
|
||||
if (!created.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(created.id);
|
||||
|
||||
const newName = createTestWorkflowName('Update - Multiple Props (Modified)');
|
||||
|
||||
// Fetch current workflow (n8n API requires nodes and connections)
|
||||
const current = await client.getWorkflow(created.id);
|
||||
|
||||
// Update multiple properties
|
||||
const response = await handleUpdateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
name: newName,
|
||||
nodes: current.nodes, // Required by n8n API
|
||||
connections: current.connections, // Required by n8n API
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const updated = response.data as any;
|
||||
expect(updated.name).toBe(newName);
|
||||
expect(updated.settings?.timezone).toBe('America/New_York');
|
||||
});
|
||||
});
|
||||
});
|
||||
432
tests/integration/n8n-api/workflows/validate-workflow.test.ts
Normal file
432
tests/integration/n8n-api/workflows/validate-workflow.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Integration Tests: handleValidateWorkflow
|
||||
*
|
||||
* Tests workflow validation against a real n8n instance.
|
||||
* Covers validation profiles, validation types, and error detection.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context';
|
||||
import { getTestN8nClient } from '../utils/n8n-client';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { SIMPLE_WEBHOOK_WORKFLOW } from '../utils/fixtures';
|
||||
import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers';
|
||||
import { createMcpContext } from '../utils/mcp-context';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { handleValidateWorkflow } from '../../../../src/mcp/handlers-n8n-manager';
|
||||
import { getNodeRepository, closeNodeRepository } from '../utils/node-repository';
|
||||
import { NodeRepository } from '../../../../src/database/node-repository';
|
||||
import { ValidationResponse } from '../types/mcp-responses';
|
||||
|
||||
describe('Integration: handleValidateWorkflow', () => {
|
||||
let context: TestContext;
|
||||
let client: N8nApiClient;
|
||||
let mcpContext: InstanceContext;
|
||||
let repository: NodeRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = createTestContext();
|
||||
client = getTestN8nClient();
|
||||
mcpContext = createMcpContext();
|
||||
repository = await getNodeRepository();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeNodeRepository();
|
||||
if (!process.env.CI) {
|
||||
await cleanupOrphanedWorkflows();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Valid Workflow - All Profiles
|
||||
// ======================================================================
|
||||
|
||||
describe('Valid Workflow', () => {
|
||||
it('should validate valid workflow with default profile (runtime)', async () => {
|
||||
// Create valid workflow
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Valid Default'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
// Validate with default profile
|
||||
const response = await handleValidateWorkflow(
|
||||
{ id: created.id },
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as ValidationResponse;
|
||||
|
||||
// Verify response structure
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.errors).toBeUndefined(); // Only present if errors exist
|
||||
expect(data.summary).toBeDefined();
|
||||
expect(data.summary.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should validate with strict profile', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Valid Strict'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: { profile: 'strict' }
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with ai-friendly profile', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Valid AI Friendly'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: { profile: 'ai-friendly' }
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with minimal profile', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Valid Minimal'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: { profile: 'minimal' }
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Invalid Workflow - Error Detection
|
||||
// ======================================================================
|
||||
|
||||
describe('Invalid Workflow Detection', () => {
|
||||
it('should detect invalid node type', async () => {
|
||||
// Create workflow with invalid node type
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Validate - Invalid Node Type'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'invalid-1',
|
||||
name: 'Invalid Node',
|
||||
type: 'invalid-node-type',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{ id: created.id },
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Should detect error
|
||||
expect(data.valid).toBe(false);
|
||||
expect(data.errors).toBeDefined();
|
||||
expect(data.errors.length).toBeGreaterThan(0);
|
||||
expect(data.summary.errorCount).toBeGreaterThan(0);
|
||||
|
||||
// Error should mention invalid node type
|
||||
const errorMessages = data.errors.map((e: any) => e.message).join(' ');
|
||||
expect(errorMessages).toMatch(/invalid-node-type|not found|unknown/i);
|
||||
});
|
||||
|
||||
it('should detect missing required connections', async () => {
|
||||
// Create workflow with 2 nodes but no connections
|
||||
const workflow = {
|
||||
name: createTestWorkflowName('Validate - Missing Connections'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}, // Empty connections - Set node is unreachable
|
||||
settings: {},
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{ id: created.id },
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Multi-node workflow with empty connections should produce warning/error
|
||||
// (depending on validation profile)
|
||||
expect(data.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Selective Validation
|
||||
// ======================================================================
|
||||
|
||||
describe('Selective Validation', () => {
|
||||
it('should validate nodes only (skip connections)', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Nodes Only'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: {
|
||||
validateNodes: true,
|
||||
validateConnections: false,
|
||||
validateExpressions: false
|
||||
}
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate connections only (skip nodes)', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Connections Only'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: {
|
||||
validateNodes: false,
|
||||
validateConnections: true,
|
||||
validateExpressions: false
|
||||
}
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate expressions only', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Expressions Only'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: {
|
||||
validateNodes: false,
|
||||
validateConnections: false,
|
||||
validateExpressions: true
|
||||
}
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
// Expression validation may pass even if workflow has other issues
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Error Handling
|
||||
// ======================================================================
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle non-existent workflow ID', async () => {
|
||||
const response = await handleValidateWorkflow(
|
||||
{ id: '99999999' },
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid profile parameter', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Invalid Profile'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{
|
||||
id: created.id,
|
||||
options: { profile: 'invalid-profile' as any }
|
||||
},
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
// Should either fail validation or use default profile
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Response Format Verification
|
||||
// ======================================================================
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return complete validation response structure', async () => {
|
||||
const workflow = {
|
||||
...SIMPLE_WEBHOOK_WORKFLOW,
|
||||
name: createTestWorkflowName('Validate - Response Format'),
|
||||
tags: ['mcp-integration-test']
|
||||
};
|
||||
|
||||
const created = await client.createWorkflow(workflow);
|
||||
context.trackWorkflow(created.id!);
|
||||
|
||||
const response = await handleValidateWorkflow(
|
||||
{ id: created.id },
|
||||
repository,
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const data = response.data as any;
|
||||
|
||||
// Verify required fields
|
||||
expect(data).toHaveProperty('workflowId');
|
||||
expect(data).toHaveProperty('workflowName');
|
||||
expect(data).toHaveProperty('valid');
|
||||
expect(data).toHaveProperty('summary');
|
||||
|
||||
// errors and warnings only present if they exist
|
||||
// For valid workflow, they should be undefined
|
||||
if (data.errors) {
|
||||
expect(Array.isArray(data.errors)).toBe(true);
|
||||
}
|
||||
if (data.warnings) {
|
||||
expect(Array.isArray(data.warnings)).toBe(true);
|
||||
}
|
||||
|
||||
// Verify summary structure
|
||||
expect(data.summary).toHaveProperty('errorCount');
|
||||
expect(data.summary).toHaveProperty('warningCount');
|
||||
expect(data.summary).toHaveProperty('totalNodes');
|
||||
expect(data.summary).toHaveProperty('enabledNodes');
|
||||
expect(data.summary).toHaveProperty('triggerNodes');
|
||||
|
||||
// Verify types
|
||||
expect(typeof data.valid).toBe('boolean');
|
||||
expect(typeof data.summary.errorCount).toBe('number');
|
||||
expect(typeof data.summary.warningCount).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -142,7 +142,8 @@ describe.skip('MCP Telemetry Integration', () => {
|
||||
telemetry.trackError(
|
||||
error.constructor.name,
|
||||
error.message,
|
||||
toolName
|
||||
toolName,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
313
tests/integration/workflow-creation-node-type-format.test.ts
Normal file
313
tests/integration/workflow-creation-node-type-format.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Integration test for workflow creation with node type format validation
|
||||
*
|
||||
* This test validates that workflows are correctly validated with FULL form node types
|
||||
* (n8n-nodes-base.*) as required by the n8n API, without normalization to SHORT form.
|
||||
*
|
||||
* Background: Bug in handlers-n8n-manager.ts was normalizing node types to SHORT form
|
||||
* (nodes-base.*) before validation, causing validation to reject all workflows.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateWorkflowStructure } from '@/services/n8n-validation';
|
||||
|
||||
describe('Workflow Creation Node Type Format (Integration)', () => {
|
||||
describe('validateWorkflowStructure with FULL form node types', () => {
|
||||
it('should accept workflows with FULL form node types (n8n-nodes-base.*)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger', // FULL form
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set', // FULL form
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: {
|
||||
assignments: [{
|
||||
id: '1',
|
||||
name: 'test',
|
||||
value: 'hello',
|
||||
type: 'string'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{
|
||||
node: 'Set Data',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reject workflows with SHORT form node types (nodes-base.*)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'nodes-base.manualTrigger', // SHORT form - should be rejected
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e =>
|
||||
e.includes('Invalid node type "nodes-base.manualTrigger"') &&
|
||||
e.includes('Use "n8n-nodes-base.manualTrigger" instead')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept workflows with LangChain nodes in FULL form', () => {
|
||||
const workflow = {
|
||||
name: 'AI Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent', // FULL form
|
||||
typeVersion: 1,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{
|
||||
node: 'AI Agent',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
// Should accept FULL form LangChain nodes
|
||||
// Note: May have other validation errors (missing parameters), but NOT node type errors
|
||||
const hasNodeTypeError = errors.some(e =>
|
||||
e.includes('Invalid node type') && e.includes('@n8n/n8n-nodes-langchain.agent')
|
||||
);
|
||||
expect(hasNodeTypeError).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject node types without package prefix', () => {
|
||||
const workflow = {
|
||||
name: 'Invalid Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Invalid Node',
|
||||
type: 'webhook', // No package prefix
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e =>
|
||||
e.includes('Invalid node type "webhook"') &&
|
||||
e.includes('must include package prefix')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world workflow examples', () => {
|
||||
it('should validate webhook workflow correctly', () => {
|
||||
const workflow = {
|
||||
name: 'Webhook to HTTP',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
parameters: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{
|
||||
node: 'HTTP Request',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should validate schedule trigger workflow correctly', () => {
|
||||
const workflow = {
|
||||
name: 'Daily Report',
|
||||
nodes: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [{
|
||||
field: 'days',
|
||||
daysInterval: 1
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: {
|
||||
assignments: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{
|
||||
node: 'Set',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression test for normalization bug', () => {
|
||||
it('should NOT normalize node types before validation', () => {
|
||||
// This test ensures that handleCreateWorkflow does NOT call
|
||||
// NodeTypeNormalizer.normalizeWorkflowNodeTypes() before validation
|
||||
|
||||
const fullFormWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: { assignments: [] }
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(fullFormWorkflow);
|
||||
|
||||
// FULL form should pass validation
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
// SHORT form (what normalizer produces) should FAIL validation
|
||||
const shortFormWorkflow = {
|
||||
...fullFormWorkflow,
|
||||
nodes: fullFormWorkflow.nodes.map(node => ({
|
||||
...node,
|
||||
type: node.type.replace('n8n-nodes-base.', 'nodes-base.') // Convert to SHORT form
|
||||
}))
|
||||
};
|
||||
|
||||
const shortFormErrors = validateWorkflowStructure(shortFormWorkflow);
|
||||
|
||||
expect(shortFormErrors.length).toBeGreaterThan(0);
|
||||
expect(shortFormErrors.some(e =>
|
||||
e.includes('Invalid node type') &&
|
||||
e.includes('nodes-base.')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,17 +13,27 @@ import { existsSync } from 'fs';
|
||||
export function loadTestEnvironment(): void {
|
||||
// CI Debug logging
|
||||
const isCI = process.env.CI === 'true';
|
||||
|
||||
|
||||
// First, load the main .env file (for integration tests that need real credentials)
|
||||
const mainEnvPath = path.resolve(process.cwd(), '.env');
|
||||
if (existsSync(mainEnvPath)) {
|
||||
dotenv.config({ path: mainEnvPath });
|
||||
if (isCI) {
|
||||
console.log('[CI-DEBUG] Loaded .env file from:', mainEnvPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Load base test environment
|
||||
const testEnvPath = path.resolve(process.cwd(), '.env.test');
|
||||
|
||||
|
||||
if (isCI) {
|
||||
console.log('[CI-DEBUG] Looking for .env.test at:', testEnvPath);
|
||||
console.log('[CI-DEBUG] File exists?', existsSync(testEnvPath));
|
||||
}
|
||||
|
||||
|
||||
if (existsSync(testEnvPath)) {
|
||||
const result = dotenv.config({ path: testEnvPath });
|
||||
// Don't override values from .env
|
||||
const result = dotenv.config({ path: testEnvPath, override: false });
|
||||
if (isCI && result.error) {
|
||||
console.error('[CI-DEBUG] Failed to load .env.test:', result.error);
|
||||
} else if (isCI && result.parsed) {
|
||||
@@ -39,9 +49,9 @@ export function loadTestEnvironment(): void {
|
||||
dotenv.config({ path: localEnvPath, override: true });
|
||||
}
|
||||
|
||||
// Set test-specific defaults
|
||||
// Set test-specific defaults (only if not already set)
|
||||
setTestDefaults();
|
||||
|
||||
|
||||
// Validate required environment variables
|
||||
validateTestEnvironment();
|
||||
}
|
||||
|
||||
@@ -231,20 +231,9 @@ describe('handlers-n8n-manager', () => {
|
||||
message: 'Workflow "Test Workflow" created successfully with ID: test-workflow-id',
|
||||
});
|
||||
|
||||
const expectedNormalizedInput = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'nodes-base.start',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
}],
|
||||
connections: testWorkflow.connections,
|
||||
};
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(expectedNormalizedInput);
|
||||
expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith(expectedNormalizedInput);
|
||||
// Should send input as-is to API (n8n expects FULL form: n8n-nodes-base.*)
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
@@ -280,9 +269,9 @@ describe('handlers-n8n-manager', () => {
|
||||
it('should handle API errors', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
@@ -317,6 +306,326 @@ describe('handlers-n8n-manager', () => {
|
||||
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('SHORT form detection', () => {
|
||||
it('should detect and reject nodes-base.* SHORT form', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('nodes-base.webhook');
|
||||
expect(result.details.errors[0]).toContain('n8n-nodes-base.webhook');
|
||||
expect(result.details.errors[0]).toContain('SHORT form');
|
||||
expect(result.details.errors[0]).toContain('FULL form');
|
||||
expect(result.details.hint).toBe('Use n8n-nodes-base.* instead of nodes-base.* for standard nodes');
|
||||
});
|
||||
|
||||
it('should detect and reject nodes-langchain.* SHORT form', async () => {
|
||||
const input = {
|
||||
name: 'AI Workflow',
|
||||
nodes: [{
|
||||
id: 'ai1',
|
||||
name: 'AI Agent',
|
||||
type: 'nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('AI Agent');
|
||||
expect(result.details.errors[0]).toContain('nodes-langchain.agent');
|
||||
expect(result.details.errors[0]).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result.details.errors[0]).toContain('SHORT form');
|
||||
expect(result.details.errors[0]).toContain('FULL form');
|
||||
expect(result.details.hint).toBe('Use n8n-nodes-base.* instead of nodes-base.* for standard nodes');
|
||||
});
|
||||
|
||||
it('should detect multiple SHORT form nodes', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'HTTP Request',
|
||||
type: 'nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node3',
|
||||
name: 'AI Agent',
|
||||
type: 'nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(3);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('n8n-nodes-base.webhook');
|
||||
expect(result.details.errors[1]).toContain('Node 1');
|
||||
expect(result.details.errors[1]).toContain('HTTP Request');
|
||||
expect(result.details.errors[1]).toContain('n8n-nodes-base.httpRequest');
|
||||
expect(result.details.errors[2]).toContain('Node 2');
|
||||
expect(result.details.errors[2]).toContain('AI Agent');
|
||||
expect(result.details.errors[2]).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should allow FULL form n8n-nodes-base.* without error', async () => {
|
||||
const testWorkflow = createTestWorkflow({
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}]
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: testWorkflow.nodes,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it('should allow FULL form @n8n/n8n-nodes-langchain.* without error', async () => {
|
||||
const testWorkflow = createTestWorkflow({
|
||||
nodes: [{
|
||||
id: 'ai1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}]
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'AI Workflow',
|
||||
nodes: testWorkflow.nodes,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it('should detect SHORT form in mixed FULL/SHORT workflow', async () => {
|
||||
const input = {
|
||||
name: 'Mixed Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start', // FULL form - correct
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook', // SHORT form - error
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 1');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('nodes-base.webhook');
|
||||
});
|
||||
|
||||
it('should handle nodes with null type gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Unknown',
|
||||
type: null,
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (null doesn't start with 'nodes-base.')
|
||||
// Will fail at structure validation or API call
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Node type is required'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle nodes with undefined type gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Unknown',
|
||||
// type is undefined
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (undefined doesn't start with 'nodes-base.')
|
||||
// Will fail at structure validation or API call
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Node type is required'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle empty nodes array gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Empty Workflow',
|
||||
nodes: [],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (no nodes to check)
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Workflow must have at least one node'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle nodes array with undefined nodes gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: undefined,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at Zod validation (nodes is required in schema)
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid input');
|
||||
expect(result.details).toHaveProperty('errors');
|
||||
});
|
||||
|
||||
it('should provide correct index in error message for multiple nodes', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start', // FULL form - OK
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Process',
|
||||
type: 'n8n-nodes-base.set', // FULL form - OK
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node3',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook', // SHORT form - index 2
|
||||
typeVersion: 1,
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 2'); // Zero-indexed
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetWorkflow', () => {
|
||||
@@ -414,6 +723,66 @@ describe('handlers-n8n-manager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteWorkflow', () => {
|
||||
it('should delete workflow successfully', async () => {
|
||||
const testWorkflow = createTestWorkflow();
|
||||
mockApiClient.deleteWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleDeleteWorkflow({ id: 'test-workflow-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: testWorkflow,
|
||||
message: 'Workflow test-workflow-id deleted successfully',
|
||||
});
|
||||
expect(mockApiClient.deleteWorkflow).toHaveBeenCalledWith('test-workflow-id');
|
||||
});
|
||||
|
||||
it('should handle invalid input', async () => {
|
||||
const result = await handlers.handleDeleteWorkflow({ notId: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid input');
|
||||
expect(result.details).toHaveProperty('errors');
|
||||
});
|
||||
|
||||
it('should handle N8nApiError', async () => {
|
||||
const apiError = new N8nNotFoundError('Workflow', 'non-existent-id');
|
||||
mockApiClient.deleteWorkflow.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleDeleteWorkflow({ id: 'non-existent-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Workflow with ID non-existent-id not found',
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic errors', async () => {
|
||||
const genericError = new Error('Database connection failed');
|
||||
mockApiClient.deleteWorkflow.mockRejectedValue(genericError);
|
||||
|
||||
const result = await handlers.handleDeleteWorkflow({ id: 'test-workflow-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Database connection failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API not configured error', async () => {
|
||||
vi.mocked(getN8nApiConfig).mockReturnValue(null);
|
||||
|
||||
const result = await handlers.handleDeleteWorkflow({ id: 'test-workflow-id' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleListWorkflows', () => {
|
||||
it('should list workflows with minimal data', async () => {
|
||||
const workflows = [
|
||||
@@ -461,6 +830,103 @@ describe('handlers-n8n-manager', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid input with ZodError', async () => {
|
||||
const result = await handlers.handleListWorkflows({
|
||||
limit: 'invalid', // Should be a number
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid input');
|
||||
expect(result.details).toHaveProperty('errors');
|
||||
});
|
||||
|
||||
it('should handle N8nApiError', async () => {
|
||||
const apiError = new N8nAuthenticationError('Invalid API key');
|
||||
mockApiClient.listWorkflows.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleListWorkflows({});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Failed to authenticate with n8n. Please check your API key.',
|
||||
code: 'AUTHENTICATION_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic errors', async () => {
|
||||
const genericError = new Error('Network timeout');
|
||||
mockApiClient.listWorkflows.mockRejectedValue(genericError);
|
||||
|
||||
const result = await handlers.handleListWorkflows({});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Network timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle workflows without isArchived field gracefully', async () => {
|
||||
const workflows = [
|
||||
createTestWorkflow({ id: 'wf1', name: 'Workflow 1' }),
|
||||
];
|
||||
// Remove isArchived field to test undefined handling
|
||||
delete (workflows[0] as any).isArchived;
|
||||
|
||||
mockApiClient.listWorkflows.mockResolvedValue({
|
||||
data: workflows,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await handlers.handleListWorkflows({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.workflows[0]).toHaveProperty('isArchived');
|
||||
});
|
||||
|
||||
it('should convert tags array to comma-separated string', async () => {
|
||||
const workflows = [
|
||||
createTestWorkflow({ id: 'wf1', name: 'Workflow 1', tags: ['tag1', 'tag2'] }),
|
||||
];
|
||||
|
||||
mockApiClient.listWorkflows.mockResolvedValue({
|
||||
data: workflows,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await handlers.handleListWorkflows({
|
||||
tags: ['production', 'active'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.listWorkflows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: 'production,active',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty tags array', async () => {
|
||||
const workflows = [
|
||||
createTestWorkflow({ id: 'wf1', name: 'Workflow 1' }),
|
||||
];
|
||||
|
||||
mockApiClient.listWorkflows.mockResolvedValue({
|
||||
data: workflows,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
const result = await handlers.handleListWorkflows({
|
||||
tags: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.listWorkflows).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleValidateWorkflow', () => {
|
||||
|
||||
@@ -381,12 +381,12 @@ describe('N8nApiClient', () => {
|
||||
});
|
||||
|
||||
it('should list workflows with custom params', async () => {
|
||||
const params = { limit: 10, active: true, tags: ['test'] };
|
||||
const params = { limit: 10, active: true, tags: 'test,production' };
|
||||
const response = { data: [], nextCursor: null };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
|
||||
const result = await client.listWorkflows(params);
|
||||
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/workflows', { params });
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
@@ -344,9 +344,9 @@ describe('n8n-validation', () => {
|
||||
expect(cleaned).not.toHaveProperty('shared');
|
||||
expect(cleaned).not.toHaveProperty('active');
|
||||
|
||||
// Should keep name but replace settings with empty object (n8n API limitation)
|
||||
// Should keep name and filter settings to safe properties
|
||||
expect(cleaned.name).toBe('Updated Workflow');
|
||||
expect(cleaned.settings).toEqual({});
|
||||
expect(cleaned.settings).toEqual({ executionOrder: 'v1' });
|
||||
});
|
||||
|
||||
it('should add empty settings object for cloud API compatibility', () => {
|
||||
@@ -360,7 +360,7 @@ describe('n8n-validation', () => {
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should replace settings with empty object to prevent API errors (Issue #248 - final fix)', () => {
|
||||
it('should filter settings to safe properties to prevent API errors (Issue #248 - final fix)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
@@ -368,36 +368,45 @@ describe('n8n-validation', () => {
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
saveDataSuccessExecution: 'none' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const,
|
||||
timeSavedPerExecution: 5, // UI-only property
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out (not in OpenAPI spec)
|
||||
timeSavedPerExecution: 5, // Filtered out (UI-only property)
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object (satisfies both API versions)
|
||||
expect(cleaned.settings).toEqual({});
|
||||
// Unsafe properties filtered out, safe properties kept
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v1',
|
||||
saveDataSuccessExecution: 'none'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
expect(cleaned.settings).not.toHaveProperty('timeSavedPerExecution');
|
||||
});
|
||||
|
||||
it('should replace settings with callerPolicy (Issue #248 - API limitation)', () => {
|
||||
it('should filter out callerPolicy (Issue #248 - API limitation)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out
|
||||
errorWorkflow: 'N2O2nZy3aUiBRGFN',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object (n8n API rejects updates with settings properties)
|
||||
expect(cleaned.settings).toEqual({});
|
||||
// callerPolicy filtered out (causes API errors), safe properties kept
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v1',
|
||||
errorWorkflow: 'N2O2nZy3aUiBRGFN'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
});
|
||||
|
||||
it('should replace all settings regardless of content (Issue #248 - API design)', () => {
|
||||
it('should filter all settings properties correctly (Issue #248 - API design)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
@@ -411,15 +420,25 @@ describe('n8n-validation', () => {
|
||||
saveExecutionProgress: false,
|
||||
executionTimeout: 300,
|
||||
errorWorkflow: 'error-workflow-id',
|
||||
callerPolicy: 'workflowsFromAList' as const,
|
||||
callerPolicy: 'workflowsFromAList' as const, // Filtered out (not in OpenAPI spec)
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object due to n8n API limitation (cannot update settings via API)
|
||||
// Safe properties kept, unsafe properties filtered out
|
||||
// See: https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
expect(cleaned.settings).toEqual({});
|
||||
expect(cleaned.settings).toEqual({
|
||||
executionOrder: 'v0',
|
||||
timezone: 'UTC',
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'none',
|
||||
saveManualExecutions: false,
|
||||
saveExecutionProgress: false,
|
||||
executionTimeout: 300,
|
||||
errorWorkflow: 'error-workflow-id'
|
||||
});
|
||||
expect(cleaned.settings).not.toHaveProperty('callerPolicy');
|
||||
});
|
||||
|
||||
it('should handle workflows without settings gracefully', () => {
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('TelemetryEventTracker', () => {
|
||||
|
||||
describe('trackError()', () => {
|
||||
it('should track error events without rate limiting', () => {
|
||||
eventTracker.trackError('ValidationError', 'Node configuration invalid', 'httpRequest');
|
||||
eventTracker.trackError('ValidationError', 'Node configuration invalid', 'httpRequest', 'Required field "url" is missing');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events).toHaveLength(1);
|
||||
@@ -202,34 +202,173 @@ describe('TelemetryEventTracker', () => {
|
||||
properties: {
|
||||
errorType: 'ValidationError',
|
||||
context: 'Node configuration invalid',
|
||||
tool: 'httpRequest'
|
||||
tool: 'httpRequest',
|
||||
error: 'Required field "url" is missing'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize error context', () => {
|
||||
const context = 'Failed to connect to https://api.example.com with key abc123def456ghi789jklmno0123456789';
|
||||
eventTracker.trackError('NetworkError', context);
|
||||
eventTracker.trackError('NetworkError', context, undefined, 'Connection timeout after 30s');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.context).toBe('Failed to connect to [URL] with key [KEY]');
|
||||
});
|
||||
|
||||
it('should sanitize error type', () => {
|
||||
eventTracker.trackError('Invalid$Error!Type', 'test context');
|
||||
eventTracker.trackError('Invalid$Error!Type', 'test context', undefined, 'Test error message');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.errorType).toBe('Invalid_Error_Type');
|
||||
});
|
||||
|
||||
it('should handle missing tool name', () => {
|
||||
eventTracker.trackError('TestError', 'test context');
|
||||
eventTracker.trackError('TestError', 'test context', undefined, 'No tool specified');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.tool).toBeNull(); // Validator converts undefined to null
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackError() with error messages', () => {
|
||||
it('should capture error messages in properties', () => {
|
||||
eventTracker.trackError('ValidationError', 'test', 'tool', 'Field "url" is required');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBe('Field "url" is required');
|
||||
});
|
||||
|
||||
it('should handle undefined error message', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', undefined);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBeNull(); // Validator converts undefined to null
|
||||
});
|
||||
|
||||
it('should sanitize API keys in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with api_key=sk_live_abc123def456');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('api_key=[REDACTED]');
|
||||
expect(events[0].properties.error).not.toContain('sk_live_abc123def456');
|
||||
});
|
||||
|
||||
it('should sanitize passwords in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Login failed: password=secret123');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('password=[REDACTED]');
|
||||
});
|
||||
|
||||
it('should sanitize long keys (32+ chars)', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Key: abc123def456ghi789jkl012mno345pqr678');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[KEY]');
|
||||
});
|
||||
|
||||
it('should sanitize URLs in error messages', () => {
|
||||
eventTracker.trackError('NetworkError', 'test', 'tool', 'Failed to fetch https://api.example.com/v1/users');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBe('Failed to fetch [URL]');
|
||||
expect(events[0].properties.error).not.toContain('api.example.com');
|
||||
expect(events[0].properties.error).not.toContain('/v1/users');
|
||||
});
|
||||
|
||||
it('should truncate very long error messages to 500 chars', () => {
|
||||
const longError = 'Error occurred while processing the request. ' + 'Additional context details. '.repeat(50);
|
||||
eventTracker.trackError('Error', 'test', 'tool', longError);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
expect(events[0].properties.error).toMatch(/\.\.\.$/);
|
||||
});
|
||||
|
||||
it('should handle stack traces by keeping first 3 lines', () => {
|
||||
const errorMsg = 'Error: Something failed\n at foo (/path/file.js:10:5)\n at bar (/path/file.js:20:10)\n at baz (/path/file.js:30:15)\n at qux (/path/file.js:40:20)';
|
||||
eventTracker.trackError('Error', 'test', 'tool', errorMsg);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
const lines = events[0].properties.error.split('\n');
|
||||
expect(lines.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should sanitize emails in error messages', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed for user test@example.com');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[EMAIL]');
|
||||
expect(events[0].properties.error).not.toContain('test@example.com');
|
||||
});
|
||||
|
||||
it('should sanitize quoted tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: "abc123def456ghi789"');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('"[TOKEN]"');
|
||||
});
|
||||
|
||||
it('should sanitize token= patterns in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with token=abc123def456');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('token=[REDACTED]');
|
||||
});
|
||||
|
||||
it('should sanitize AWS access keys', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed with AWS key AKIAIOSFODNN7EXAMPLE');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[AWS_KEY]');
|
||||
expect(events[0].properties.error).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
||||
});
|
||||
|
||||
it('should sanitize GitHub tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[GITHUB_TOKEN]');
|
||||
expect(events[0].properties.error).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
||||
});
|
||||
|
||||
it('should sanitize JWT tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Invalid JWT eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature provided');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[JWT]');
|
||||
expect(events[0].properties.error).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||
});
|
||||
|
||||
it('should sanitize Bearer tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Authorization failed: Bearer abc123def456ghi789');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('Bearer [TOKEN]');
|
||||
expect(events[0].properties.error).not.toContain('abc123def456ghi789');
|
||||
});
|
||||
|
||||
it('should prevent email leakage in URLs by sanitizing URLs first', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed: https://api.example.com/users/test@example.com/profile');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// URL should be fully redacted, preventing any email leakage
|
||||
expect(events[0].properties.error).toBe('Failed: [URL]');
|
||||
expect(events[0].properties.error).not.toContain('test@example.com');
|
||||
expect(events[0].properties.error).not.toContain('/users/');
|
||||
});
|
||||
|
||||
it('should handle extremely long error messages efficiently', () => {
|
||||
const hugeError = 'Error: ' + 'x'.repeat(10000);
|
||||
eventTracker.trackError('Error', 'test', 'tool', hugeError);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// Should be truncated at 500 chars max
|
||||
expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackEvent()', () => {
|
||||
it('should track generic events', () => {
|
||||
const properties = { key: 'value', count: 42 };
|
||||
@@ -618,7 +757,7 @@ describe('TelemetryEventTracker', () => {
|
||||
describe('sanitization helpers', () => {
|
||||
it('should sanitize context strings properly', () => {
|
||||
const context = 'Error at https://api.example.com/v1/users/test@email.com?key=secret123456789012345678901234567890';
|
||||
eventTracker.trackError('TestError', context);
|
||||
eventTracker.trackError('TestError', context, undefined, 'Test error with special chars');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// After sanitization: emails first, then keys, then URL (keeping path)
|
||||
@@ -628,7 +767,7 @@ describe('TelemetryEventTracker', () => {
|
||||
it('should handle context truncation', () => {
|
||||
// Use a more realistic long context that won't trigger key sanitization
|
||||
const longContext = 'Error occurred while processing the request: ' + 'details '.repeat(20);
|
||||
eventTracker.trackError('TestError', longContext);
|
||||
eventTracker.trackError('TestError', longContext, undefined, 'Long error message for truncation test');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// Should be truncated to 100 chars
|
||||
|
||||
@@ -233,12 +233,13 @@ describe('TelemetryManager', () => {
|
||||
});
|
||||
|
||||
it('should track errors', () => {
|
||||
manager.trackError('ValidationError', 'Node configuration invalid', 'httpRequest');
|
||||
manager.trackError('ValidationError', 'Node configuration invalid', 'httpRequest', 'Required field "url" is missing');
|
||||
|
||||
expect(mockEventTracker.trackError).toHaveBeenCalledWith(
|
||||
'ValidationError',
|
||||
'Node configuration invalid',
|
||||
'httpRequest'
|
||||
'httpRequest',
|
||||
'Required field "url" is missing'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
793
tests/unit/templates/template-repository-metadata.test.ts
Normal file
793
tests/unit/templates/template-repository-metadata.test.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { TemplateRepository } from '../../../src/templates/template-repository';
|
||||
import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
|
||||
import { logger } from '../../../src/utils/logger';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock template sanitizer
|
||||
vi.mock('../../../src/utils/template-sanitizer', () => {
|
||||
class MockTemplateSanitizer {
|
||||
sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
|
||||
detectTokens = vi.fn(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
TemplateSanitizer: MockTemplateSanitizer
|
||||
};
|
||||
});
|
||||
|
||||
// Create mock database adapter
|
||||
class MockDatabaseAdapter implements DatabaseAdapter {
|
||||
private statements = new Map<string, MockPreparedStatement>();
|
||||
private execCalls: string[] = [];
|
||||
private _fts5Support = true;
|
||||
|
||||
prepare = vi.fn((sql: string) => {
|
||||
if (!this.statements.has(sql)) {
|
||||
this.statements.set(sql, new MockPreparedStatement(sql));
|
||||
}
|
||||
return this.statements.get(sql)!;
|
||||
});
|
||||
|
||||
exec = vi.fn((sql: string) => {
|
||||
this.execCalls.push(sql);
|
||||
});
|
||||
close = vi.fn();
|
||||
pragma = vi.fn();
|
||||
transaction = vi.fn((fn: () => any) => fn());
|
||||
checkFTS5Support = vi.fn(() => this._fts5Support);
|
||||
inTransaction = false;
|
||||
|
||||
_setFTS5Support(supported: boolean) {
|
||||
this._fts5Support = supported;
|
||||
}
|
||||
|
||||
_getStatement(sql: string) {
|
||||
return this.statements.get(sql);
|
||||
}
|
||||
|
||||
_getExecCalls() {
|
||||
return this.execCalls;
|
||||
}
|
||||
|
||||
_clearExecCalls() {
|
||||
this.execCalls = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockPreparedStatement implements PreparedStatement {
|
||||
public mockResults: any[] = [];
|
||||
public capturedParams: any[][] = [];
|
||||
|
||||
run = vi.fn((...params: any[]): RunResult => {
|
||||
this.capturedParams.push(params);
|
||||
return { changes: 1, lastInsertRowid: 1 };
|
||||
});
|
||||
|
||||
get = vi.fn((...params: any[]) => {
|
||||
this.capturedParams.push(params);
|
||||
return this.mockResults[0] || null;
|
||||
});
|
||||
|
||||
all = vi.fn((...params: any[]) => {
|
||||
this.capturedParams.push(params);
|
||||
return this.mockResults;
|
||||
});
|
||||
|
||||
iterate = vi.fn();
|
||||
pluck = vi.fn(() => this);
|
||||
expand = vi.fn(() => this);
|
||||
raw = vi.fn(() => this);
|
||||
columns = vi.fn(() => []);
|
||||
bind = vi.fn(() => this);
|
||||
|
||||
constructor(private sql: string) {}
|
||||
|
||||
_setMockResults(results: any[]) {
|
||||
this.mockResults = results;
|
||||
}
|
||||
|
||||
_getCapturedParams() {
|
||||
return this.capturedParams;
|
||||
}
|
||||
}
|
||||
|
||||
describe('TemplateRepository - Metadata Filter Tests', () => {
|
||||
let repository: TemplateRepository;
|
||||
let mockAdapter: MockDatabaseAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAdapter = new MockDatabaseAdapter();
|
||||
repository = new TemplateRepository(mockAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('buildMetadataFilterConditions - All Filter Combinations', () => {
|
||||
it('should build conditions with no filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
// Should only have the base condition
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
// Should not have any additional conditions
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.categories')");
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity')");
|
||||
});
|
||||
|
||||
it('should build conditions with only category filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ category: 'automation' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('automation');
|
||||
});
|
||||
|
||||
it('should build conditions with only complexity filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('simple');
|
||||
});
|
||||
|
||||
it('should build conditions with only maxSetupMinutes filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 30 }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(30);
|
||||
});
|
||||
|
||||
it('should build conditions with only minSetupMinutes filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ minSetupMinutes: 10 }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(10);
|
||||
});
|
||||
|
||||
it('should build conditions with only requiredService filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ requiredService: 'slack' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('slack');
|
||||
});
|
||||
|
||||
it('should build conditions with only targetAudience filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ targetAudience: 'developers' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('developers');
|
||||
});
|
||||
|
||||
it('should build conditions with all filters combined', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: 'automation',
|
||||
complexity: 'medium',
|
||||
maxSetupMinutes: 60,
|
||||
minSetupMinutes: 15,
|
||||
requiredService: 'openai',
|
||||
targetAudience: 'marketers'
|
||||
}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['automation', 'medium', 60, 15, 'openai', 'marketers', 10, 0]);
|
||||
});
|
||||
|
||||
it('should build conditions with partial filter combinations', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: 'data-processing',
|
||||
maxSetupMinutes: 45,
|
||||
targetAudience: 'analysts'
|
||||
}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Should not have complexity, minSetupMinutes, or requiredService conditions
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).not.toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['data-processing', 45, 'analysts', 10, 0]);
|
||||
});
|
||||
|
||||
it('should handle complexity variations', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
// Test each complexity level
|
||||
const complexityLevels: Array<'simple' | 'medium' | 'complex'> = ['simple', 'medium', 'complex'];
|
||||
|
||||
complexityLevels.forEach((complexity) => {
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity }, 10, 0);
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(complexity);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle setup minutes edge cases', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
// Test zero values
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 0, minSetupMinutes: 0 }, 10, 0);
|
||||
|
||||
let capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(0);
|
||||
|
||||
// Test very large values
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 999999 }, 10, 0);
|
||||
|
||||
capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(999999);
|
||||
|
||||
// Test negative values (should still work, though might not make sense semantically)
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
repository.searchTemplatesByMetadata({ minSetupMinutes: -10 }, 10, 0);
|
||||
|
||||
capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(-10);
|
||||
});
|
||||
|
||||
it('should sanitize special characters in string filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const specialCategory = 'test"with\'quotes';
|
||||
const specialService = 'service\\with\\backslashes';
|
||||
const specialAudience = 'audience\nwith\nnewlines';
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: specialCategory,
|
||||
requiredService: specialService,
|
||||
targetAudience: specialAudience
|
||||
}, 10, 0);
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
// JSON.stringify escapes special characters, then slice(1, -1) removes quotes
|
||||
expect(capturedParams[0][0]).toBe(JSON.stringify(specialCategory).slice(1, -1));
|
||||
expect(capturedParams[0][1]).toBe(JSON.stringify(specialService).slice(1, -1));
|
||||
expect(capturedParams[0][2]).toBe(JSON.stringify(specialAudience).slice(1, -1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Logging and Timing', () => {
|
||||
it('should log debug info on successful search', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Metadata search found'),
|
||||
expect.objectContaining({
|
||||
filters: { complexity: 'simple' },
|
||||
count: 2,
|
||||
phase1Ms: expect.any(Number),
|
||||
phase2Ms: expect.any(Number),
|
||||
totalMs: expect.any(Number),
|
||||
optimization: 'two-phase-with-ordering'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log debug info on empty results', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Metadata search found 0 results',
|
||||
expect.objectContaining({
|
||||
filters: { category: 'nonexistent' },
|
||||
phase1Ms: expect.any(Number)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include all filter types in logs', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const filters = {
|
||||
category: 'automation',
|
||||
complexity: 'medium' as const,
|
||||
maxSetupMinutes: 60,
|
||||
minSetupMinutes: 15,
|
||||
requiredService: 'slack',
|
||||
targetAudience: 'developers'
|
||||
};
|
||||
|
||||
repository.searchTemplatesByMetadata(filters, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
filters: filters
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ID Filtering and Validation', () => {
|
||||
it('should filter out negative IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: -5 },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid IDs (1 and 2)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(2, 1)');
|
||||
expect(prepareCall).not.toContain('-5');
|
||||
});
|
||||
|
||||
it('should filter out zero IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 0 },
|
||||
{ id: 1 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid ID (1)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).not.toContain('(0,');
|
||||
});
|
||||
|
||||
it('should filter out non-integer IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2.5 },
|
||||
{ id: 3 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch integer IDs (1 and 3)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(3, 1)');
|
||||
expect(prepareCall).not.toContain('2.5');
|
||||
});
|
||||
|
||||
it('should filter out null IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: null },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid IDs (1 and 2)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(2, 1)');
|
||||
expect(prepareCall).not.toContain('null');
|
||||
});
|
||||
|
||||
it('should warn when no valid IDs after filtering', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([
|
||||
{ id: -1 },
|
||||
{ id: 0 },
|
||||
{ id: null }
|
||||
]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'No valid IDs after filtering',
|
||||
expect.objectContaining({
|
||||
filters: {},
|
||||
originalCount: 3
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when some IDs are filtered out', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: -2 },
|
||||
{ id: 3 },
|
||||
{ id: null }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Some IDs were filtered out as invalid',
|
||||
expect.objectContaining({
|
||||
original: 4,
|
||||
valid: 2,
|
||||
filtered: 2
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn when all IDs are valid', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2 },
|
||||
{ id: 3 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
'Some IDs were filtered out as invalid',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadataSearchCount - Shared Helper Usage', () => {
|
||||
it('should use buildMetadataFilterConditions for category', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 5 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ category: 'automation' });
|
||||
|
||||
expect(result).toBe(5);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('automation');
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for complexity', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 10 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ complexity: 'medium' });
|
||||
|
||||
expect(result).toBe(10);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for setup minutes', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 3 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
maxSetupMinutes: 30,
|
||||
minSetupMinutes: 10
|
||||
});
|
||||
|
||||
expect(result).toBe(3);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for service and audience', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 7 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
requiredService: 'openai',
|
||||
targetAudience: 'developers'
|
||||
});
|
||||
|
||||
expect(result).toBe(7);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions with all filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 2 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
category: 'integration',
|
||||
complexity: 'complex',
|
||||
maxSetupMinutes: 120,
|
||||
minSetupMinutes: 30,
|
||||
requiredService: 'slack',
|
||||
targetAudience: 'marketers'
|
||||
});
|
||||
|
||||
expect(result).toBe(2);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['integration', 'complex', 120, 30, 'slack', 'marketers']);
|
||||
});
|
||||
|
||||
it('should return 0 when no matches', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 0 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ category: 'nonexistent' });
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Two-Phase Query Optimization', () => {
|
||||
it('should execute two separate queries', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
expect(mockAdapter.prepare).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First query should select only ID
|
||||
const phase1Query = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(phase1Query).toContain('SELECT id FROM templates');
|
||||
expect(phase1Query).toContain('ORDER BY views DESC, created_at DESC, id ASC');
|
||||
|
||||
// Second query should use CTE with ordered IDs
|
||||
const phase2Query = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(phase2Query).toContain('WITH ordered_ids(id, sort_order) AS');
|
||||
expect(phase2Query).toContain('VALUES (1, 0), (2, 1)');
|
||||
expect(phase2Query).toContain('SELECT t.* FROM templates t');
|
||||
expect(phase2Query).toContain('INNER JOIN ordered_ids o ON t.id = o.id');
|
||||
expect(phase2Query).toContain('ORDER BY o.sort_order');
|
||||
});
|
||||
|
||||
it('should skip phase 2 when no IDs found', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
// Should only call prepare once (phase 1)
|
||||
expect(mockAdapter.prepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should preserve ordering with stable sort', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 5 },
|
||||
{ id: 3 },
|
||||
{ id: 1 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 5, workflow_id: 5, name: 'Template 5', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' },
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Check that phase 2 query maintains order: (5,0), (3,1), (1,2)
|
||||
const phase2Query = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(phase2Query).toContain('VALUES (5, 0), (3, 1), (1, 2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,9 @@ export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
// Include both global setup and integration-specific MSW setup
|
||||
setupFiles: ['./tests/setup/global-setup.ts', './tests/integration/setup/integration-setup.ts'],
|
||||
// Include global setup, but NOT integration-setup.ts for n8n-api tests
|
||||
// (they need real network requests, not MSW mocks)
|
||||
setupFiles: ['./tests/setup/global-setup.ts'],
|
||||
// Only include integration tests
|
||||
include: ['tests/integration/**/*.test.ts'],
|
||||
// Integration tests might need more time
|
||||
|
||||
Reference in New Issue
Block a user