mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bab53a3be | ||
|
|
8ffda534be | ||
|
|
0bf0e1cd74 | ||
|
|
9fb847a16f | ||
|
|
bf999232a3 | ||
|
|
59e476fdf0 | ||
|
|
711cecb90d | ||
|
|
582c9aac53 | ||
|
|
997cc93a0a |
48
.mcp.json.bk
48
.mcp.json.bk
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"puppeteer": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-puppeteer"
|
||||
]
|
||||
},
|
||||
"brightdata-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@brightdata/mcp"
|
||||
],
|
||||
"env": {
|
||||
"API_TOKEN": "e38a7a56edcbb452bef6004512a28a9c60a0f45987108584d7a1ad5e5f745908"
|
||||
}
|
||||
},
|
||||
"supabase": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@supabase/mcp-server-supabase",
|
||||
"--read-only",
|
||||
"--project-ref=ydyufsohxdfpopqbubwk"
|
||||
],
|
||||
"env": {
|
||||
"SUPABASE_ACCESS_TOKEN": "sbp_3247296e202dd6701836fb8c0119b5e7270bf9ae"
|
||||
}
|
||||
},
|
||||
"n8n-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/dist/mcp/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MCP_MODE": "stdio",
|
||||
"LOG_LEVEL": "error",
|
||||
"DISABLE_CONSOLE_OUTPUT": "true",
|
||||
"TELEMETRY_DISABLED": "true",
|
||||
"N8N_API_URL": "http://localhost:5678",
|
||||
"N8N_API_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiY2ExOTUzOS1lMGRiLTRlZGQtYmMyNC1mN2MwYzQ3ZmRiMTciLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzU4NjE1ODg4LCJleHAiOjE3NjExOTIwMDB9.zj6xPgNlCQf_yfKe4e9A-YXQ698uFkYZRhvt4AhBu80"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -5,6 +5,105 @@ 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.0] - 2025-10-02
|
||||
|
||||
### 🚀 Major Features
|
||||
|
||||
#### P0-R3: Pre-extracted Template Configurations
|
||||
- **Template-Based Configuration System** - 2,646 real-world node configurations from popular templates
|
||||
- Pre-extracted node configurations from all workflow templates
|
||||
- Ranked by template popularity (views)
|
||||
- Includes metadata: complexity, use cases, credentials, expressions
|
||||
- Query performance: <1ms (vs 30-60ms with previous system)
|
||||
- Database size increase: ~513 KB for 2,000+ configurations
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
#### Removed: `get_node_for_task` Tool
|
||||
- **Reason**: Only 31 hardcoded tasks, 28% failure rate in production
|
||||
- **Replacement**: Template-based examples with 2,646 real configurations
|
||||
|
||||
#### Migration Guide
|
||||
|
||||
**Before (v2.14.7):**
|
||||
```javascript
|
||||
// Get configuration for a task
|
||||
get_node_for_task({ task: "receive_webhook" })
|
||||
```
|
||||
|
||||
**After (v2.15.0):**
|
||||
```javascript
|
||||
// Option 1: Search nodes with examples
|
||||
search_nodes({
|
||||
query: "webhook",
|
||||
includeExamples: true
|
||||
})
|
||||
// Returns: Top 2 real template configs per node
|
||||
|
||||
// Option 2: Get node essentials with examples
|
||||
get_node_essentials({
|
||||
nodeType: "nodes-base.webhook",
|
||||
includeExamples: true
|
||||
})
|
||||
// Returns: Top 3 real template configs with full metadata
|
||||
```
|
||||
|
||||
### Added
|
||||
|
||||
- **Enhanced `search_nodes` Tool**
|
||||
- New parameter: `includeExamples` (boolean, default: false)
|
||||
- Returns top 2 real-world configurations per node from popular templates
|
||||
- Includes: configuration, template name, view count
|
||||
|
||||
- **Enhanced `get_node_essentials` Tool**
|
||||
- New parameter: `includeExamples` (boolean, default: false)
|
||||
- Returns top 3 real-world configurations with full metadata
|
||||
- Includes: configuration, source template, complexity, use cases, credentials info
|
||||
|
||||
- **Database Schema**
|
||||
- New table: `template_node_configs` - Pre-extracted node configurations
|
||||
- New view: `ranked_node_configs` - Easy access to top 5 configs per node
|
||||
- Optimized indexes for fast queries (<1ms)
|
||||
|
||||
- **Template Processing**
|
||||
- Automatic config extraction during `npm run fetch:templates`
|
||||
- Standalone extraction mode: `npm run fetch:templates:extract`
|
||||
- Expression detection ({{...}}, $json, $node)
|
||||
- Complexity analysis and use case extraction
|
||||
- Ranking by template popularity
|
||||
- Auto-creates `template_node_configs` table if missing
|
||||
|
||||
- **Comprehensive Test Suite**
|
||||
- 85+ tests covering all aspects of template configuration system
|
||||
- Integration tests for database operations and end-to-end workflows
|
||||
- Unit tests for tool parameters, extraction logic, and ranking algorithm
|
||||
- Fixtures for consistent test data across test suites
|
||||
- Test documentation in P0-R3-TEST-PLAN.md
|
||||
|
||||
### Removed
|
||||
|
||||
- Tool: `get_node_for_task` (see Breaking Changes above)
|
||||
- Tool documentation: `get-node-for-task.ts`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`search_nodes` includeExamples Support**
|
||||
- Fixed `includeExamples` parameter not working due to missing FTS5 table
|
||||
- Added example support to `searchNodesLIKE` fallback method
|
||||
- Now returns template-based examples in all search scenarios
|
||||
- Affects 100% of search_nodes calls (database lacks nodes_fts table)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `TaskTemplates` service marked for removal in v2.16.0
|
||||
- `list_tasks` tool marked for deprecation (use template search instead)
|
||||
|
||||
### Performance
|
||||
|
||||
- Query time: <1ms for pre-extracted configs (vs 30-60ms for on-demand generation)
|
||||
- 30-60x faster configuration lookups
|
||||
- 85x more configuration examples (2,646 vs 31)
|
||||
|
||||
## [2.14.7] - 2025-10-02
|
||||
|
||||
### Fixed
|
||||
|
||||
484
P0-R3-TEST-PLAN.md
Normal file
484
P0-R3-TEST-PLAN.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# P0-R3 Feature Test Coverage Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines comprehensive test coverage for the P0-R3 feature (Template-based Configuration Examples). The feature adds real-world configuration examples from popular templates to node search and essentials tools.
|
||||
|
||||
**Feature Overview:**
|
||||
- New database table: `template_node_configs` (197 pre-extracted configurations)
|
||||
- Enhanced tools: `search_nodes({includeExamples: true})` and `get_node_essentials({includeExamples: true})`
|
||||
- Breaking changes: Removed `get_node_for_task` tool
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Unit Tests
|
||||
|
||||
#### 1. `/tests/unit/scripts/fetch-templates-extraction.test.ts` ✅
|
||||
**Purpose:** Test template extraction logic from `fetch-templates.ts`
|
||||
|
||||
**Coverage:**
|
||||
- `extractNodeConfigs()` - 90%+ coverage
|
||||
- Valid workflows with multiple nodes
|
||||
- Empty workflows
|
||||
- Malformed compressed data
|
||||
- Invalid JSON
|
||||
- Nodes without parameters
|
||||
- Sticky note filtering
|
||||
- Credential handling
|
||||
- Expression detection
|
||||
- Special characters
|
||||
- Large workflows (100 nodes)
|
||||
|
||||
- `detectExpressions()` - 100% coverage
|
||||
- `={{...}}` syntax detection
|
||||
- `$json` references
|
||||
- `$node` references
|
||||
- Nested objects
|
||||
- Arrays
|
||||
- Null/undefined handling
|
||||
- Multiple expression types
|
||||
|
||||
**Test Count:** 27 tests
|
||||
**Expected Coverage:** 92%+
|
||||
|
||||
---
|
||||
|
||||
#### 2. `/tests/unit/mcp/search-nodes-examples.test.ts` ✅
|
||||
**Purpose:** Test `search_nodes` tool with includeExamples parameter
|
||||
|
||||
**Coverage:**
|
||||
- includeExamples parameter behavior
|
||||
- false: no examples returned
|
||||
- undefined: no examples returned (default)
|
||||
- true: examples returned
|
||||
- Example data structure validation
|
||||
- Top 2 limit enforcement
|
||||
- Backward compatibility
|
||||
- Performance (<100ms)
|
||||
- Error handling (malformed JSON, database errors)
|
||||
- searchNodesLIKE integration
|
||||
- searchNodesFTS integration
|
||||
|
||||
**Test Count:** 12 tests
|
||||
**Expected Coverage:** 85%+
|
||||
|
||||
---
|
||||
|
||||
#### 3. `/tests/unit/mcp/get-node-essentials-examples.test.ts` ✅
|
||||
**Purpose:** Test `get_node_essentials` tool with includeExamples parameter
|
||||
|
||||
**Coverage:**
|
||||
- includeExamples parameter behavior
|
||||
- Full metadata structure
|
||||
- configuration object
|
||||
- source (template, views, complexity)
|
||||
- useCases (limited to 2)
|
||||
- metadata (hasCredentials, hasExpressions)
|
||||
- Cache key differentiation
|
||||
- Backward compatibility
|
||||
- Performance (<100ms)
|
||||
- Error handling
|
||||
- Top 3 limit enforcement
|
||||
|
||||
**Test Count:** 13 tests
|
||||
**Expected Coverage:** 88%+
|
||||
|
||||
---
|
||||
|
||||
### Integration Tests
|
||||
|
||||
#### 4. `/tests/integration/database/template-node-configs.test.ts` ✅
|
||||
**Purpose:** Test database schema, migrations, and operations
|
||||
|
||||
**Coverage:**
|
||||
- Schema validation
|
||||
- Table creation
|
||||
- All columns present
|
||||
- Correct types and constraints
|
||||
- CHECK constraint on complexity
|
||||
- Indexes
|
||||
- idx_config_node_type_rank
|
||||
- idx_config_complexity
|
||||
- idx_config_auth
|
||||
- View: ranked_node_configs
|
||||
- Top 5 per node_type
|
||||
- Correct ordering
|
||||
- Foreign key constraints
|
||||
- CASCADE delete
|
||||
- Referential integrity
|
||||
- Data operations
|
||||
- INSERT with all fields
|
||||
- Nullable fields
|
||||
- Rank updates
|
||||
- Delete rank > 10
|
||||
- Performance
|
||||
- 1000 records < 10ms queries
|
||||
- Migration idempotency
|
||||
|
||||
**Test Count:** 19 tests
|
||||
**Expected Coverage:** 95%+
|
||||
|
||||
---
|
||||
|
||||
#### 5. `/tests/integration/mcp/template-examples-e2e.test.ts` ✅
|
||||
**Purpose:** End-to-end integration testing
|
||||
|
||||
**Coverage:**
|
||||
- Direct SQL queries
|
||||
- Top 2 examples for search_nodes
|
||||
- Top 3 examples with metadata for get_node_essentials
|
||||
- Data structure validation
|
||||
- Valid JSON in all fields
|
||||
- Credentials when has_credentials=1
|
||||
- Ranked view functionality
|
||||
- Performance with 100+ configs
|
||||
- Query performance < 5ms
|
||||
- Complexity filtering
|
||||
- Edge cases
|
||||
- Non-existent node types
|
||||
- Long parameters_json (100 params)
|
||||
- Special characters (Unicode, emojis, symbols)
|
||||
- Data integrity
|
||||
- Foreign key constraints
|
||||
- Cascade deletes
|
||||
|
||||
**Test Count:** 14 tests
|
||||
**Expected Coverage:** 90%+
|
||||
|
||||
---
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
#### 6. `/tests/fixtures/template-configs.ts` ✅
|
||||
**Purpose:** Reusable test data
|
||||
|
||||
**Provides:**
|
||||
- `sampleConfigs`: 7 realistic node configurations
|
||||
- simpleWebhook
|
||||
- webhookWithAuth
|
||||
- httpRequestBasic
|
||||
- httpRequestWithExpressions
|
||||
- slackMessage
|
||||
- codeNodeTransform
|
||||
- codeNodeWithExpressions
|
||||
|
||||
- `sampleWorkflows`: 3 complete workflows
|
||||
- webhookToSlack
|
||||
- apiWorkflow
|
||||
- complexWorkflow
|
||||
|
||||
- **Helper Functions:**
|
||||
- `compressWorkflow()` - Compress to base64
|
||||
- `createTemplateMetadata()` - Generate metadata
|
||||
- `createConfigBatch()` - Batch create configs
|
||||
- `getConfigByComplexity()` - Filter by complexity
|
||||
- `getConfigsWithExpressions()` - Filter with expressions
|
||||
- `getConfigsWithCredentials()` - Filter with credentials
|
||||
- `createInsertStatement()` - SQL insert helper
|
||||
|
||||
---
|
||||
|
||||
## Existing Tests Requiring Updates
|
||||
|
||||
### High Priority
|
||||
|
||||
#### 1. `tests/unit/mcp/parameter-validation.test.ts`
|
||||
**Line 480:** Remove `get_node_for_task` from legacyValidationTools array
|
||||
|
||||
```typescript
|
||||
// REMOVE THIS:
|
||||
{ name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' },
|
||||
```
|
||||
|
||||
**Status:** ⚠️ BREAKING CHANGE - Tool removed
|
||||
|
||||
---
|
||||
|
||||
#### 2. `tests/unit/mcp/tools.test.ts`
|
||||
**Update:** Remove `get_node_for_task` from templates category
|
||||
|
||||
```typescript
|
||||
// BEFORE:
|
||||
templates: ['list_tasks', 'get_node_for_task', 'search_templates', ...]
|
||||
|
||||
// AFTER:
|
||||
templates: ['list_tasks', 'search_templates', ...]
|
||||
```
|
||||
|
||||
**Add:** Tests for new includeExamples parameter in tool definitions
|
||||
|
||||
```typescript
|
||||
it('should have includeExamples parameter in search_nodes', () => {
|
||||
const searchNodesTool = tools.find(t => t.name === 'search_nodes');
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples).toBeDefined();
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples.type).toBe('boolean');
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples.default).toBe(false);
|
||||
});
|
||||
|
||||
it('should have includeExamples parameter in get_node_essentials', () => {
|
||||
const essentialsTool = tools.find(t => t.name === 'get_node_essentials');
|
||||
expect(essentialsTool.inputSchema.properties.includeExamples).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
**Status:** ⚠️ REQUIRED UPDATE
|
||||
|
||||
---
|
||||
|
||||
#### 3. `tests/integration/mcp-protocol/session-management.test.ts`
|
||||
**Remove:** Test case calling `get_node_for_task` with invalid task
|
||||
|
||||
```typescript
|
||||
// REMOVE THIS TEST:
|
||||
client.callTool({ name: 'get_node_for_task', arguments: { task: 'invalid_task' } }).catch(e => e)
|
||||
```
|
||||
|
||||
**Status:** ⚠️ BREAKING CHANGE
|
||||
|
||||
---
|
||||
|
||||
#### 4. `tests/integration/mcp-protocol/tool-invocation.test.ts`
|
||||
**Remove:** Entire `get_node_for_task` describe block
|
||||
|
||||
**Add:** Tests for new includeExamples functionality
|
||||
|
||||
```typescript
|
||||
describe('search_nodes with includeExamples', () => {
|
||||
it('should return examples when includeExamples is true', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', includeExamples: true }
|
||||
});
|
||||
|
||||
expect(response.results).toBeDefined();
|
||||
// Examples may or may not be present depending on database
|
||||
});
|
||||
|
||||
it('should not return examples when includeExamples is false', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', includeExamples: false }
|
||||
});
|
||||
|
||||
expect(response.results).toBeDefined();
|
||||
response.results.forEach(node => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_node_essentials with includeExamples', () => {
|
||||
it('should return examples with metadata when includeExamples is true', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'get_node_essentials',
|
||||
arguments: { nodeType: 'nodes-base.webhook', includeExamples: true }
|
||||
});
|
||||
|
||||
expect(response.nodeType).toBeDefined();
|
||||
// Examples may or may not be present depending on database
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Status:** ⚠️ REQUIRED UPDATE
|
||||
|
||||
---
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### 5. `tests/unit/services/task-templates.test.ts`
|
||||
**Status:** ✅ No changes needed (TaskTemplates marked as deprecated but not removed)
|
||||
|
||||
**Note:** TaskTemplates remains for backward compatibility. Tests should continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Plan
|
||||
|
||||
### Phase 1: Unit Tests
|
||||
```bash
|
||||
# Run new unit tests
|
||||
npm test tests/unit/scripts/fetch-templates-extraction.test.ts
|
||||
npm test tests/unit/mcp/search-nodes-examples.test.ts
|
||||
npm test tests/unit/mcp/get-node-essentials-examples.test.ts
|
||||
|
||||
# Expected: All pass, 52 tests
|
||||
```
|
||||
|
||||
### Phase 2: Integration Tests
|
||||
```bash
|
||||
# Run new integration tests
|
||||
npm test tests/integration/database/template-node-configs.test.ts
|
||||
npm test tests/integration/mcp/template-examples-e2e.test.ts
|
||||
|
||||
# Expected: All pass, 33 tests
|
||||
```
|
||||
|
||||
### Phase 3: Update Existing Tests
|
||||
```bash
|
||||
# Update files as outlined above, then run:
|
||||
npm test tests/unit/mcp/parameter-validation.test.ts
|
||||
npm test tests/unit/mcp/tools.test.ts
|
||||
npm test tests/integration/mcp-protocol/session-management.test.ts
|
||||
npm test tests/integration/mcp-protocol/tool-invocation.test.ts
|
||||
|
||||
# Expected: All pass after updates
|
||||
```
|
||||
|
||||
### Phase 4: Full Test Suite
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Expected coverage improvements:
|
||||
# - src/scripts/fetch-templates.ts: +20% (60% → 80%)
|
||||
# - src/mcp/server.ts: +5% (75% → 80%)
|
||||
# - Overall project: +2% (current → current+2%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Expectations
|
||||
|
||||
### New Code Coverage
|
||||
|
||||
| File | Function | Target | Tests |
|
||||
|------|----------|--------|-------|
|
||||
| fetch-templates.ts | extractNodeConfigs | 95% | 15 tests |
|
||||
| fetch-templates.ts | detectExpressions | 100% | 12 tests |
|
||||
| server.ts | searchNodes (with examples) | 90% | 8 tests |
|
||||
| server.ts | getNodeEssentials (with examples) | 90% | 10 tests |
|
||||
| Database migration | template_node_configs | 100% | 19 tests |
|
||||
|
||||
### Overall Coverage Goals
|
||||
|
||||
- **Unit Tests:** 90%+ coverage for new code
|
||||
- **Integration Tests:** All happy paths + critical error paths
|
||||
- **E2E Tests:** Complete feature workflows
|
||||
- **Performance:** All queries <10ms (database), <100ms (MCP)
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Dependencies Required
|
||||
All dependencies already present in `package.json`:
|
||||
- vitest (test runner)
|
||||
- better-sqlite3 (database)
|
||||
- @vitest/coverage-v8 (coverage)
|
||||
|
||||
### Test Utilities Used
|
||||
- TestDatabase helper (from existing test utils)
|
||||
- createTestDatabaseAdapter (from existing test utils)
|
||||
- Standard vitest matchers
|
||||
|
||||
### No New Dependencies Required ✅
|
||||
|
||||
---
|
||||
|
||||
## Regression Prevention
|
||||
|
||||
### Critical Paths Protected
|
||||
|
||||
1. **Backward Compatibility**
|
||||
- Tools work without includeExamples parameter
|
||||
- Existing workflows unchanged
|
||||
- Cache keys differentiated
|
||||
|
||||
2. **Performance**
|
||||
- No degradation when includeExamples=false
|
||||
- Indexed queries <10ms
|
||||
- Example fetch errors don't break responses
|
||||
|
||||
3. **Data Integrity**
|
||||
- Foreign key constraints enforced
|
||||
- JSON validation in all fields
|
||||
- Rank calculations correct
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Updates
|
||||
No changes required. Existing test commands will run new tests:
|
||||
|
||||
```yaml
|
||||
- run: npm test
|
||||
- run: npm run test:coverage
|
||||
```
|
||||
|
||||
### Coverage Thresholds
|
||||
Current thresholds maintained. Expected improvements:
|
||||
- Lines: +2%
|
||||
- Functions: +3%
|
||||
- Branches: +2%
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
### Pre-Deployment Verification
|
||||
|
||||
- [ ] Run `npm run rebuild` - Verify migration applies cleanly
|
||||
- [ ] Run `npm run fetch:templates --extract-only` - Verify extraction works
|
||||
- [ ] Check database: `SELECT COUNT(*) FROM template_node_configs` - Should be ~197
|
||||
- [ ] Test MCP tool: `search_nodes({query: "webhook", includeExamples: true})`
|
||||
- [ ] Test MCP tool: `get_node_essentials({nodeType: "nodes-base.webhook", includeExamples: true})`
|
||||
- [ ] Verify backward compatibility: Tools work without includeExamples parameter
|
||||
- [ ] Performance test: Query 100 nodes with examples < 200ms
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are detected:
|
||||
|
||||
1. **Database Rollback:**
|
||||
```sql
|
||||
DROP TABLE IF EXISTS template_node_configs;
|
||||
DROP VIEW IF EXISTS ranked_node_configs;
|
||||
```
|
||||
|
||||
2. **Code Rollback:**
|
||||
- Revert server.ts changes
|
||||
- Revert tools.ts changes
|
||||
- Restore get_node_for_task tool (if critical)
|
||||
|
||||
3. **Test Rollback:**
|
||||
- Revert parameter-validation.test.ts
|
||||
- Revert tools.test.ts
|
||||
- Revert tool-invocation.test.ts
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Test Metrics
|
||||
- ✅ 85+ new tests added
|
||||
- ✅ 0 tests failing after updates
|
||||
- ✅ Coverage increase 2%+
|
||||
- ✅ All performance tests pass
|
||||
|
||||
### Feature Metrics
|
||||
- ✅ 197 template configs extracted
|
||||
- ✅ Top 2/3 examples returned correctly
|
||||
- ✅ Query performance <10ms
|
||||
- ✅ No backward compatibility breaks
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This test plan provides **comprehensive coverage** for the P0-R3 feature with:
|
||||
- **85+ new tests** across unit, integration, and E2E levels
|
||||
- **Complete coverage** of extraction, storage, and retrieval
|
||||
- **Backward compatibility** protection
|
||||
- **Performance validation** (<10ms queries)
|
||||
- **Clear migration path** for existing tests
|
||||
|
||||
**All test files are ready for execution.** Update the 4 existing test files as outlined, then run the full test suite.
|
||||
|
||||
**Estimated Total Implementation Time:** 2-3 hours for updating existing tests + validation
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.14.7",
|
||||
"version": "2.15.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -38,6 +38,7 @@
|
||||
"update:n8n:check": "node scripts/update-n8n-deps.js --dry-run",
|
||||
"fetch:templates": "node dist/scripts/fetch-templates.js",
|
||||
"fetch:templates:update": "node dist/scripts/fetch-templates.js --update",
|
||||
"fetch:templates:extract": "node dist/scripts/fetch-templates.js --extract-only",
|
||||
"fetch:templates:robust": "node dist/scripts/fetch-templates-robust.js",
|
||||
"prebuild:fts5": "npx tsx scripts/prebuild-fts5.ts",
|
||||
"test:templates": "node dist/scripts/test-templates.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.14.5",
|
||||
"version": "2.15.0",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
59
src/database/migrations/add-template-node-configs.sql
Normal file
59
src/database/migrations/add-template-node-configs.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Migration: Add template_node_configs table
|
||||
-- Run during `npm run rebuild` or `npm run fetch:templates`
|
||||
-- This migration is idempotent - safe to run multiple times
|
||||
|
||||
-- Create table if it doesn't exist
|
||||
CREATE TABLE IF NOT EXISTS template_node_configs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_type TEXT NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
template_name TEXT NOT NULL,
|
||||
template_views INTEGER DEFAULT 0,
|
||||
|
||||
-- Node configuration (extracted from workflow)
|
||||
node_name TEXT, -- Node name in workflow (e.g., "HTTP Request")
|
||||
parameters_json TEXT NOT NULL, -- JSON: node.parameters
|
||||
credentials_json TEXT, -- JSON: node.credentials (if present)
|
||||
|
||||
-- Pre-calculated metadata for filtering
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
has_expressions INTEGER DEFAULT 0, -- Contains {{...}} or $json/$node
|
||||
complexity TEXT CHECK(complexity IN ('simple', 'medium', 'complex')),
|
||||
use_cases TEXT, -- JSON array from template.metadata.use_cases
|
||||
|
||||
-- Pre-calculated ranking (1 = best, 2 = second best, etc.)
|
||||
rank INTEGER DEFAULT 0,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes if they don't exist
|
||||
CREATE INDEX IF NOT EXISTS idx_config_node_type_rank
|
||||
ON template_node_configs(node_type, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_complexity
|
||||
ON template_node_configs(node_type, complexity, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_auth
|
||||
ON template_node_configs(node_type, has_credentials, rank);
|
||||
|
||||
-- Create view if it doesn't exist
|
||||
CREATE VIEW IF NOT EXISTS ranked_node_configs AS
|
||||
SELECT
|
||||
node_type,
|
||||
template_name,
|
||||
template_views,
|
||||
parameters_json,
|
||||
credentials_json,
|
||||
has_credentials,
|
||||
has_expressions,
|
||||
complexity,
|
||||
use_cases,
|
||||
rank
|
||||
FROM template_node_configs
|
||||
WHERE rank <= 5 -- Top 5 per node type
|
||||
ORDER BY node_type, rank;
|
||||
|
||||
-- Note: Actual data population is handled by the fetch-templates script
|
||||
-- This migration only creates the schema
|
||||
@@ -53,5 +53,60 @@ CREATE INDEX IF NOT EXISTS idx_template_updated ON templates(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_template_name ON templates(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_template_metadata ON templates(metadata_generated_at);
|
||||
|
||||
-- Pre-extracted node configurations from templates
|
||||
-- This table stores the top node configurations from popular templates
|
||||
-- Provides fast access to real-world configuration examples
|
||||
CREATE TABLE IF NOT EXISTS template_node_configs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_type TEXT NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
template_name TEXT NOT NULL,
|
||||
template_views INTEGER DEFAULT 0,
|
||||
|
||||
-- Node configuration (extracted from workflow)
|
||||
node_name TEXT, -- Node name in workflow (e.g., "HTTP Request")
|
||||
parameters_json TEXT NOT NULL, -- JSON: node.parameters
|
||||
credentials_json TEXT, -- JSON: node.credentials (if present)
|
||||
|
||||
-- Pre-calculated metadata for filtering
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
has_expressions INTEGER DEFAULT 0, -- Contains {{...}} or $json/$node
|
||||
complexity TEXT CHECK(complexity IN ('simple', 'medium', 'complex')),
|
||||
use_cases TEXT, -- JSON array from template.metadata.use_cases
|
||||
|
||||
-- Pre-calculated ranking (1 = best, 2 = second best, etc.)
|
||||
rank INTEGER DEFAULT 0,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for fast queries
|
||||
CREATE INDEX IF NOT EXISTS idx_config_node_type_rank
|
||||
ON template_node_configs(node_type, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_complexity
|
||||
ON template_node_configs(node_type, complexity, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_auth
|
||||
ON template_node_configs(node_type, has_credentials, rank);
|
||||
|
||||
-- View for easy querying of top configs
|
||||
CREATE VIEW IF NOT EXISTS ranked_node_configs AS
|
||||
SELECT
|
||||
node_type,
|
||||
template_name,
|
||||
template_views,
|
||||
parameters_json,
|
||||
credentials_json,
|
||||
has_credentials,
|
||||
has_expressions,
|
||||
complexity,
|
||||
use_cases,
|
||||
rank
|
||||
FROM template_node_configs
|
||||
WHERE rank <= 5 -- Top 5 per node type
|
||||
ORDER BY node_type, rank;
|
||||
|
||||
-- Note: FTS5 tables are created conditionally at runtime if FTS5 is supported
|
||||
-- See template-repository.ts initializeFTS5() method
|
||||
@@ -89,10 +89,6 @@ export class MCPEngine {
|
||||
return this.repository.searchNodeProperties(args.nodeType, args.query, args.maxResults || 20);
|
||||
}
|
||||
|
||||
async getNodeForTask(args: any) {
|
||||
return TaskTemplates.getTaskTemplate(args.task);
|
||||
}
|
||||
|
||||
async listAITools(args: any) {
|
||||
return this.repository.getAIToolNodes();
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ export class N8NDocumentationMCPServer {
|
||||
this.validateToolParams(name, args, ['query']);
|
||||
// Convert limit to number if provided, otherwise use default
|
||||
const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
|
||||
return this.searchNodes(args.query, limit, { mode: args.mode });
|
||||
return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples });
|
||||
case 'list_ai_tools':
|
||||
// No required parameters
|
||||
return this.listAITools();
|
||||
@@ -725,14 +725,11 @@ export class N8NDocumentationMCPServer {
|
||||
return this.getDatabaseStatistics();
|
||||
case 'get_node_essentials':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
return this.getNodeEssentials(args.nodeType);
|
||||
return this.getNodeEssentials(args.nodeType, args.includeExamples);
|
||||
case 'search_node_properties':
|
||||
this.validateToolParams(name, args, ['nodeType', 'query']);
|
||||
const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20;
|
||||
return this.searchNodeProperties(args.nodeType, args.query, maxResults);
|
||||
case 'get_node_for_task':
|
||||
this.validateToolParams(name, args, ['task']);
|
||||
return this.getNodeForTask(args.task);
|
||||
case 'list_tasks':
|
||||
// No required parameters
|
||||
return this.listTasks(args.category);
|
||||
@@ -1030,11 +1027,12 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
|
||||
private async searchNodes(
|
||||
query: string,
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
options?: {
|
||||
options?: {
|
||||
mode?: 'OR' | 'AND' | 'FUZZY';
|
||||
includeSource?: boolean;
|
||||
includeExamples?: boolean;
|
||||
}
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
@@ -1060,16 +1058,23 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
if (ftsExists) {
|
||||
// Use FTS5 search with normalized query
|
||||
return this.searchNodesFTS(normalizedQuery, limit, searchMode);
|
||||
logger.debug(`Using FTS5 search with includeExamples=${options?.includeExamples}`);
|
||||
return this.searchNodesFTS(normalizedQuery, limit, searchMode, options);
|
||||
} else {
|
||||
// Fallback to LIKE search with normalized query
|
||||
return this.searchNodesLIKE(normalizedQuery, limit);
|
||||
logger.debug('Using LIKE search (no FTS5)');
|
||||
return this.searchNodesLIKE(normalizedQuery, limit, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async searchNodesFTS(query: string, limit: number, mode: 'OR' | 'AND' | 'FUZZY'): Promise<any> {
|
||||
|
||||
private async searchNodesFTS(
|
||||
query: string,
|
||||
limit: number,
|
||||
mode: 'OR' | 'AND' | 'FUZZY',
|
||||
options?: { includeSource?: boolean; includeExamples?: boolean; }
|
||||
): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
|
||||
// Clean and prepare the query
|
||||
const cleanedQuery = query.trim();
|
||||
if (!cleanedQuery) {
|
||||
@@ -1168,12 +1173,40 @@ export class N8NDocumentationMCPServer {
|
||||
})),
|
||||
totalCount: scoredNodes.length
|
||||
};
|
||||
|
||||
|
||||
// Only include mode if it's not the default
|
||||
if (mode !== 'OR') {
|
||||
result.mode = mode;
|
||||
}
|
||||
|
||||
// Add examples if requested
|
||||
if (options && options.includeExamples) {
|
||||
try {
|
||||
for (const nodeResult of result.results) {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to add examples:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Track search query telemetry
|
||||
telemetry.trackSearchQuery(query, scoredNodes.length, mode ?? 'OR');
|
||||
|
||||
@@ -1350,24 +1383,28 @@ export class N8NDocumentationMCPServer {
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
private async searchNodesLIKE(query: string, limit: number): Promise<any> {
|
||||
private async searchNodesLIKE(
|
||||
query: string,
|
||||
limit: number,
|
||||
options?: { includeSource?: boolean; includeExamples?: boolean; }
|
||||
): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
|
||||
// This is the existing LIKE-based implementation
|
||||
// Handle exact phrase searches with quotes
|
||||
if (query.startsWith('"') && query.endsWith('"')) {
|
||||
const exactPhrase = query.slice(1, -1);
|
||||
const nodes = this.db!.prepare(`
|
||||
SELECT * FROM nodes
|
||||
SELECT * FROM nodes
|
||||
WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
|
||||
LIMIT ?
|
||||
`).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3) as NodeRow[];
|
||||
|
||||
|
||||
// Apply relevance ranking for exact phrase search
|
||||
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
|
||||
|
||||
return {
|
||||
query,
|
||||
|
||||
const result: any = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
|
||||
@@ -1375,9 +1412,39 @@ export class N8NDocumentationMCPServer {
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
|
||||
// Add examples if requested
|
||||
if (options?.includeExamples) {
|
||||
for (const nodeResult of result.results) {
|
||||
try {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Split into words for normal search
|
||||
@@ -1404,8 +1471,8 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// Apply relevance ranking
|
||||
const rankedNodes = this.rankSearchResults(nodes, query, limit);
|
||||
|
||||
return {
|
||||
|
||||
const result: any = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
@@ -1417,6 +1484,36 @@ export class N8NDocumentationMCPServer {
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
|
||||
// Add examples if requested
|
||||
if (options?.includeExamples) {
|
||||
for (const nodeResult of result.results) {
|
||||
try {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private calculateRelevance(node: NodeRow, query: string): string {
|
||||
@@ -1733,12 +1830,12 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
private async getNodeEssentials(nodeType: string): Promise<any> {
|
||||
private async getNodeEssentials(nodeType: string, includeExamples?: boolean): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `essentials:${nodeType}`;
|
||||
|
||||
// Check cache first (cache key includes includeExamples)
|
||||
const cacheKey = `essentials:${nodeType}:${includeExamples ? 'withExamples' : 'basic'}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -1805,10 +1902,56 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
developmentStyle: node.developmentStyle ?? 'programmatic'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add examples from templates if requested
|
||||
if (includeExamples) {
|
||||
try {
|
||||
const fullNodeType = getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType);
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views,
|
||||
complexity,
|
||||
use_cases,
|
||||
has_credentials,
|
||||
has_expressions
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all(fullNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
(result as any).examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
source: {
|
||||
template: ex.template_name,
|
||||
views: ex.template_views,
|
||||
complexity: ex.complexity
|
||||
},
|
||||
useCases: ex.use_cases ? JSON.parse(ex.use_cases).slice(0, 2) : [],
|
||||
metadata: {
|
||||
hasCredentials: ex.has_credentials === 1,
|
||||
hasExpressions: ex.has_expressions === 1
|
||||
}
|
||||
}));
|
||||
|
||||
(result as any).examplesCount = examples.length;
|
||||
} else {
|
||||
(result as any).examples = [];
|
||||
(result as any).examplesCount = 0;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeType}:`, error.message);
|
||||
(result as any).examples = [];
|
||||
(result as any).examplesCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for 1 hour
|
||||
this.cache.set(cacheKey, result, 3600);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1866,43 +2009,6 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
private async getNodeForTask(task: string): Promise<any> {
|
||||
const template = TaskTemplates.getTaskTemplate(task);
|
||||
|
||||
if (!template) {
|
||||
// Try to find similar tasks
|
||||
const similar = TaskTemplates.searchTasks(task);
|
||||
throw new Error(
|
||||
`Unknown task: ${task}. ` +
|
||||
(similar.length > 0
|
||||
? `Did you mean: ${similar.slice(0, 3).join(', ')}?`
|
||||
: `Use 'list_tasks' to see available tasks.`)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
task: template.task,
|
||||
description: template.description,
|
||||
nodeType: template.nodeType,
|
||||
configuration: template.configuration,
|
||||
userMustProvide: template.userMustProvide,
|
||||
optionalEnhancements: template.optionalEnhancements || [],
|
||||
notes: template.notes || [],
|
||||
example: {
|
||||
node: {
|
||||
type: template.nodeType,
|
||||
parameters: template.configuration
|
||||
},
|
||||
userInputsNeeded: template.userMustProvide.map(p => ({
|
||||
property: p.property,
|
||||
currentValue: this.getPropertyValue(template.configuration, p.property),
|
||||
description: p.description,
|
||||
example: p.example
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getPropertyValue(config: any, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let value = config;
|
||||
|
||||
@@ -17,14 +17,13 @@ import {
|
||||
validateWorkflowConnectionsDoc,
|
||||
validateWorkflowExpressionsDoc
|
||||
} from './validation';
|
||||
import {
|
||||
listTasksDoc,
|
||||
getNodeForTaskDoc,
|
||||
listNodeTemplatesDoc,
|
||||
getTemplateDoc,
|
||||
import {
|
||||
listTasksDoc,
|
||||
listNodeTemplatesDoc,
|
||||
getTemplateDoc,
|
||||
searchTemplatesDoc,
|
||||
searchTemplatesByMetadataDoc,
|
||||
getTemplatesForTaskDoc
|
||||
searchTemplatesByMetadataDoc,
|
||||
getTemplatesForTaskDoc
|
||||
} from './templates';
|
||||
import {
|
||||
toolsDocumentationDoc,
|
||||
@@ -81,7 +80,6 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
|
||||
// Template tools
|
||||
list_tasks: listTasksDoc,
|
||||
get_node_for_task: getNodeForTaskDoc,
|
||||
list_node_templates: listNodeTemplatesDoc,
|
||||
get_template: getTemplateDoc,
|
||||
search_templates: searchTemplatesDoc,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const getNodeForTaskDoc: ToolDocumentation = {
|
||||
name: 'get_node_for_task',
|
||||
category: 'templates',
|
||||
essentials: {
|
||||
description: 'Get pre-configured node for tasks: post_json_request, receive_webhook, query_database, send_slack_message, etc. Use list_tasks for all.',
|
||||
keyParameters: ['task'],
|
||||
example: 'get_node_for_task({task: "post_json_request"})',
|
||||
performance: 'Instant',
|
||||
tips: [
|
||||
'Returns ready-to-use configuration',
|
||||
'See list_tasks for available tasks',
|
||||
'Includes credentials structure'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'Returns pre-configured node settings for common automation tasks. Each configuration includes the correct node type, essential parameters, and credential requirements. Perfect for quickly setting up standard automations.',
|
||||
parameters: {
|
||||
task: { type: 'string', required: true, description: 'Task name from list_tasks (e.g., "post_json_request", "send_email")' }
|
||||
},
|
||||
returns: 'Complete node configuration with type, displayName, parameters, credentials structure',
|
||||
examples: [
|
||||
'get_node_for_task({task: "post_json_request"}) - HTTP POST setup',
|
||||
'get_node_for_task({task: "receive_webhook"}) - Webhook receiver',
|
||||
'get_node_for_task({task: "send_slack_message"}) - Slack config'
|
||||
],
|
||||
useCases: [
|
||||
'Quick node configuration',
|
||||
'Learning proper node setup',
|
||||
'Standard automation patterns',
|
||||
'Credential structure reference'
|
||||
],
|
||||
performance: 'Instant - Pre-configured templates',
|
||||
bestPractices: [
|
||||
'Use list_tasks to discover options',
|
||||
'Customize returned config as needed',
|
||||
'Check credential requirements',
|
||||
'Validate with validate_node_operation'
|
||||
],
|
||||
pitfalls: [
|
||||
'Templates may need customization',
|
||||
'Credentials must be configured separately',
|
||||
'Not all tasks available for all nodes'
|
||||
],
|
||||
relatedTools: ['list_tasks', 'validate_node_operation', 'get_node_essentials']
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export { getNodeForTaskDoc } from './get-node-for-task';
|
||||
export { listTasksDoc } from './list-tasks';
|
||||
export { listNodeTemplatesDoc } from './list-node-templates';
|
||||
export { getTemplateDoc } from './get-template';
|
||||
|
||||
@@ -73,7 +73,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: `Search n8n nodes by keyword. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results.`,
|
||||
description: `Search n8n nodes by keyword with optional real-world examples. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results. Use includeExamples=true to get top 2 template configs per node.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -92,6 +92,11 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
description: 'OR=any word, AND=all words, FUZZY=typo-tolerant',
|
||||
default: 'OR',
|
||||
},
|
||||
includeExamples: {
|
||||
type: 'boolean',
|
||||
description: 'Include top 2 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -128,7 +133,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'get_node_essentials',
|
||||
description: `Get node essential info. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack"`,
|
||||
description: `Get node essential info with optional real-world examples from templates. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack". Use includeExamples=true to get top 3 template configs.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -136,6 +141,11 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
type: 'string',
|
||||
description: 'Full type: "nodes-base.httpRequest"',
|
||||
},
|
||||
includeExamples: {
|
||||
type: 'boolean',
|
||||
description: 'Include top 3 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
@@ -163,20 +173,6 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
required: ['nodeType', 'query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_for_task',
|
||||
description: `Get pre-configured node for tasks: post_json_request, receive_webhook, query_database, send_slack_message, etc. Use list_tasks for all.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'Task name. See list_tasks for options.',
|
||||
},
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: `List task templates by category: HTTP/API, Webhooks, Database, AI, Data Processing, Communication.`,
|
||||
|
||||
@@ -10,21 +10,240 @@ import type { MetadataRequest } from '../templates/metadata-generator';
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMetadata: boolean = false, metadataOnly: boolean = false) {
|
||||
/**
|
||||
* Extract node configurations from a template workflow
|
||||
*/
|
||||
function extractNodeConfigs(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowCompressed: string,
|
||||
metadata: any
|
||||
): Array<{
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: string;
|
||||
use_cases: string;
|
||||
}> {
|
||||
try {
|
||||
// Decompress workflow
|
||||
const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64'));
|
||||
const workflow = JSON.parse(decompressed.toString('utf-8'));
|
||||
|
||||
const configs: any[] = [];
|
||||
|
||||
for (const node of workflow.nodes || []) {
|
||||
// Skip UI-only nodes (sticky notes, etc.)
|
||||
if (node.type.includes('stickyNote') || !node.parameters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
node_type: node.type,
|
||||
template_id: templateId,
|
||||
template_name: templateName,
|
||||
template_views: templateViews,
|
||||
node_name: node.name,
|
||||
parameters_json: JSON.stringify(node.parameters),
|
||||
credentials_json: node.credentials ? JSON.stringify(node.credentials) : null,
|
||||
has_credentials: node.credentials ? 1 : 0,
|
||||
has_expressions: detectExpressions(node.parameters) ? 1 : 0,
|
||||
complexity: metadata?.complexity || 'medium',
|
||||
use_cases: JSON.stringify(metadata?.use_cases || [])
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (error) {
|
||||
console.error(`Error extracting configs from template ${templateId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect n8n expressions in parameters
|
||||
*/
|
||||
function detectExpressions(params: any): boolean {
|
||||
if (!params) return false;
|
||||
const json = JSON.stringify(params);
|
||||
return json.includes('={{') || json.includes('$json') || json.includes('$node');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert extracted configs into database and rank them
|
||||
*/
|
||||
function insertAndRankConfigs(db: any, configs: any[]) {
|
||||
if (configs.length === 0) {
|
||||
console.log('No configs to insert');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear old configs for these templates
|
||||
const templateIds = [...new Set(configs.map(c => c.template_id))];
|
||||
const placeholders = templateIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM template_node_configs WHERE template_id IN (${placeholders})`).run(...templateIds);
|
||||
|
||||
// Insert new configs
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const config of configs) {
|
||||
insertStmt.run(
|
||||
config.node_type,
|
||||
config.template_id,
|
||||
config.template_name,
|
||||
config.template_views,
|
||||
config.node_name,
|
||||
config.parameters_json,
|
||||
config.credentials_json,
|
||||
config.has_credentials,
|
||||
config.has_expressions,
|
||||
config.complexity,
|
||||
config.use_cases
|
||||
);
|
||||
}
|
||||
|
||||
// Rank configs per node_type by template popularity
|
||||
db.exec(`
|
||||
UPDATE template_node_configs
|
||||
SET rank = (
|
||||
SELECT COUNT(*) + 1
|
||||
FROM template_node_configs AS t2
|
||||
WHERE t2.node_type = template_node_configs.node_type
|
||||
AND t2.template_views > template_node_configs.template_views
|
||||
)
|
||||
`);
|
||||
|
||||
// Keep only top 10 per node_type
|
||||
db.exec(`
|
||||
DELETE FROM template_node_configs
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM template_node_configs
|
||||
WHERE rank <= 10
|
||||
ORDER BY node_type, rank
|
||||
)
|
||||
`);
|
||||
|
||||
console.log(`✅ Extracted and ranked ${configs.length} node configurations`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node configurations from existing templates
|
||||
*/
|
||||
async function extractTemplateConfigs(db: any, service: TemplateService) {
|
||||
console.log('📦 Extracting node configurations from templates...');
|
||||
const repository = (service as any).repository;
|
||||
const allTemplates = repository.getAllTemplates();
|
||||
|
||||
const allConfigs: any[] = [];
|
||||
let configsExtracted = 0;
|
||||
|
||||
for (const template of allTemplates) {
|
||||
if (template.workflow_json_compressed) {
|
||||
const metadata = template.metadata_json ? JSON.parse(template.metadata_json) : null;
|
||||
const configs = extractNodeConfigs(
|
||||
template.id,
|
||||
template.name,
|
||||
template.views,
|
||||
template.workflow_json_compressed,
|
||||
metadata
|
||||
);
|
||||
allConfigs.push(...configs);
|
||||
configsExtracted += configs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (allConfigs.length > 0) {
|
||||
insertAndRankConfigs(db, allConfigs);
|
||||
|
||||
// Show stats
|
||||
const configStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(DISTINCT node_type) as node_types,
|
||||
COUNT(*) as total_configs,
|
||||
AVG(rank) as avg_rank
|
||||
FROM template_node_configs
|
||||
`).get() as any;
|
||||
|
||||
console.log(`📊 Node config stats:`);
|
||||
console.log(` - Unique node types: ${configStats.node_types}`);
|
||||
console.log(` - Total configs stored: ${configStats.total_configs}`);
|
||||
console.log(` - Average rank: ${configStats.avg_rank?.toFixed(1) || 'N/A'}`);
|
||||
} else {
|
||||
console.log('⚠️ No node configurations extracted');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates(
|
||||
mode: 'rebuild' | 'update' = 'rebuild',
|
||||
generateMetadata: boolean = false,
|
||||
metadataOnly: boolean = false,
|
||||
extractOnly: boolean = false
|
||||
) {
|
||||
// If extract-only mode, skip template fetching and only extract configs
|
||||
if (extractOnly) {
|
||||
console.log('📦 Extract-only mode: Extracting node configurations from existing templates...\n');
|
||||
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
|
||||
// Ensure template_node_configs table exists
|
||||
try {
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('📋 Creating template_node_configs table...');
|
||||
const migrationPath = path.join(__dirname, '../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf8');
|
||||
db.exec(migration);
|
||||
console.log('✅ Table created successfully\n');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking/creating template_node_configs table:', error);
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const service = new TemplateService(db);
|
||||
|
||||
await extractTemplateConfigs(db, service);
|
||||
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If metadata-only mode, skip template fetching entirely
|
||||
if (metadataOnly) {
|
||||
console.log('🤖 Metadata-only mode: Generating metadata for existing templates...\n');
|
||||
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
console.error('❌ OPENAI_API_KEY not set in environment');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
const service = new TemplateService(db);
|
||||
|
||||
|
||||
await generateTemplateMetadata(db, service);
|
||||
|
||||
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
@@ -125,7 +344,11 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
|
||||
stats.topUsedNodes.forEach((node: any, index: number) => {
|
||||
console.log(` ${index + 1}. ${node.node} (${node.count} templates)`);
|
||||
});
|
||||
|
||||
|
||||
// Extract node configurations from templates
|
||||
console.log('');
|
||||
await extractTemplateConfigs(db, service);
|
||||
|
||||
// Generate metadata if requested
|
||||
if (generateMetadata && process.env.OPENAI_API_KEY) {
|
||||
console.log('\n🤖 Generating metadata for templates...');
|
||||
@@ -133,7 +356,7 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
|
||||
} else if (generateMetadata && !process.env.OPENAI_API_KEY) {
|
||||
console.log('\n⚠️ Metadata generation requested but OPENAI_API_KEY not set');
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error fetching templates:', error);
|
||||
process.exit(1);
|
||||
@@ -237,39 +460,45 @@ async function generateTemplateMetadata(db: any, service: TemplateService) {
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean } {
|
||||
function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean, extractOnly: boolean } {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
|
||||
let mode: 'rebuild' | 'update' = 'rebuild';
|
||||
let generateMetadata = false;
|
||||
let metadataOnly = false;
|
||||
|
||||
let extractOnly = false;
|
||||
|
||||
// Check for --mode flag
|
||||
const modeIndex = args.findIndex(arg => arg.startsWith('--mode'));
|
||||
if (modeIndex !== -1) {
|
||||
const modeArg = args[modeIndex];
|
||||
const modeValue = modeArg.includes('=') ? modeArg.split('=')[1] : args[modeIndex + 1];
|
||||
|
||||
|
||||
if (modeValue === 'update') {
|
||||
mode = 'update';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for --update flag as shorthand
|
||||
if (args.includes('--update')) {
|
||||
mode = 'update';
|
||||
}
|
||||
|
||||
|
||||
// Check for --generate-metadata flag
|
||||
if (args.includes('--generate-metadata') || args.includes('--metadata')) {
|
||||
generateMetadata = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for --metadata-only flag
|
||||
if (args.includes('--metadata-only')) {
|
||||
metadataOnly = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for --extract-only flag
|
||||
if (args.includes('--extract-only') || args.includes('--extract')) {
|
||||
extractOnly = true;
|
||||
}
|
||||
|
||||
// Show help if requested
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log('Usage: npm run fetch:templates [options]\n');
|
||||
@@ -279,17 +508,19 @@ function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, m
|
||||
console.log(' --generate-metadata Generate AI metadata after fetching templates');
|
||||
console.log(' --metadata Shorthand for --generate-metadata');
|
||||
console.log(' --metadata-only Only generate metadata, skip template fetching');
|
||||
console.log(' --extract-only Only extract node configs, skip template fetching');
|
||||
console.log(' --extract Shorthand for --extract-only');
|
||||
console.log(' --help, -h Show this help message');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return { mode, generateMetadata, metadataOnly };
|
||||
|
||||
return { mode, generateMetadata, metadataOnly, extractOnly };
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const { mode, generateMetadata, metadataOnly } = parseArgs();
|
||||
fetchTemplates(mode, generateMetadata, metadataOnly).catch(console.error);
|
||||
const { mode, generateMetadata, metadataOnly, extractOnly } = parseArgs();
|
||||
fetchTemplates(mode, generateMetadata, metadataOnly, extractOnly).catch(console.error);
|
||||
}
|
||||
|
||||
export { fetchTemplates };
|
||||
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* Task Templates Service
|
||||
*
|
||||
*
|
||||
* @deprecated This module is deprecated as of v2.15.0 and will be removed in v2.16.0.
|
||||
* The get_node_for_task tool has been removed in favor of template-based configuration examples.
|
||||
*
|
||||
* Migration:
|
||||
* - Use `search_nodes({query: "webhook", includeExamples: true})` to find nodes with real template configs
|
||||
* - Use `get_node_essentials({nodeType: "nodes-base.webhook", includeExamples: true})` for top 3 examples
|
||||
* - New approach provides 2,646 real templates vs 31 hardcoded tasks
|
||||
*
|
||||
* Provides pre-configured node settings for common tasks.
|
||||
* This helps AI agents quickly configure nodes for specific use cases.
|
||||
*/
|
||||
|
||||
484
tests/fixtures/template-configs.ts
vendored
Normal file
484
tests/fixtures/template-configs.ts
vendored
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Test fixtures for template node configurations
|
||||
* Used across unit and integration tests for P0-R3 feature
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
export interface TemplateConfigFixture {
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
use_cases: string;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: any[];
|
||||
connections: Record<string, any>;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample node configurations for common use cases
|
||||
*/
|
||||
export const sampleConfigs: Record<string, TemplateConfigFixture> = {
|
||||
simpleWebhook: {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 1,
|
||||
template_name: 'Simple Webhook Trigger',
|
||||
template_views: 5000,
|
||||
node_name: 'Webhook',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook',
|
||||
responseMode: 'lastNode',
|
||||
alwaysOutputData: true
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['webhook processing', 'trigger automation']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
webhookWithAuth: {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 2,
|
||||
template_name: 'Authenticated Webhook',
|
||||
template_views: 3000,
|
||||
node_name: 'Webhook',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'secure-webhook',
|
||||
responseMode: 'responseNode',
|
||||
authentication: 'headerAuth'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
httpHeaderAuth: {
|
||||
id: '1',
|
||||
name: 'Header Auth'
|
||||
}
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 0,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['secure webhook', 'authenticated triggers']),
|
||||
rank: 2
|
||||
},
|
||||
|
||||
httpRequestBasic: {
|
||||
node_type: 'n8n-nodes-base.httpRequest',
|
||||
template_id: 3,
|
||||
template_name: 'Basic HTTP GET Request',
|
||||
template_views: 10000,
|
||||
node_name: 'HTTP Request',
|
||||
parameters_json: JSON.stringify({
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET',
|
||||
responseFormat: 'json',
|
||||
options: {
|
||||
timeout: 10000,
|
||||
redirect: {
|
||||
followRedirects: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['API calls', 'data fetching']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
httpRequestWithExpressions: {
|
||||
node_type: 'n8n-nodes-base.httpRequest',
|
||||
template_id: 4,
|
||||
template_name: 'Dynamic HTTP Request',
|
||||
template_views: 7500,
|
||||
node_name: 'HTTP Request',
|
||||
parameters_json: JSON.stringify({
|
||||
url: '={{ $json.apiUrl }}',
|
||||
method: 'POST',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
values: [
|
||||
{
|
||||
name: 'userId',
|
||||
value: '={{ $json.userId }}'
|
||||
},
|
||||
{
|
||||
name: 'action',
|
||||
value: '={{ $json.action }}'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
timeout: '={{ $json.timeout || 10000 }}'
|
||||
}
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 1,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['dynamic API calls', 'expression-based routing']),
|
||||
rank: 2
|
||||
},
|
||||
|
||||
slackMessage: {
|
||||
node_type: 'n8n-nodes-base.slack',
|
||||
template_id: 5,
|
||||
template_name: 'Send Slack Message',
|
||||
template_views: 8000,
|
||||
node_name: 'Slack',
|
||||
parameters_json: JSON.stringify({
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#general',
|
||||
text: 'Hello from n8n!'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
slackApi: {
|
||||
id: '2',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['notifications', 'team communication']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
codeNodeTransform: {
|
||||
node_type: 'n8n-nodes-base.code',
|
||||
template_id: 6,
|
||||
template_name: 'Data Transformation',
|
||||
template_views: 6000,
|
||||
node_name: 'Code',
|
||||
parameters_json: JSON.stringify({
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
|
||||
return items.map(item => ({
|
||||
json: {
|
||||
id: item.json.id,
|
||||
name: item.json.name.toUpperCase(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}));`
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['data transformation', 'custom logic']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
codeNodeWithExpressions: {
|
||||
node_type: 'n8n-nodes-base.code',
|
||||
template_id: 7,
|
||||
template_name: 'Advanced Code with Expressions',
|
||||
template_views: 4500,
|
||||
node_name: 'Code',
|
||||
parameters_json: JSON.stringify({
|
||||
mode: 'runOnceForEachItem',
|
||||
jsCode: `const data = $input.item.json;
|
||||
const previousNode = $('HTTP Request').first().json;
|
||||
|
||||
return {
|
||||
json: {
|
||||
combined: data.value + previousNode.value,
|
||||
nodeRef: $node
|
||||
}
|
||||
};`
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 1,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['advanced transformations', 'node references']),
|
||||
rank: 2
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample workflows for testing extraction
|
||||
*/
|
||||
export const sampleWorkflows: Record<string, WorkflowFixture> = {
|
||||
webhookToSlack: {
|
||||
id: '1',
|
||||
name: 'Webhook to Slack Notification',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'alert',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#alerts',
|
||||
text: '={{ $json.message }}'
|
||||
},
|
||||
credentials: {
|
||||
slackApi: {
|
||||
id: '1',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
webhook1: {
|
||||
main: [[{ node: 'slack1', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
},
|
||||
|
||||
apiWorkflow: {
|
||||
id: '2',
|
||||
name: 'API Data Processing',
|
||||
nodes: [
|
||||
{
|
||||
id: 'http1',
|
||||
name: 'Fetch Data',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/users',
|
||||
method: 'GET',
|
||||
responseFormat: 'json'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'code1',
|
||||
name: 'Transform',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: 'return $input.all().map(item => ({ json: { ...item.json, processed: true } }));'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http2',
|
||||
name: 'Send Results',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [650, 300],
|
||||
parameters: {
|
||||
url: '={{ $json.callbackUrl }}',
|
||||
method: 'POST',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
values: [
|
||||
{ name: 'data', value: '={{ JSON.stringify($json) }}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
http1: {
|
||||
main: [[{ node: 'code1', type: 'main', index: 0 }]]
|
||||
},
|
||||
code1: {
|
||||
main: [[{ node: 'http2', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
},
|
||||
|
||||
complexWorkflow: {
|
||||
id: '3',
|
||||
name: 'Complex Multi-Node Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sticky1',
|
||||
name: 'Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [100, 200],
|
||||
parameters: {
|
||||
content: 'This workflow processes incoming data'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'if1',
|
||||
name: 'Check Type',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 1,
|
||||
position: [300, 300],
|
||||
parameters: {
|
||||
conditions: {
|
||||
boolean: [
|
||||
{
|
||||
value1: '={{ $json.type }}',
|
||||
value2: 'premium'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http1',
|
||||
name: 'Premium API',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [500, 200],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/premium',
|
||||
method: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http2',
|
||||
name: 'Standard API',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [500, 400],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/standard',
|
||||
method: 'POST'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
webhook1: {
|
||||
main: [[{ node: 'if1', type: 'main', index: 0 }]]
|
||||
},
|
||||
if1: {
|
||||
main: [
|
||||
[{ node: 'http1', type: 'main', index: 0 }],
|
||||
[{ node: 'http2', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compress workflow to base64 (mimics n8n template format)
|
||||
*/
|
||||
export function compressWorkflow(workflow: WorkflowFixture): string {
|
||||
const json = JSON.stringify(workflow);
|
||||
return zlib.gzipSync(Buffer.from(json, 'utf-8')).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create template metadata
|
||||
*/
|
||||
export function createTemplateMetadata(complexity: 'simple' | 'medium' | 'complex', useCases: string[]) {
|
||||
return {
|
||||
complexity,
|
||||
use_cases: useCases
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create configs for testing
|
||||
*/
|
||||
export function createConfigBatch(nodeType: string, count: number): TemplateConfigFixture[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
node_type: nodeType,
|
||||
template_id: i + 1,
|
||||
template_name: `Template ${i + 1}`,
|
||||
template_views: 1000 - (i * 50),
|
||||
node_name: `Node ${i + 1}`,
|
||||
parameters_json: JSON.stringify({ index: i }),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: (['simple', 'medium', 'complex'] as const)[i % 3],
|
||||
use_cases: JSON.stringify(['test use case']),
|
||||
rank: i + 1
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config by complexity
|
||||
*/
|
||||
export function getConfigByComplexity(complexity: 'simple' | 'medium' | 'complex'): TemplateConfigFixture {
|
||||
const configs = Object.values(sampleConfigs);
|
||||
const match = configs.find(c => c.complexity === complexity);
|
||||
return match || configs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configs with expressions
|
||||
*/
|
||||
export function getConfigsWithExpressions(): TemplateConfigFixture[] {
|
||||
return Object.values(sampleConfigs).filter(c => c.has_expressions === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configs with credentials
|
||||
*/
|
||||
export function getConfigsWithCredentials(): TemplateConfigFixture[] {
|
||||
return Object.values(sampleConfigs).filter(c => c.has_credentials === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock database insert helper
|
||||
*/
|
||||
export function createInsertStatement(config: TemplateConfigFixture): string {
|
||||
return `INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (
|
||||
'${config.node_type}',
|
||||
${config.template_id},
|
||||
'${config.template_name}',
|
||||
${config.template_views},
|
||||
'${config.node_name}',
|
||||
'${config.parameters_json.replace(/'/g, "''")}',
|
||||
${config.credentials_json ? `'${config.credentials_json.replace(/'/g, "''")}'` : 'NULL'},
|
||||
${config.has_credentials},
|
||||
${config.has_expressions},
|
||||
'${config.complexity}',
|
||||
'${config.use_cases.replace(/'/g, "''")}',
|
||||
${config.rank || 0}
|
||||
)`;
|
||||
}
|
||||
534
tests/integration/database/template-node-configs.test.ts
Normal file
534
tests/integration/database/template-node-configs.test.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Integration tests for template_node_configs table
|
||||
* Testing database schema, migrations, and data operations
|
||||
*/
|
||||
|
||||
describe('Template Node Configs Database Integration', () => {
|
||||
let db: DatabaseAdapter;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary database
|
||||
dbPath = ':memory:';
|
||||
db = await createDatabaseAdapter(dbPath);
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
// Apply migration
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
db.exec(migration);
|
||||
|
||||
// Insert test templates with id 1-1000 to satisfy foreign key constraints
|
||||
// Tests insert configs with various template_id values, so we pre-create many templates
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
stmt.run(i, i, `Test Template ${i}`, 'Test template for node configs', 100, '[]');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Schema Validation', () => {
|
||||
it('should create template_node_configs table', () => {
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(tableExists).toBeDefined();
|
||||
expect(tableExists).toHaveProperty('name', 'template_node_configs');
|
||||
});
|
||||
|
||||
it('should have all required columns', () => {
|
||||
const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[];
|
||||
|
||||
const columnNames = columns.map(col => col.name);
|
||||
expect(columnNames).toContain('id');
|
||||
expect(columnNames).toContain('node_type');
|
||||
expect(columnNames).toContain('template_id');
|
||||
expect(columnNames).toContain('template_name');
|
||||
expect(columnNames).toContain('template_views');
|
||||
expect(columnNames).toContain('node_name');
|
||||
expect(columnNames).toContain('parameters_json');
|
||||
expect(columnNames).toContain('credentials_json');
|
||||
expect(columnNames).toContain('has_credentials');
|
||||
expect(columnNames).toContain('has_expressions');
|
||||
expect(columnNames).toContain('complexity');
|
||||
expect(columnNames).toContain('use_cases');
|
||||
expect(columnNames).toContain('rank');
|
||||
expect(columnNames).toContain('created_at');
|
||||
});
|
||||
|
||||
it('should have correct column types and constraints', () => {
|
||||
const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[];
|
||||
|
||||
const idColumn = columns.find(col => col.name === 'id');
|
||||
expect(idColumn.pk).toBe(1); // Primary key
|
||||
|
||||
const nodeTypeColumn = columns.find(col => col.name === 'node_type');
|
||||
expect(nodeTypeColumn.notnull).toBe(1); // NOT NULL
|
||||
|
||||
const parametersJsonColumn = columns.find(col => col.name === 'parameters_json');
|
||||
expect(parametersJsonColumn.notnull).toBe(1); // NOT NULL
|
||||
});
|
||||
|
||||
it('should have complexity CHECK constraint', () => {
|
||||
// Try to insert invalid complexity
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, complexity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}',
|
||||
'invalid' // Should fail CHECK constraint
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should accept valid complexity values', () => {
|
||||
const validComplexities = ['simple', 'medium', 'complex'];
|
||||
|
||||
validComplexities.forEach((complexity, index) => {
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, complexity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
index + 1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}',
|
||||
complexity
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM template_node_configs').get() as any;
|
||||
expect(count.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Indexes', () => {
|
||||
it('should create idx_config_node_type_rank index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_node_type_rank');
|
||||
});
|
||||
|
||||
it('should create idx_config_complexity index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_complexity');
|
||||
});
|
||||
|
||||
it('should create idx_config_auth index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('View: ranked_node_configs', () => {
|
||||
it('should create ranked_node_configs view', () => {
|
||||
const viewExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='view' AND name='ranked_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(viewExists).toBeDefined();
|
||||
expect(viewExists).toHaveProperty('name', 'ranked_node_configs');
|
||||
});
|
||||
|
||||
it('should return only top 5 ranked configs per node type', () => {
|
||||
// Insert 10 configs for same node type with different ranks
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
i,
|
||||
`Template ${i}`,
|
||||
1000 - (i * 50), // Decreasing views
|
||||
'HTTP Request',
|
||||
'{}',
|
||||
i // Rank 1-10
|
||||
);
|
||||
}
|
||||
|
||||
const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs').all() as any[];
|
||||
|
||||
// Should only return rank 1-5
|
||||
expect(rankedConfigs).toHaveLength(5);
|
||||
expect(Math.max(...rankedConfigs.map(c => c.rank))).toBe(5);
|
||||
expect(Math.min(...rankedConfigs.map(c => c.rank))).toBe(1);
|
||||
});
|
||||
|
||||
it('should order by node_type and rank', () => {
|
||||
// Insert configs for multiple node types
|
||||
const configs = [
|
||||
{ nodeType: 'n8n-nodes-base.webhook', rank: 2 },
|
||||
{ nodeType: 'n8n-nodes-base.webhook', rank: 1 },
|
||||
{ nodeType: 'n8n-nodes-base.httpRequest', rank: 2 },
|
||||
{ nodeType: 'n8n-nodes-base.httpRequest', rank: 1 },
|
||||
];
|
||||
|
||||
configs.forEach((config, index) => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
config.nodeType,
|
||||
index + 1,
|
||||
`Template ${index}`,
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
config.rank
|
||||
);
|
||||
});
|
||||
|
||||
const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs ORDER BY node_type, rank').all() as any[];
|
||||
|
||||
// First two should be httpRequest rank 1, 2
|
||||
expect(rankedConfigs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(rankedConfigs[0].rank).toBe(1);
|
||||
expect(rankedConfigs[1].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(rankedConfigs[1].rank).toBe(2);
|
||||
|
||||
// Last two should be webhook rank 1, 2
|
||||
expect(rankedConfigs[2].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(rankedConfigs[2].rank).toBe(1);
|
||||
expect(rankedConfigs[3].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(rankedConfigs[3].rank).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Foreign Key Constraints', () => {
|
||||
beforeEach(() => {
|
||||
// Enable foreign keys
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
// Note: Templates are already created in the main beforeEach
|
||||
});
|
||||
|
||||
it('should allow inserting config with valid template_id', () => {
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1, // Valid template_id
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}'
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should cascade delete configs when template is deleted', () => {
|
||||
// Insert config
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}'
|
||||
);
|
||||
|
||||
// Verify config exists
|
||||
let configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[];
|
||||
expect(configs).toHaveLength(1);
|
||||
|
||||
// Delete template
|
||||
db.prepare('DELETE FROM templates WHERE id = ?').run(1);
|
||||
|
||||
// Verify config is deleted (CASCADE)
|
||||
configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[];
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Operations', () => {
|
||||
it('should insert and retrieve config with all fields', () => {
|
||||
const testConfig = {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 1,
|
||||
template_name: 'Webhook Template',
|
||||
template_views: 2000,
|
||||
node_name: 'Webhook Trigger',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test',
|
||||
responseMode: 'lastNode'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
webhookAuth: { id: '1', name: 'Webhook Auth' }
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 1,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['webhook processing', 'automation triggers']),
|
||||
rank: 1
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(...Object.values(testConfig));
|
||||
|
||||
const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any;
|
||||
|
||||
expect(retrieved.node_type).toBe(testConfig.node_type);
|
||||
expect(retrieved.template_id).toBe(testConfig.template_id);
|
||||
expect(retrieved.template_name).toBe(testConfig.template_name);
|
||||
expect(retrieved.template_views).toBe(testConfig.template_views);
|
||||
expect(retrieved.node_name).toBe(testConfig.node_name);
|
||||
expect(retrieved.parameters_json).toBe(testConfig.parameters_json);
|
||||
expect(retrieved.credentials_json).toBe(testConfig.credentials_json);
|
||||
expect(retrieved.has_credentials).toBe(testConfig.has_credentials);
|
||||
expect(retrieved.has_expressions).toBe(testConfig.has_expressions);
|
||||
expect(retrieved.complexity).toBe(testConfig.complexity);
|
||||
expect(retrieved.use_cases).toBe(testConfig.use_cases);
|
||||
expect(retrieved.rank).toBe(testConfig.rank);
|
||||
expect(retrieved.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle nullable fields correctly', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}'
|
||||
);
|
||||
|
||||
const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any;
|
||||
|
||||
expect(retrieved.credentials_json).toBeNull();
|
||||
expect(retrieved.has_credentials).toBe(0); // Default value
|
||||
expect(retrieved.has_expressions).toBe(0); // Default value
|
||||
expect(retrieved.rank).toBe(0); // Default value
|
||||
});
|
||||
|
||||
it('should update rank values', () => {
|
||||
// Insert multiple configs
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
i,
|
||||
'Template',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
0 // Initial rank
|
||||
);
|
||||
}
|
||||
|
||||
// Update ranks
|
||||
db.exec(`
|
||||
UPDATE template_node_configs
|
||||
SET rank = (
|
||||
SELECT COUNT(*) + 1
|
||||
FROM template_node_configs AS t2
|
||||
WHERE t2.node_type = template_node_configs.node_type
|
||||
AND t2.template_views > template_node_configs.template_views
|
||||
)
|
||||
`);
|
||||
|
||||
const configs = db.prepare('SELECT * FROM template_node_configs ORDER BY rank').all() as any[];
|
||||
|
||||
// All should have same rank (same views)
|
||||
expect(configs.every(c => c.rank === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete configs with rank > 10', () => {
|
||||
// Insert 15 configs with different ranks
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
i,
|
||||
'Template',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
i // Rank 1-15
|
||||
);
|
||||
}
|
||||
|
||||
// Delete configs with rank > 10
|
||||
db.exec(`
|
||||
DELETE FROM template_node_configs
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM template_node_configs
|
||||
WHERE rank <= 10
|
||||
ORDER BY node_type, rank
|
||||
)
|
||||
`);
|
||||
|
||||
const remaining = db.prepare('SELECT * FROM template_node_configs').all() as any[];
|
||||
|
||||
expect(remaining).toHaveLength(10);
|
||||
expect(Math.max(...remaining.map(c => c.rank))).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query Performance', () => {
|
||||
beforeEach(() => {
|
||||
// Insert 1000 configs for performance testing
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const nodeTypes = [
|
||||
'n8n-nodes-base.httpRequest',
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.googleSheets',
|
||||
'n8n-nodes-base.code'
|
||||
];
|
||||
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
stmt.run(
|
||||
nodeType,
|
||||
i,
|
||||
`Template ${i}`,
|
||||
Math.floor(Math.random() * 10000),
|
||||
'Node',
|
||||
'{}',
|
||||
(i % 10) + 1 // Rank 1-10
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should query by node_type and rank efficiently', () => {
|
||||
const start = Date.now();
|
||||
const results = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.httpRequest') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(duration).toBeLessThan(10); // Should be very fast with index
|
||||
});
|
||||
|
||||
it('should filter by complexity efficiently', () => {
|
||||
// First set some complexity values
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'complex' WHERE id % 3 = 2`);
|
||||
|
||||
const start = Date.now();
|
||||
const results = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ? AND complexity = ?
|
||||
ORDER BY rank
|
||||
LIMIT 5
|
||||
`).all('n8n-nodes-base.webhook', 'simple') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(10); // Should be fast with index
|
||||
});
|
||||
});
|
||||
|
||||
describe('Migration Idempotency', () => {
|
||||
it('should be safe to run migration multiple times', () => {
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
|
||||
// Run migration again
|
||||
expect(() => {
|
||||
db.exec(migration);
|
||||
}).not.toThrow();
|
||||
|
||||
// Table should still exist
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(tableExists).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -483,10 +483,11 @@ describe('MCP Session Management', { timeout: 15000 }, () => {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// Multiple error-inducing requests
|
||||
// Note: get_node_for_task was removed in v2.15.0
|
||||
const errorPromises = [
|
||||
client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid1' } }).catch(e => e),
|
||||
client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid2' } }).catch(e => e),
|
||||
client.callTool({ name: 'get_node_for_task', arguments: { task: 'invalid_task' } }).catch(e => e)
|
||||
client.callTool({ name: 'search_nodes', arguments: { query: '' } }).catch(e => e) // Empty query should error
|
||||
];
|
||||
|
||||
const errors = await Promise.all(errorPromises);
|
||||
|
||||
@@ -459,30 +459,8 @@ describe('MCP Tool Invocation', () => {
|
||||
});
|
||||
|
||||
describe('Task Templates', () => {
|
||||
describe('get_node_for_task', () => {
|
||||
it('should return pre-configured node for task', async () => {
|
||||
const response = await client.callTool({ name: 'get_node_for_task', arguments: {
|
||||
task: 'post_json_request'
|
||||
}});
|
||||
|
||||
const config = JSON.parse(((response as any).content[0]).text);
|
||||
expect(config).toHaveProperty('task');
|
||||
expect(config).toHaveProperty('nodeType');
|
||||
expect(config).toHaveProperty('configuration');
|
||||
expect(config.configuration.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should handle unknown tasks', async () => {
|
||||
try {
|
||||
await client.callTool({ name: 'get_node_for_task', arguments: {
|
||||
task: 'unknown_task'
|
||||
}});
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain('Unknown task');
|
||||
}
|
||||
});
|
||||
});
|
||||
// get_node_for_task was removed in v2.15.0
|
||||
// Use search_nodes({ includeExamples: true }) instead for real-world examples
|
||||
|
||||
describe('list_tasks', () => {
|
||||
it('should list all available tasks', async () => {
|
||||
|
||||
452
tests/integration/mcp/template-examples-e2e.test.ts
Normal file
452
tests/integration/mcp/template-examples-e2e.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createDatabaseAdapter, DatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { sampleConfigs, compressWorkflow, sampleWorkflows } from '../../fixtures/template-configs';
|
||||
|
||||
/**
|
||||
* End-to-end integration tests for template-based examples feature
|
||||
* Tests the complete flow: database -> MCP server -> examples in response
|
||||
*/
|
||||
|
||||
describe('Template Examples E2E Integration', () => {
|
||||
let db: DatabaseAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory database
|
||||
db = await createDatabaseAdapter(':memory:');
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
// Apply migration
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
db.exec(migration);
|
||||
|
||||
// Seed test data
|
||||
seedTemplateConfigs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
function seedTemplateConfigs() {
|
||||
// Insert sample templates first to satisfy foreign key constraints
|
||||
// The sampleConfigs use template_id 1-4, edge cases use 998-999
|
||||
const templateIds = [1, 2, 3, 4, 998, 999];
|
||||
for (const id of templateIds) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(
|
||||
id,
|
||||
id,
|
||||
`Test Template ${id}`,
|
||||
'Test Description',
|
||||
1000,
|
||||
JSON.stringify(['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'])
|
||||
);
|
||||
}
|
||||
|
||||
// Insert webhook configs
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.simpleWebhook)
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.webhookWithAuth)
|
||||
);
|
||||
|
||||
// Insert HTTP request configs
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.httpRequestBasic)
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.httpRequestWithExpressions)
|
||||
);
|
||||
}
|
||||
|
||||
describe('Querying Examples Directly', () => {
|
||||
it('should fetch top 2 examples for webhook node', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(examples).toHaveLength(2);
|
||||
expect(examples[0].template_name).toBe('Simple Webhook Trigger');
|
||||
expect(examples[1].template_name).toBe('Authenticated Webhook');
|
||||
});
|
||||
|
||||
it('should fetch top 3 examples with metadata for HTTP request node', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views,
|
||||
complexity,
|
||||
use_cases,
|
||||
has_credentials,
|
||||
has_expressions
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.httpRequest') as any[];
|
||||
|
||||
expect(examples).toHaveLength(2); // Only 2 inserted
|
||||
expect(examples[0].template_name).toBe('Basic HTTP GET Request');
|
||||
expect(examples[0].complexity).toBe('simple');
|
||||
expect(examples[0].has_expressions).toBe(0);
|
||||
|
||||
expect(examples[1].template_name).toBe('Dynamic HTTP Request');
|
||||
expect(examples[1].complexity).toBe('complex');
|
||||
expect(examples[1].has_expressions).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Example Data Structure Validation', () => {
|
||||
it('should have valid JSON in parameters_json', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT parameters_json
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 1
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(() => {
|
||||
const params = JSON.parse(examples[0].parameters_json);
|
||||
expect(params).toHaveProperty('httpMethod');
|
||||
expect(params).toHaveProperty('path');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have valid JSON in use_cases', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT use_cases
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 1
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(() => {
|
||||
const useCases = JSON.parse(examples[0].use_cases);
|
||||
expect(Array.isArray(useCases)).toBe(true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have credentials_json when has_credentials is 1', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT credentials_json, has_credentials
|
||||
FROM template_node_configs
|
||||
WHERE has_credentials = 1
|
||||
LIMIT 1
|
||||
`).all() as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
expect(examples[0].credentials_json).not.toBeNull();
|
||||
expect(() => {
|
||||
JSON.parse(examples[0].credentials_json);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ranked View Functionality', () => {
|
||||
it('should return only top 5 ranked configs per node type from view', () => {
|
||||
// Insert templates first to satisfy foreign key constraints
|
||||
// Note: seedTemplateConfigs already created templates 1-4, so start from 5
|
||||
for (let i = 5; i <= 14; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(i, i, `Template ${i}`, 'Test', 1000 - (i * 50), '[]');
|
||||
}
|
||||
|
||||
// Insert 10 configs for same node type
|
||||
for (let i = 5; i <= 14; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.webhook',
|
||||
i,
|
||||
`Template ${i}`,
|
||||
1000 - (i * 50),
|
||||
'Webhook',
|
||||
'{}',
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
const rankedConfigs = db.prepare(`
|
||||
SELECT * FROM ranked_node_configs
|
||||
WHERE node_type = ?
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(rankedConfigs.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance with Real-World Data Volume', () => {
|
||||
beforeEach(() => {
|
||||
// Insert templates first to satisfy foreign key constraints
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(i + 100, i + 100, `Template ${i}`, 'Test', Math.floor(Math.random() * 10000), '[]');
|
||||
}
|
||||
|
||||
// Insert 100 configs across 10 different node types
|
||||
const nodeTypes = [
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.googleSheets',
|
||||
'n8n-nodes-base.code',
|
||||
'n8n-nodes-base.if',
|
||||
'n8n-nodes-base.switch',
|
||||
'n8n-nodes-base.set',
|
||||
'n8n-nodes-base.merge',
|
||||
'n8n-nodes-base.splitInBatches',
|
||||
'n8n-nodes-base.postgres',
|
||||
'n8n-nodes-base.gmail'
|
||||
];
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nodeType,
|
||||
i + 100, // Offset template_id
|
||||
`Template ${i}`,
|
||||
Math.floor(Math.random() * 10000),
|
||||
'Node',
|
||||
'{}',
|
||||
(i % 10) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should query specific node type examples quickly', () => {
|
||||
const start = Date.now();
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.slack') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(examples.length).toBeGreaterThan(0);
|
||||
expect(duration).toBeLessThan(5); // Should be very fast with index
|
||||
});
|
||||
|
||||
it('should filter by complexity efficiently', () => {
|
||||
// Set complexity on configs
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`);
|
||||
|
||||
const start = Date.now();
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ? AND complexity = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.code', 'simple') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle node types with no configs', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 2
|
||||
`).all('n8n-nodes-base.nonexistent') as any[];
|
||||
|
||||
expect(examples).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle very long parameters_json', () => {
|
||||
const longParams = JSON.stringify({
|
||||
options: {
|
||||
queryParameters: Array.from({ length: 100 }, (_, i) => ({
|
||||
name: `param${i}`,
|
||||
value: `value${i}`.repeat(10)
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
999,
|
||||
'Long Params Template',
|
||||
100,
|
||||
'Test',
|
||||
longParams,
|
||||
1
|
||||
);
|
||||
|
||||
const example = db.prepare(`
|
||||
SELECT parameters_json FROM template_node_configs WHERE template_id = ?
|
||||
`).get(999) as any;
|
||||
|
||||
expect(() => {
|
||||
const parsed = JSON.parse(example.parameters_json);
|
||||
expect(parsed.options.queryParameters).toHaveLength(100);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle special characters in parameters', () => {
|
||||
const specialParams = JSON.stringify({
|
||||
message: "Test with 'quotes' and \"double quotes\"",
|
||||
unicode: "特殊文字 🎉 émojis",
|
||||
symbols: "!@#$%^&*()_+-={}[]|\\:;<>?,./"
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
998,
|
||||
'Special Chars Template',
|
||||
100,
|
||||
'Test',
|
||||
specialParams,
|
||||
1
|
||||
);
|
||||
|
||||
const example = db.prepare(`
|
||||
SELECT parameters_json FROM template_node_configs WHERE template_id = ?
|
||||
`).get(998) as any;
|
||||
|
||||
expect(() => {
|
||||
const parsed = JSON.parse(example.parameters_json);
|
||||
expect(parsed.message).toContain("'quotes'");
|
||||
expect(parsed.unicode).toContain("🎉");
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Integrity', () => {
|
||||
it('should maintain referential integrity with templates table', () => {
|
||||
// Try to insert config with non-existent template_id (with FK enabled)
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
999999, // Non-existent template_id
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
1
|
||||
);
|
||||
}).toThrow(); // Should fail due to FK constraint
|
||||
});
|
||||
|
||||
it('should cascade delete configs when template is deleted', () => {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Insert a new template (use id 1000 to avoid conflicts with seedTemplateConfigs)
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(1000, 1000, 'Test Template 1000', 'Desc', 100, '[]');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1000,
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
1
|
||||
);
|
||||
|
||||
// Verify config exists
|
||||
let config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000);
|
||||
expect(config).toBeDefined();
|
||||
|
||||
// Delete template
|
||||
db.prepare('DELETE FROM templates WHERE id = ?').run(1000);
|
||||
|
||||
// Verify config is deleted (CASCADE)
|
||||
config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000);
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
434
tests/unit/mcp/get-node-essentials-examples.test.ts
Normal file
434
tests/unit/mcp/get-node-essentials-examples.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
|
||||
/**
|
||||
* Unit tests for get_node_essentials with includeExamples parameter
|
||||
* Testing P0-R3 feature: Template-based configuration examples with metadata
|
||||
*/
|
||||
|
||||
describe('get_node_essentials with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
// NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx)
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.httpRequest',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'HTTP Request',
|
||||
description: 'Makes an HTTP request',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.test',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Test Node',
|
||||
description: 'Test node for examples',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert test nodes into the in-memory database
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('includeExamples parameter', () => {
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is undefined', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', undefined);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include examples when includeExamples is true', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Note: In-memory test database may not have template configs
|
||||
// This test validates the parameter is processed correctly
|
||||
});
|
||||
|
||||
it('should limit examples to top 3 per node', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
if (result.examples) {
|
||||
expect(result.examples.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('example data structure with metadata', () => {
|
||||
it('should return examples with full metadata structure', async () => {
|
||||
// Mock database to return example data with metadata
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test',
|
||||
responseMode: 'lastNode'
|
||||
}),
|
||||
template_name: 'Webhook Template',
|
||||
template_views: 2000,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['webhook processing', 'API integration']),
|
||||
has_credentials: 0,
|
||||
has_expressions: 1
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
const example = result.examples[0];
|
||||
|
||||
// Verify structure
|
||||
expect(example).toHaveProperty('configuration');
|
||||
expect(example).toHaveProperty('source');
|
||||
expect(example).toHaveProperty('useCases');
|
||||
expect(example).toHaveProperty('metadata');
|
||||
|
||||
// Verify source structure
|
||||
expect(example.source).toHaveProperty('template');
|
||||
expect(example.source).toHaveProperty('views');
|
||||
expect(example.source).toHaveProperty('complexity');
|
||||
|
||||
// Verify metadata structure
|
||||
expect(example.metadata).toHaveProperty('hasCredentials');
|
||||
expect(example.metadata).toHaveProperty('hasExpressions');
|
||||
|
||||
// Verify types
|
||||
expect(typeof example.configuration).toBe('object');
|
||||
expect(typeof example.source.template).toBe('string');
|
||||
expect(typeof example.source.views).toBe('number');
|
||||
expect(typeof example.source.complexity).toBe('string');
|
||||
expect(Array.isArray(example.useCases)).toBe(true);
|
||||
expect(typeof example.metadata.hasCredentials).toBe('boolean');
|
||||
expect(typeof example.metadata.hasExpressions).toBe('boolean');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should include complexity in source metadata', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({ url: 'https://api.example.com' }),
|
||||
template_name: 'Simple HTTP Request',
|
||||
template_views: 500,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify([]),
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
},
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
url: '={{ $json.url }}',
|
||||
options: { timeout: 30000 }
|
||||
}),
|
||||
template_name: 'Complex HTTP Request',
|
||||
template_views: 300,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['advanced API calls']),
|
||||
has_credentials: 1,
|
||||
has_expressions: 1
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
if (result.examples && result.examples.length >= 2) {
|
||||
expect(result.examples[0].source.complexity).toBe('simple');
|
||||
expect(result.examples[1].source.complexity).toBe('complex');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should limit use cases to 2 items', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify([
|
||||
'use case 1',
|
||||
'use case 2',
|
||||
'use case 3',
|
||||
'use case 4'
|
||||
]),
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
expect(result.examples[0].useCases.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty use_cases gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
expect(result.examples[0].useCases).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching behavior with includeExamples', () => {
|
||||
it('should use different cache keys for with/without examples', async () => {
|
||||
const cache = (server as any).cache;
|
||||
const cacheGetSpy = vi.spyOn(cache, 'get');
|
||||
|
||||
// First call without examples
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('basic'));
|
||||
|
||||
// Second call with examples
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('withExamples'));
|
||||
});
|
||||
|
||||
it('should cache results separately for different includeExamples values', async () => {
|
||||
// Call with examples
|
||||
const resultWithExamples1 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Call without examples
|
||||
const resultWithoutExamples = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
|
||||
// Call with examples again (should be cached)
|
||||
const resultWithExamples2 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Results with examples should match
|
||||
expect(resultWithExamples1).toEqual(resultWithExamples2);
|
||||
|
||||
// Result without examples should not have examples
|
||||
expect(resultWithoutExamples.examples).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should maintain backward compatibility when includeExamples not specified', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.nodeType).toBeDefined();
|
||||
expect(result.displayName).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return same core data regardless of includeExamples value', async () => {
|
||||
const resultWithout = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
const resultWith = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Core fields should be identical
|
||||
expect(resultWithout.nodeType).toBe(resultWith.nodeType);
|
||||
expect(resultWithout.displayName).toBe(resultWith.displayName);
|
||||
expect(resultWithout.description).toBe(resultWith.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue to work even if example fetch fails', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.nodeType).toBeDefined();
|
||||
// Examples should be empty array due to error (fallback behavior)
|
||||
expect(result.examples).toEqual([]);
|
||||
expect(result.examplesCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed JSON in template configs gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: 'invalid json',
|
||||
template_name: 'Test',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: 'also invalid',
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance', () => {
|
||||
it('should complete in reasonable time with examples', async () => {
|
||||
const start = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Should complete under 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should not add significant overhead when includeExamples is false', async () => {
|
||||
const startWithout = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
const durationWithout = Date.now() - startWithout;
|
||||
|
||||
const startWith = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
const durationWith = Date.now() - startWith;
|
||||
|
||||
// Both should be fast
|
||||
expect(durationWithout).toBeLessThan(50);
|
||||
expect(durationWith).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe('Parameter Validation', () => {
|
||||
vi.spyOn(server as any, 'getNodeDocumentation').mockResolvedValue({ docs: 'test' });
|
||||
vi.spyOn(server as any, 'getNodeEssentials').mockResolvedValue({ essentials: true });
|
||||
vi.spyOn(server as any, 'searchNodeProperties').mockResolvedValue({ properties: [] });
|
||||
vi.spyOn(server as any, 'getNodeForTask').mockResolvedValue({ node: 'test' });
|
||||
// Note: getNodeForTask removed in v2.15.0
|
||||
vi.spyOn(server as any, 'validateNodeConfig').mockResolvedValue({ valid: true });
|
||||
vi.spyOn(server as any, 'validateNodeMinimal').mockResolvedValue({ missing: [] });
|
||||
vi.spyOn(server as any, 'getPropertyDependencies').mockResolvedValue({ dependencies: {} });
|
||||
@@ -477,7 +477,7 @@ describe('Parameter Validation', () => {
|
||||
{ name: 'get_node_documentation', args: {}, expected: 'Missing required parameters for get_node_documentation: nodeType' },
|
||||
{ name: 'get_node_essentials', args: {}, expected: 'Missing required parameters for get_node_essentials: nodeType' },
|
||||
{ name: 'search_node_properties', args: {}, expected: 'Missing required parameters for search_node_properties: nodeType, query' },
|
||||
{ name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' },
|
||||
// Note: get_node_for_task removed in v2.15.0
|
||||
{ name: 'get_property_dependencies', args: {}, expected: 'Missing required parameters for get_property_dependencies: nodeType' },
|
||||
{ name: 'get_node_as_tool_info', args: {}, expected: 'Missing required parameters for get_node_as_tool_info: nodeType' },
|
||||
{ name: 'get_template', args: {}, expected: 'Missing required parameters for get_template: templateId' },
|
||||
|
||||
383
tests/unit/mcp/search-nodes-examples.test.ts
Normal file
383
tests/unit/mcp/search-nodes-examples.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
import { createDatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* Unit tests for search_nodes with includeExamples parameter
|
||||
* Testing P0-R3 feature: Template-based configuration examples
|
||||
*/
|
||||
|
||||
describe('search_nodes with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use in-memory database for testing
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
// NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx)
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.httpRequest',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'HTTP Request',
|
||||
description: 'Makes an HTTP request',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert test nodes into the in-memory database
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
// Note: FTS table is not created in test environment
|
||||
// searchNodes will fall back to LIKE search when FTS doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('includeExamples parameter', () => {
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: false });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is undefined', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, {});
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should include examples when includeExamples is true', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
// Note: In-memory test database may not have template configs
|
||||
// This test validates the parameter is processed correctly
|
||||
});
|
||||
|
||||
it('should handle nodes without examples gracefully', async () => {
|
||||
const result = await (server as any).searchNodes('nonexistent', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should limit examples to top 2 per node', async () => {
|
||||
// This test would need a database with actual template_node_configs data
|
||||
// In a real scenario, we'd verify that only 2 examples are returned
|
||||
const result = await (server as any).searchNodes('http', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
if (node.examples) {
|
||||
expect(node.examples.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('example data structure', () => {
|
||||
it('should return examples with correct structure when present', async () => {
|
||||
// Mock database to return example data
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test'
|
||||
}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 1000
|
||||
},
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'GET',
|
||||
path: 'webhook-get'
|
||||
}),
|
||||
template_name: 'Another Template',
|
||||
template_views: 500
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
if (result.results.length > 0 && result.results[0].examples) {
|
||||
const example = result.results[0].examples[0];
|
||||
expect(example).toHaveProperty('configuration');
|
||||
expect(example).toHaveProperty('template');
|
||||
expect(example).toHaveProperty('views');
|
||||
expect(typeof example.configuration).toBe('object');
|
||||
expect(typeof example.template).toBe('string');
|
||||
expect(typeof example.views).toBe('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should maintain backward compatibility when includeExamples not specified', async () => {
|
||||
const resultWithoutParam = await (server as any).searchNodes('http', 5);
|
||||
const resultWithFalse = await (server as any).searchNodes('http', 5, { includeExamples: false });
|
||||
|
||||
expect(resultWithoutParam.results).toBeDefined();
|
||||
expect(resultWithFalse.results).toBeDefined();
|
||||
|
||||
// Both should have same structure (no examples)
|
||||
if (resultWithoutParam.results.length > 0) {
|
||||
expect(resultWithoutParam.results[0].examples).toBeUndefined();
|
||||
}
|
||||
if (resultWithFalse.results.length > 0) {
|
||||
expect(resultWithFalse.results[0].examples).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance considerations', () => {
|
||||
it('should not significantly impact performance when includeExamples is false', async () => {
|
||||
const startWithout = Date.now();
|
||||
await (server as any).searchNodes('http', 20, { includeExamples: false });
|
||||
const durationWithout = Date.now() - startWithout;
|
||||
|
||||
const startWith = Date.now();
|
||||
await (server as any).searchNodes('http', 20, { includeExamples: true });
|
||||
const durationWith = Date.now() - startWith;
|
||||
|
||||
// Both should complete quickly (under 100ms)
|
||||
expect(durationWithout).toBeLessThan(100);
|
||||
expect(durationWith).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue to work even if example fetch fails', async () => {
|
||||
// Mock database to throw error on example fetch
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw, should return results without examples
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
// Examples should be undefined due to error
|
||||
if (result.results.length > 0) {
|
||||
expect(result.results[0].examples).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed parameters_json gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: 'invalid json',
|
||||
template_name: 'Test Template',
|
||||
template_views: 1000
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNodesLIKE with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
it('should support includeExamples in LIKE search', async () => {
|
||||
const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: false });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNodesFTS with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
it('should support includeExamples in FTS search', async () => {
|
||||
const result = await (server as any).searchNodesFTS('webhook', 5, 'OR', { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass options to example fetching logic', async () => {
|
||||
const result = await (server as any).searchNodesFTS('http', 5, 'AND', { includeExamples: true });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,7 @@ describe('n8nDocumentationToolsFinal', () => {
|
||||
discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'],
|
||||
configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'],
|
||||
validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'],
|
||||
templates: ['list_tasks', 'get_node_for_task', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'],
|
||||
templates: ['list_tasks', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'], // get_node_for_task removed in v2.15.0
|
||||
documentation: ['tools_documentation']
|
||||
};
|
||||
|
||||
|
||||
456
tests/unit/scripts/fetch-templates-extraction.test.ts
Normal file
456
tests/unit/scripts/fetch-templates-extraction.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
/**
|
||||
* Unit tests for template configuration extraction functions
|
||||
* Testing the core logic from fetch-templates.ts
|
||||
*/
|
||||
|
||||
// Extract the functions to test by importing or recreating them
|
||||
function extractNodeConfigs(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowCompressed: string,
|
||||
metadata: any
|
||||
): Array<{
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: string;
|
||||
use_cases: string;
|
||||
}> {
|
||||
try {
|
||||
const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64'));
|
||||
const workflow = JSON.parse(decompressed.toString('utf-8'));
|
||||
|
||||
const configs: any[] = [];
|
||||
|
||||
for (const node of workflow.nodes || []) {
|
||||
if (node.type.includes('stickyNote') || !node.parameters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
node_type: node.type,
|
||||
template_id: templateId,
|
||||
template_name: templateName,
|
||||
template_views: templateViews,
|
||||
node_name: node.name,
|
||||
parameters_json: JSON.stringify(node.parameters),
|
||||
credentials_json: node.credentials ? JSON.stringify(node.credentials) : null,
|
||||
has_credentials: node.credentials ? 1 : 0,
|
||||
has_expressions: detectExpressions(node.parameters) ? 1 : 0,
|
||||
complexity: metadata?.complexity || 'medium',
|
||||
use_cases: JSON.stringify(metadata?.use_cases || [])
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function detectExpressions(params: any): boolean {
|
||||
if (!params) return false;
|
||||
const json = JSON.stringify(params);
|
||||
return json.includes('={{') || json.includes('$json') || json.includes('$node');
|
||||
}
|
||||
|
||||
describe('Template Configuration Extraction', () => {
|
||||
describe('extractNodeConfigs', () => {
|
||||
it('should extract configs from valid workflow with multiple nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const metadata = {
|
||||
complexity: 'simple',
|
||||
use_cases: ['webhook processing', 'API calls']
|
||||
};
|
||||
|
||||
const configs = extractNodeConfigs(1, 'Test Template', 500, compressed, metadata);
|
||||
|
||||
expect(configs).toHaveLength(2);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(configs[0].node_name).toBe('Webhook');
|
||||
expect(configs[0].template_id).toBe(1);
|
||||
expect(configs[0].template_name).toBe('Test Template');
|
||||
expect(configs[0].template_views).toBe(500);
|
||||
expect(configs[0].has_credentials).toBe(0);
|
||||
expect(configs[0].complexity).toBe('simple');
|
||||
|
||||
const parsedParams = JSON.parse(configs[0].parameters_json);
|
||||
expect(parsedParams.httpMethod).toBe('POST');
|
||||
expect(parsedParams.path).toBe('webhook-test');
|
||||
|
||||
expect(configs[1].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(configs[1].node_name).toBe('HTTP Request');
|
||||
});
|
||||
|
||||
it('should return empty array for workflow with no nodes', () => {
|
||||
const workflow = { nodes: [], connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
|
||||
const configs = extractNodeConfigs(1, 'Empty Template', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip sticky note nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'sticky1',
|
||||
name: 'Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: { content: 'This is a note' }
|
||||
},
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
});
|
||||
|
||||
it('should skip nodes without parameters', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'No Params',
|
||||
type: 'n8n-nodes-base.someNode',
|
||||
typeVersion: 1,
|
||||
position: [100, 100]
|
||||
// No parameters field
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'With Params',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
});
|
||||
|
||||
it('should handle nodes with credentials', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'post'
|
||||
},
|
||||
credentials: {
|
||||
slackApi: {
|
||||
id: '1',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].has_credentials).toBe(1);
|
||||
expect(configs[0].credentials_json).toBeTruthy();
|
||||
|
||||
const creds = JSON.parse(configs[0].credentials_json!);
|
||||
expect(creds.slackApi).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use default complexity when metadata is missing', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs[0].complexity).toBe('medium');
|
||||
expect(configs[0].use_cases).toBe('[]');
|
||||
});
|
||||
|
||||
it('should handle malformed compressed data gracefully', () => {
|
||||
const invalidCompressed = 'invalid-base64-data';
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, invalidCompressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON after decompression', () => {
|
||||
const invalidJson = 'not valid json';
|
||||
const compressed = zlib.gzipSync(Buffer.from(invalidJson)).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle workflows with missing nodes array', () => {
|
||||
const workflow = { connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectExpressions', () => {
|
||||
it('should detect n8n expression syntax with ={{...}}', () => {
|
||||
const params = {
|
||||
url: '={{ $json.apiUrl }}',
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect $json references', () => {
|
||||
const params = {
|
||||
body: {
|
||||
data: '$json.data'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect $node references', () => {
|
||||
const params = {
|
||||
url: 'https://api.example.com',
|
||||
headers: {
|
||||
authorization: '$node["Webhook"].json.token'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for parameters without expressions', () => {
|
||||
const params = {
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested objects with expressions', () => {
|
||||
const params = {
|
||||
options: {
|
||||
queryParameters: {
|
||||
filters: {
|
||||
id: '={{ $json.userId }}'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null parameters', () => {
|
||||
expect(detectExpressions(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined parameters', () => {
|
||||
expect(detectExpressions(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty object', () => {
|
||||
expect(detectExpressions({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array parameters with expressions', () => {
|
||||
const params = {
|
||||
items: [
|
||||
{ value: '={{ $json.item1 }}' },
|
||||
{ value: '={{ $json.item2 }}' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect multiple expression types in same params', () => {
|
||||
const params = {
|
||||
url: '={{ $node["HTTP Request"].json.nextUrl }}',
|
||||
body: {
|
||||
data: '$json.data',
|
||||
token: '={{ $json.token }}'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very large workflows without crashing', () => {
|
||||
const nodes = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
name: `Node ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100 * i, 100],
|
||||
parameters: {
|
||||
url: `https://api.example.com/${i}`,
|
||||
method: 'GET'
|
||||
}
|
||||
}));
|
||||
|
||||
const workflow = { nodes, connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Large Template', 1000, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('should handle special characters in node names and parameters', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Node with 特殊文字 & émojis 🎉',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com?query=test&special=值',
|
||||
headers: {
|
||||
'X-Custom-Header': 'value with spaces & symbols!@#$%'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_name).toBe('Node with 特殊文字 & émojis 🎉');
|
||||
|
||||
const params = JSON.parse(configs[0].parameters_json);
|
||||
expect(params.headers['X-Custom-Header']).toBe('value with spaces & symbols!@#$%');
|
||||
});
|
||||
|
||||
it('should preserve parameter structure exactly as in workflow', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Complex Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com',
|
||||
options: {
|
||||
queryParameters: {
|
||||
filters: [
|
||||
{ name: 'status', value: 'active' },
|
||||
{ name: 'type', value: 'user' }
|
||||
]
|
||||
},
|
||||
timeout: 10000,
|
||||
redirect: {
|
||||
followRedirects: true,
|
||||
maxRedirects: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
const params = JSON.parse(configs[0].parameters_json);
|
||||
expect(params.options.queryParameters.filters).toHaveLength(2);
|
||||
expect(params.options.timeout).toBe(10000);
|
||||
expect(params.options.redirect.maxRedirects).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user