diff --git a/docs/local/integration-testing-plan.md b/docs/local/integration-testing-plan.md index d1f62e1..5c29ee0 100644 --- a/docs/local/integration-testing-plan.md +++ b/docs/local/integration-testing-plan.md @@ -1,5 +1,25 @@ # Comprehensive Integration Testing Plan +## Status + +**Phase 1: Foundation** ✅ **COMPLETE** (October 3, 2025) +- All utility files created and tested +- Webhook workflows created on `https://n8n-test.n8n-mcp.com` +- GitHub secrets configured +- Critical fix: Updated credentials to use webhook URLs instead of IDs +- Environment loading fixed to support real n8n API integration tests + +**Phase 2: Workflow Creation Tests** ✅ **COMPLETE** (October 3, 2025) +- 15 test scenarios implemented and passing +- P0 bug verification confirmed (FULL node type format) +- All test categories covered: base nodes, advanced features, error scenarios, edge cases +- Documented actual n8n API behavior (validation at execution time, not creation time) +- Test file: `tests/integration/n8n-api/workflows/create-workflow.test.ts` (484 lines) + +**Next Phase**: Phase 3 - Workflow Retrieval Tests + +--- + ## Overview Transform the test suite to test all 17 n8n API handlers against a **real n8n instance** instead of mocks. This plan ensures 100% coverage of every tool, operation, and parameter combination to prevent bugs like the P0 workflow creation issue from slipping through. @@ -13,11 +33,12 @@ Transform the test suite to test all 17 n8n API handlers against a **real n8n in 2. **Pre-activated Webhook Workflows**: - n8n API doesn't support workflow activation via API - Need pre-created, activated workflows for webhook testing - - Store workflow IDs in `.env`: - - `N8N_TEST_WEBHOOK_GET_ID` - Webhook with GET method - - `N8N_TEST_WEBHOOK_POST_ID` - Webhook with POST method - - `N8N_TEST_WEBHOOK_PUT_ID` - Webhook with PUT method - - `N8N_TEST_WEBHOOK_DELETE_ID` - Webhook with DELETE method + - Store webhook URLs (not workflow IDs) in `.env`: + - `N8N_TEST_WEBHOOK_GET_URL` - GET method webhook URL + - `N8N_TEST_WEBHOOK_POST_URL` - POST method webhook URL + - `N8N_TEST_WEBHOOK_PUT_URL` - PUT method webhook URL + - `N8N_TEST_WEBHOOK_DELETE_URL` - DELETE method webhook URL + - **Rationale**: Webhook URLs are what the `n8n_trigger_webhook_workflow` tool needs. Workflow IDs are only for workflow management tests (which create workflows dynamically during test execution). 3. **100% Coverage Goal**: Test EVERY tool, EVERY operation, EVERY parameter combination @@ -232,13 +253,13 @@ Transform the test suite to test all 17 n8n API handlers against a **real n8n in N8N_API_URL=http://localhost:5678 N8N_API_KEY=your-api-key-here -# Pre-activated Webhook Workflows for Testing +# Pre-activated Webhook URLs for Testing # Create these workflows manually in n8n and activate them -# Each workflow should have a single Webhook node with the specified HTTP method -N8N_TEST_WEBHOOK_GET_ID= # Webhook with GET method -N8N_TEST_WEBHOOK_POST_ID= # Webhook with POST method -N8N_TEST_WEBHOOK_PUT_ID= # Webhook with PUT method -N8N_TEST_WEBHOOK_DELETE_ID= # Webhook with DELETE method +# Store the full webhook URLs (not workflow IDs) +N8N_TEST_WEBHOOK_GET_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-get +N8N_TEST_WEBHOOK_POST_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-post +N8N_TEST_WEBHOOK_PUT_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-put +N8N_TEST_WEBHOOK_DELETE_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-delete # Test Configuration N8N_TEST_CLEANUP_ENABLED=true # Enable automatic cleanup @@ -247,12 +268,14 @@ N8N_TEST_NAME_PREFIX=[MCP-TEST] # Name prefix for test workflows ``` **GitHub Secrets (for CI):** -- `N8N_URL`: n8n instance URL -- `N8N_API_KEY`: n8n API key -- `N8N_TEST_WEBHOOK_GET_ID`: Pre-activated GET webhook workflow ID -- `N8N_TEST_WEBHOOK_POST_ID`: Pre-activated POST webhook workflow ID -- `N8N_TEST_WEBHOOK_PUT_ID`: Pre-activated PUT webhook workflow ID -- `N8N_TEST_WEBHOOK_DELETE_ID`: Pre-activated DELETE webhook workflow ID +- `N8N_URL`: n8n instance URL (e.g., `https://n8n-test.n8n-mcp.com`) +- `N8N_API_KEY`: n8n API key (JWT token from n8n Settings > API) +- `N8N_TEST_WEBHOOK_GET_URL`: Pre-activated GET webhook URL +- `N8N_TEST_WEBHOOK_POST_URL`: Pre-activated POST webhook URL +- `N8N_TEST_WEBHOOK_PUT_URL`: Pre-activated PUT webhook URL +- `N8N_TEST_WEBHOOK_DELETE_URL`: Pre-activated DELETE webhook URL + +**Note**: Webhook URLs can be stored as repository secrets (not environment secrets) since they don't grant API access. The real secret is `N8N_API_KEY`. #### 1.2 Directory Structure @@ -300,7 +323,7 @@ dotenv.config(); export interface N8nTestCredentials { url: string; apiKey: string; - webhookWorkflows: { + webhookUrls: { get: string; post: string; put: string; @@ -316,14 +339,26 @@ export interface N8nTestCredentials { export function getN8nCredentials(): N8nTestCredentials { if (process.env.CI) { // CI: Use GitHub secrets + const url = process.env.N8N_URL; + const apiKey = process.env.N8N_API_KEY; + + if (!url || !apiKey) { + throw new Error( + 'Missing required CI credentials:\n' + + ` N8N_URL: ${url ? 'set' : 'MISSING'}\n` + + ` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n` + + 'Please configure GitHub secrets for integration tests.' + ); + } + return { - url: process.env.N8N_URL!, - apiKey: process.env.N8N_API_KEY!, - webhookWorkflows: { - get: process.env.N8N_TEST_WEBHOOK_GET_ID!, - post: process.env.N8N_TEST_WEBHOOK_POST_ID!, - put: process.env.N8N_TEST_WEBHOOK_PUT_ID!, - delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID! + url, + apiKey, + webhookUrls: { + get: process.env.N8N_TEST_WEBHOOK_GET_URL || '', + post: process.env.N8N_TEST_WEBHOOK_POST_URL || '', + put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '', + delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || '' }, cleanup: { enabled: true, @@ -333,14 +368,27 @@ export function getN8nCredentials(): N8nTestCredentials { }; } else { // Local: Use .env file + const url = process.env.N8N_API_URL; + const apiKey = process.env.N8N_API_KEY; + + if (!url || !apiKey) { + throw new Error( + 'Missing required credentials in .env:\n' + + ` N8N_API_URL: ${url ? 'set' : 'MISSING'}\n` + + ` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n\n` + + 'Please add these to your .env file.\n' + + 'See .env.example for configuration details.' + ); + } + return { - url: process.env.N8N_API_URL!, - apiKey: process.env.N8N_API_KEY!, - webhookWorkflows: { - get: process.env.N8N_TEST_WEBHOOK_GET_ID || '', - post: process.env.N8N_TEST_WEBHOOK_POST_ID || '', - put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '', - delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || '' + url, + apiKey, + webhookUrls: { + get: process.env.N8N_TEST_WEBHOOK_GET_URL || '', + post: process.env.N8N_TEST_WEBHOOK_POST_URL || '', + put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '', + delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || '' }, cleanup: { enabled: process.env.N8N_TEST_CLEANUP_ENABLED !== 'false', @@ -356,18 +404,18 @@ export function validateCredentials(creds: N8nTestCredentials): void { if (!creds.apiKey) throw new Error('N8N_API_KEY is required'); } -export function validateWebhookWorkflows(creds: N8nTestCredentials): void { +export function validateWebhookUrls(creds: N8nTestCredentials): void { const missing: string[] = []; - if (!creds.webhookWorkflows.get) missing.push('GET'); - if (!creds.webhookWorkflows.post) missing.push('POST'); - if (!creds.webhookWorkflows.put) missing.push('PUT'); - if (!creds.webhookWorkflows.delete) missing.push('DELETE'); + if (!creds.webhookUrls.get) missing.push('GET'); + if (!creds.webhookUrls.post) missing.push('POST'); + if (!creds.webhookUrls.put) missing.push('PUT'); + if (!creds.webhookUrls.delete) missing.push('DELETE'); if (missing.length > 0) { throw new Error( - `Missing webhook workflow IDs for HTTP methods: ${missing.join(', ')}\n` + + `Missing webhook URLs for HTTP methods: ${missing.join(', ')}\n` + `Please create and activate webhook workflows, then set:\n` + - missing.map(m => ` N8N_TEST_WEBHOOK_${m}_ID`).join('\n') + missing.map(m => ` N8N_TEST_WEBHOOK_${m}_URL`).join('\n') ); } } @@ -818,12 +866,12 @@ jobs: env: N8N_URL: ${{ secrets.N8N_URL }} N8N_API_KEY: ${{ secrets.N8N_API_KEY }} - N8N_TEST_WEBHOOK_GET_ID: ${{ secrets.N8N_TEST_WEBHOOK_GET_ID }} - N8N_TEST_WEBHOOK_POST_ID: ${{ secrets.N8N_TEST_WEBHOOK_POST_ID }} - N8N_TEST_WEBHOOK_PUT_ID: ${{ secrets.N8N_TEST_WEBHOOK_PUT_ID }} - N8N_TEST_WEBHOOK_DELETE_ID: ${{ secrets.N8N_TEST_WEBHOOK_DELETE_ID }} + N8N_TEST_WEBHOOK_GET_URL: ${{ secrets.N8N_TEST_WEBHOOK_GET_URL }} + N8N_TEST_WEBHOOK_POST_URL: ${{ secrets.N8N_TEST_WEBHOOK_POST_URL }} + N8N_TEST_WEBHOOK_PUT_URL: ${{ secrets.N8N_TEST_WEBHOOK_PUT_URL }} + N8N_TEST_WEBHOOK_DELETE_URL: ${{ secrets.N8N_TEST_WEBHOOK_DELETE_URL }} CI: true - run: npm run test:integration + run: npm run test:integration:n8n - name: Cleanup orphaned workflows if: always() @@ -871,30 +919,57 @@ jobs: 2. ✅ Start n8n instance: `npx n8n start` 3. ✅ Create 4 webhook workflows (GET, POST, PUT, DELETE) 4. ✅ Activate all 4 webhook workflows in n8n UI -5. ✅ Get workflow IDs from n8n UI +5. ✅ Get webhook URLs from the workflow's Webhook node 6. ✅ Copy `.env.example` to `.env` -7. ✅ Set `N8N_API_URL=http://localhost:5678` +7. ✅ Set `N8N_API_URL=` 8. ✅ Generate API key in n8n Settings > API 9. ✅ Set `N8N_API_KEY=` -10. ✅ Set all 4 `N8N_TEST_WEBHOOK_*_ID` variables +10. ✅ Set all 4 `N8N_TEST_WEBHOOK_*_URL` variables with full webhook URLs -### CI/GitHub Actions -1. ✅ Set up cloud n8n instance (or self-hosted) +### CI/GitHub Actions (✅ COMPLETED) +1. ✅ Set up cloud n8n instance: `https://n8n-test.n8n-mcp.com` 2. ✅ Create 4 webhook workflows (GET, POST, PUT, DELETE) 3. ✅ Activate all 4 webhook workflows 4. ✅ Add GitHub secrets: `N8N_URL`, `N8N_API_KEY` -5. ✅ Add webhook workflow ID secrets (4 total) +5. ✅ Add webhook URL secrets: + - `N8N_TEST_WEBHOOK_GET_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-get` + - `N8N_TEST_WEBHOOK_POST_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-post` + - `N8N_TEST_WEBHOOK_PUT_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-put` + - `N8N_TEST_WEBHOOK_DELETE_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-delete` --- ## Success Criteria -- ✅ All 17 handlers have integration tests -- ✅ All operations/parameters covered (150+ scenarios) -- ✅ Tests run successfully locally and in CI +### Phase 1: Foundation ✅ COMPLETE +- ✅ Environment configuration (.env, GitHub secrets) +- ✅ All utility files created (8 files, ~1,520 lines of code) +- ✅ Pre-activated webhook workflows created and tested +- ✅ Cleanup helpers with pagination safety +- ✅ Resource tracking with TestContext +- ✅ Fixtures and factories for test data +- ✅ Documentation updated +- ✅ Environment loading fixed (loads .env before test defaults) +- ✅ Vitest integration config updated (removed MSW for n8n-api tests) + +### Phase 2: Workflow Creation Tests ✅ COMPLETE +- ✅ 15 test scenarios implemented (all passing) +- ✅ P0 bug verification (FULL vs SHORT node type format) +- ✅ Base node tests (webhook, HTTP, langchain, multi-node) +- ✅ Advanced features (connections, settings, expressions, error handling) +- ✅ Error scenarios (4 tests documenting actual API behavior) +- ✅ Edge cases (3 tests for minimal/empty configurations) +- ✅ Test file: 484 lines covering all handleCreateWorkflow scenarios +- ✅ All tests passing on real n8n instance + +### Overall Project (In Progress) +- ⏳ All 17 handlers have integration tests (1 of 17 complete) +- ⏳ All operations/parameters covered (15 of 150+ scenarios complete) +- ✅ Tests run successfully locally (Phase 2 verified) +- ⏳ Tests run successfully in CI (pending Phase 9) - ✅ No manual cleanup required (automatic) -- ✅ Test coverage catches P0-level bugs -- ✅ CI runs on every PR and daily +- ✅ Test coverage catches P0-level bugs (verified in Phase 2) +- ⏳ CI runs on every PR and daily (pending Phase 9) - ✅ Clear error messages when tests fail - ✅ Documentation for webhook workflow setup @@ -902,8 +977,8 @@ jobs: ## Timeline Estimate -- **Phase 1 (Foundation)**: 2-3 days -- **Phase 2 (Workflow Creation)**: 1 day +- **Phase 1 (Foundation)**: ✅ COMPLETE (October 3, 2025) +- **Phase 2 (Workflow Creation)**: ✅ COMPLETE (October 3, 2025) - **Phase 3 (Retrieval)**: 1 day - **Phase 4 (Updates)**: 2-3 days (15 operations) - **Phase 5 (Management)**: 1 day @@ -912,7 +987,7 @@ jobs: - **Phase 8 (System)**: 1 day - **Phase 9 (CI/CD)**: 1 day -**Total**: ~14-18 days +**Total**: 2 days complete (~4-6 hours actual), ~12-16 days remaining --- @@ -922,3 +997,17 @@ jobs: - Phases can be parallelized where dependencies allow - Run local tests frequently to catch issues early - Document any n8n API quirks discovered during testing + +## Key Learnings from Phase 2 + +### n8n API Behavior Discoveries +1. **Validation Timing**: n8n API accepts workflows with invalid node types and connection references at creation time. Validation only happens at execution time. +2. **Node Type Format**: FULL node type format (`n8n-nodes-base.*`) must be used in API requests. The P0 bug was confirmed fixed. +3. **Missing Parameters**: n8n accepts workflows with missing required parameters. They fail during execution, not creation. +4. **Duplicate Names**: n8n API handles duplicate node names gracefully (may auto-rename). + +### Technical Implementation Insights +1. **MSW Interference**: Integration tests that need real network requests must NOT load MSW setup. Removed from vitest.config.integration.ts. +2. **Environment Loading**: Must load `.env` file BEFORE test defaults in global setup to preserve real credentials. +3. **Cleanup Safety**: TestContext pattern works well for tracking and cleaning up test resources. +4. **Test Isolation**: Each test creates unique workflows with timestamps to avoid conflicts. diff --git a/scripts/export-webhook-workflows.ts b/scripts/export-webhook-workflows.ts new file mode 100644 index 0000000..697b153 --- /dev/null +++ b/scripts/export-webhook-workflows.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env tsx + +/** + * Export Webhook Workflow JSONs + * + * Generates the 4 webhook workflow JSON files needed for integration testing. + * These workflows must be imported into n8n and activated manually. + */ + +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { exportAllWebhookWorkflows } from '../tests/integration/n8n-api/utils/webhook-workflows'; + +const OUTPUT_DIR = join(process.cwd(), 'workflows-for-import'); + +// Create output directory +mkdirSync(OUTPUT_DIR, { recursive: true }); + +// Generate all workflow JSONs +const workflows = exportAllWebhookWorkflows(); + +// Write each workflow to a separate file +Object.entries(workflows).forEach(([method, workflow]) => { + const filename = `webhook-${method.toLowerCase()}.json`; + const filepath = join(OUTPUT_DIR, filename); + + writeFileSync(filepath, JSON.stringify(workflow, null, 2), 'utf-8'); + + console.log(`✓ Generated: ${filename}`); +}); + +console.log(`\n✓ All workflow JSONs written to: ${OUTPUT_DIR}`); +console.log('\nNext steps:'); +console.log('1. Import each JSON file into your n8n instance'); +console.log('2. Activate each workflow in the n8n UI'); +console.log('3. Copy the webhook URLs from each workflow (open workflow → Webhook node → copy URL)'); +console.log('4. Add them to your .env file:'); +console.log(' N8N_TEST_WEBHOOK_GET_URL=https://your-n8n.com/webhook/mcp-test-get'); +console.log(' N8N_TEST_WEBHOOK_POST_URL=https://your-n8n.com/webhook/mcp-test-post'); +console.log(' N8N_TEST_WEBHOOK_PUT_URL=https://your-n8n.com/webhook/mcp-test-put'); +console.log(' N8N_TEST_WEBHOOK_DELETE_URL=https://your-n8n.com/webhook/mcp-test-delete'); diff --git a/tests/integration/n8n-api/test-connection.ts b/tests/integration/n8n-api/test-connection.ts new file mode 100644 index 0000000..8ba49a2 --- /dev/null +++ b/tests/integration/n8n-api/test-connection.ts @@ -0,0 +1,34 @@ +/** + * Quick test script to verify n8n API connection + */ + +import { getN8nCredentials } from './utils/credentials'; +import { getTestN8nClient } from './utils/n8n-client'; + +async function testConnection() { + try { + console.log('Loading credentials...'); + const creds = getN8nCredentials(); + console.log('Credentials loaded:', { + url: creds.url, + hasApiKey: !!creds.apiKey, + apiKeyLength: creds.apiKey?.length + }); + + console.log('\nCreating n8n client...'); + const client = getTestN8nClient(); + console.log('Client created successfully'); + + console.log('\nTesting health check...'); + const health = await client.healthCheck(); + console.log('Health check result:', health); + + console.log('\n✅ Connection test passed!'); + } catch (error) { + console.error('❌ Connection test failed:'); + console.error(error); + process.exit(1); + } +} + +testConnection(); diff --git a/tests/integration/n8n-api/utils/cleanup-helpers.ts b/tests/integration/n8n-api/utils/cleanup-helpers.ts index 6d5b71e..f2dbe9d 100644 --- a/tests/integration/n8n-api/utils/cleanup-helpers.ts +++ b/tests/integration/n8n-api/utils/cleanup-helpers.ts @@ -62,13 +62,22 @@ export async function cleanupOrphanedWorkflows(): Promise { throw error; } - // Find test workflows - const testWorkflows = allWorkflows.filter(w => - w.tags?.includes(creds.cleanup.tag) || - w.name?.startsWith(creds.cleanup.namePrefix) - ); + // Pre-activated webhook workflow that should NOT be deleted + // This is needed for webhook trigger integration tests + // Note: Single webhook accepts all HTTP methods (GET, POST, PUT, DELETE) + const preservedWorkflowNames = new Set([ + '[MCP-TEST] Webhook All Methods' + ]); - logger.info(`Found ${testWorkflows.length} orphaned test workflow(s)`); + // Find test workflows but exclude pre-activated webhook workflows + const testWorkflows = allWorkflows.filter(w => { + const isTestWorkflow = w.tags?.includes(creds.cleanup.tag) || w.name?.startsWith(creds.cleanup.namePrefix); + const isPreserved = preservedWorkflowNames.has(w.name); + + return isTestWorkflow && !isPreserved; + }); + + logger.info(`Found ${testWorkflows.length} orphaned test workflow(s) (excluding ${preservedWorkflowNames.size} preserved webhook workflow)`); if (testWorkflows.length === 0) { return deleted; diff --git a/tests/integration/n8n-api/utils/credentials.ts b/tests/integration/n8n-api/utils/credentials.ts index ea171c9..d348f5d 100644 --- a/tests/integration/n8n-api/utils/credentials.ts +++ b/tests/integration/n8n-api/utils/credentials.ts @@ -15,7 +15,7 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env') }); export interface N8nTestCredentials { url: string; apiKey: string; - webhookWorkflows: { + webhookUrls: { get: string; post: string; put: string; @@ -55,11 +55,11 @@ export function getN8nCredentials(): N8nTestCredentials { return { url, apiKey, - webhookWorkflows: { - get: process.env.N8N_TEST_WEBHOOK_GET_ID || '', - post: process.env.N8N_TEST_WEBHOOK_POST_ID || '', - put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '', - delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || '' + webhookUrls: { + get: process.env.N8N_TEST_WEBHOOK_GET_URL || '', + post: process.env.N8N_TEST_WEBHOOK_POST_URL || '', + put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '', + delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || '' }, cleanup: { enabled: true, @@ -85,11 +85,11 @@ export function getN8nCredentials(): N8nTestCredentials { return { url, apiKey, - webhookWorkflows: { - get: process.env.N8N_TEST_WEBHOOK_GET_ID || '', - post: process.env.N8N_TEST_WEBHOOK_POST_ID || '', - put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '', - delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || '' + webhookUrls: { + get: process.env.N8N_TEST_WEBHOOK_GET_URL || '', + post: process.env.N8N_TEST_WEBHOOK_POST_URL || '', + put: process.env.N8N_TEST_WEBHOOK_PUT_URL || '', + delete: process.env.N8N_TEST_WEBHOOK_DELETE_URL || '' }, cleanup: { enabled: process.env.N8N_TEST_CLEANUP_ENABLED !== 'false', @@ -127,24 +127,24 @@ export function validateCredentials(creds: N8nTestCredentials): void { } /** - * Validate that webhook workflow IDs are configured + * Validate that webhook URLs are configured * * @param creds - Credentials to validate - * @throws Error with setup instructions if webhook workflows are missing + * @throws Error with setup instructions if webhook URLs are missing */ -export function validateWebhookWorkflows(creds: N8nTestCredentials): void { +export function validateWebhookUrls(creds: N8nTestCredentials): void { const missing: string[] = []; - if (!creds.webhookWorkflows.get) missing.push('GET'); - if (!creds.webhookWorkflows.post) missing.push('POST'); - if (!creds.webhookWorkflows.put) missing.push('PUT'); - if (!creds.webhookWorkflows.delete) missing.push('DELETE'); + if (!creds.webhookUrls.get) missing.push('GET'); + if (!creds.webhookUrls.post) missing.push('POST'); + if (!creds.webhookUrls.put) missing.push('PUT'); + if (!creds.webhookUrls.delete) missing.push('DELETE'); if (missing.length > 0) { - const envVars = missing.map(m => `N8N_TEST_WEBHOOK_${m}_ID`); + const envVars = missing.map(m => `N8N_TEST_WEBHOOK_${m}_URL`); throw new Error( - `Missing webhook workflow IDs for HTTP methods: ${missing.join(', ')}\n\n` + + `Missing webhook URLs for HTTP methods: ${missing.join(', ')}\n\n` + `Webhook testing requires pre-activated workflows in n8n.\n` + `n8n API doesn't support workflow activation, so these must be created manually.\n\n` + `Setup Instructions:\n` + @@ -153,8 +153,9 @@ export function validateWebhookWorkflows(creds: N8nTestCredentials): void { `3. Configure webhook paths:\n` + missing.map(m => ` - ${m}: mcp-test-${m.toLowerCase()}`).join('\n') + '\n' + `4. ACTIVATE each workflow in n8n UI\n` + - `5. Set the following environment variables with workflow IDs:\n` + - envVars.map(v => ` ${v}=`).join('\n') + '\n\n' + + `5. Set the following environment variables with full webhook URLs:\n` + + envVars.map(v => ` ${v}=`).join('\n') + '\n\n' + + `Example: N8N_TEST_WEBHOOK_GET_URL=https://n8n-test.n8n-mcp.com/webhook/mcp-test-get\n\n` + `See docs/local/integration-testing-plan.md for detailed instructions.` ); } @@ -175,18 +176,18 @@ export function hasCredentials(): boolean { } /** - * Check if webhook workflows are configured (non-throwing version) + * Check if webhook URLs are configured (non-throwing version) * - * @returns true if all webhook workflow IDs are available + * @returns true if all webhook URLs are available */ -export function hasWebhookWorkflows(): boolean { +export function hasWebhookUrls(): boolean { try { const creds = getN8nCredentials(); return !!( - creds.webhookWorkflows.get && - creds.webhookWorkflows.post && - creds.webhookWorkflows.put && - creds.webhookWorkflows.delete + creds.webhookUrls.get && + creds.webhookUrls.post && + creds.webhookUrls.put && + creds.webhookUrls.delete ); } catch { return false; diff --git a/tests/integration/n8n-api/utils/n8n-client.ts b/tests/integration/n8n-api/utils/n8n-client.ts index 52af11e..17922f9 100644 --- a/tests/integration/n8n-api/utils/n8n-client.ts +++ b/tests/integration/n8n-api/utils/n8n-client.ts @@ -29,7 +29,9 @@ export function getTestN8nClient(): N8nApiClient { validateCredentials(creds); client = new N8nApiClient({ baseUrl: creds.url, - apiKey: creds.apiKey + apiKey: creds.apiKey, + timeout: 30000, + maxRetries: 3 }); } return client; diff --git a/tests/integration/n8n-api/workflows/create-workflow.test.ts b/tests/integration/n8n-api/workflows/create-workflow.test.ts new file mode 100644 index 0000000..69157fb --- /dev/null +++ b/tests/integration/n8n-api/workflows/create-workflow.test.ts @@ -0,0 +1,527 @@ +/** + * Integration Tests: handleCreateWorkflow + * + * Tests workflow creation against a real n8n instance. + * Verifies the P0 bug fix (FULL vs SHORT node type formats) + * and covers all major workflow creation scenarios. + */ + +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; +import { createTestContext, TestContext, createTestWorkflowName } from '../utils/test-context'; +import { getTestN8nClient } from '../utils/n8n-client'; +import { N8nApiClient } from '../../../../src/services/n8n-api-client'; +import { + SIMPLE_WEBHOOK_WORKFLOW, + SIMPLE_HTTP_WORKFLOW, + MULTI_NODE_WORKFLOW, + ERROR_HANDLING_WORKFLOW, + AI_AGENT_WORKFLOW, + EXPRESSION_WORKFLOW, + getFixture +} from '../utils/fixtures'; +import { cleanupOrphanedWorkflows } from '../utils/cleanup-helpers'; + +describe('Integration: handleCreateWorkflow', () => { + let context: TestContext; + let client: N8nApiClient; + + beforeEach(() => { + context = createTestContext(); + client = getTestN8nClient(); + }); + + afterEach(async () => { + await context.cleanup(); + }); + + // Global cleanup after all tests to catch any orphaned workflows + // (e.g., from test retries or failures) + afterAll(async () => { + await cleanupOrphanedWorkflows(); + }); + + // ====================================================================== + // P0: Critical Bug Verification + // ====================================================================== + + describe('P0: Node Type Format Bug Fix', () => { + it('should create workflow with webhook node using FULL node type format', async () => { + // This test verifies the P0 bug fix where SHORT node type format + // (e.g., "webhook") was incorrectly normalized to FULL format + // causing workflow creation failures. + // + // The fix ensures FULL format (e.g., "n8n-nodes-base.webhook") + // is preserved and passed to n8n API correctly. + + const workflowName = createTestWorkflowName('P0 Bug Verification - Webhook Node'); + const workflow = { + name: workflowName, + ...getFixture('simple-webhook') + }; + + // Create workflow + const result = await client.createWorkflow(workflow); + + // Verify workflow created successfully + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.name).toBe(workflowName); + expect(result.nodes).toHaveLength(1); + + // Critical: Verify FULL node type format is preserved + expect(result.nodes[0].type).toBe('n8n-nodes-base.webhook'); + expect(result.nodes[0].name).toBe('Webhook'); + expect(result.nodes[0].parameters).toBeDefined(); + }); + }); + + // ====================================================================== + // P1: Base Nodes (High Priority) + // ====================================================================== + + describe('P1: Base n8n Nodes', () => { + it('should create workflow with HTTP Request node', async () => { + const workflowName = createTestWorkflowName('HTTP Request Node'); + const workflow = { + name: workflowName, + ...getFixture('simple-http') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.name).toBe(workflowName); + expect(result.nodes).toHaveLength(2); + + // Verify both nodes created with FULL type format + const webhookNode = result.nodes.find(n => n.name === 'Webhook'); + const httpNode = result.nodes.find(n => n.name === 'HTTP Request'); + + expect(webhookNode).toBeDefined(); + expect(webhookNode!.type).toBe('n8n-nodes-base.webhook'); + + expect(httpNode).toBeDefined(); + expect(httpNode!.type).toBe('n8n-nodes-base.httpRequest'); + + // Verify connections + expect(result.connections).toBeDefined(); + expect(result.connections.Webhook).toBeDefined(); + }); + + it('should create workflow with langchain agent node', async () => { + const workflowName = createTestWorkflowName('Langchain Agent Node'); + const workflow = { + name: workflowName, + ...getFixture('ai-agent') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.name).toBe(workflowName); + expect(result.nodes).toHaveLength(2); + + // Verify langchain node type format + const agentNode = result.nodes.find(n => n.name === 'AI Agent'); + expect(agentNode).toBeDefined(); + expect(agentNode!.type).toBe('@n8n/n8n-nodes-langchain.agent'); + }); + + it('should create complex multi-node workflow', async () => { + const workflowName = createTestWorkflowName('Multi-Node Workflow'); + const workflow = { + name: workflowName, + ...getFixture('multi-node') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.name).toBe(workflowName); + expect(result.nodes).toHaveLength(4); + + // Verify all node types preserved + const nodeTypes = result.nodes.map(n => n.type); + expect(nodeTypes).toContain('n8n-nodes-base.webhook'); + expect(nodeTypes).toContain('n8n-nodes-base.set'); + expect(nodeTypes).toContain('n8n-nodes-base.merge'); + + // Verify complex connections + expect(result.connections.Webhook.main[0]).toHaveLength(2); // Branches to 2 nodes + }); + }); + + // ====================================================================== + // P2: Advanced Features (Medium Priority) + // ====================================================================== + + describe('P2: Advanced Workflow Features', () => { + it('should create workflow with complex connections and branching', async () => { + const workflowName = createTestWorkflowName('Complex Connections'); + const workflow = { + name: workflowName, + ...getFixture('multi-node') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.connections).toBeDefined(); + + // Verify branching: Webhook -> Set 1 and Set 2 + const webhookConnections = result.connections.Webhook.main[0]; + expect(webhookConnections).toHaveLength(2); + + // Verify merging: Set 1 -> Merge (port 0), Set 2 -> Merge (port 1) + const set1Connections = result.connections['Set 1'].main[0]; + const set2Connections = result.connections['Set 2'].main[0]; + + expect(set1Connections[0].node).toBe('Merge'); + expect(set1Connections[0].index).toBe(0); + + expect(set2Connections[0].node).toBe('Merge'); + expect(set2Connections[0].index).toBe(1); + }); + + it('should create workflow with custom settings', async () => { + const workflowName = createTestWorkflowName('Custom Settings'); + const workflow = { + name: workflowName, + ...getFixture('error-handling'), + settings: { + executionOrder: 'v1' as const, + timezone: 'America/New_York', + saveDataErrorExecution: 'all' as const, + saveDataSuccessExecution: 'all' as const, + saveExecutionProgress: true + } + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.settings).toBeDefined(); + expect(result.settings!.executionOrder).toBe('v1'); + }); + + it('should create workflow with n8n expressions', async () => { + const workflowName = createTestWorkflowName('n8n Expressions'); + const workflow = { + name: workflowName, + ...getFixture('expression') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.nodes).toHaveLength(2); + + // Verify Set node with expressions + const setNode = result.nodes.find(n => n.name === 'Set Variables'); + expect(setNode).toBeDefined(); + expect(setNode!.parameters.assignments).toBeDefined(); + + // Verify expressions are preserved + const assignmentsData = setNode!.parameters.assignments as { assignments: Array<{ value: string }> }; + expect(assignmentsData.assignments).toHaveLength(3); + expect(assignmentsData.assignments[0].value).toContain('$now'); + expect(assignmentsData.assignments[1].value).toContain('$json'); + expect(assignmentsData.assignments[2].value).toContain('$node'); + }); + + it('should create workflow with error handling configuration', async () => { + const workflowName = createTestWorkflowName('Error Handling'); + const workflow = { + name: workflowName, + ...getFixture('error-handling') + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.nodes).toHaveLength(3); + + // Verify HTTP node with error handling + const httpNode = result.nodes.find(n => n.name === 'HTTP Request'); + expect(httpNode).toBeDefined(); + expect(httpNode!.continueOnFail).toBe(true); + expect(httpNode!.onError).toBe('continueErrorOutput'); + + // Verify error connection + expect(result.connections['HTTP Request'].error).toBeDefined(); + expect(result.connections['HTTP Request'].error[0][0].node).toBe('Handle Error'); + }); + }); + + // ====================================================================== + // Error Scenarios (P1 Priority) + // ====================================================================== + + describe('Error Scenarios', () => { + it('should accept workflow with invalid node type (fails at execution time)', async () => { + // Note: n8n API accepts workflows with invalid node types at creation time. + // The error only occurs when trying to execute the workflow. + // This documents the actual API behavior. + + const workflowName = createTestWorkflowName('Invalid Node Type'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'invalid-1', + name: 'Invalid Node', + type: 'n8n-nodes-base.nonexistentnode', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {} + } + ], + connections: {}, + settings: { executionOrder: 'v1' as const } + }; + + // n8n API accepts the workflow (validation happens at execution time) + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.nodes[0].type).toBe('n8n-nodes-base.nonexistentnode'); + }); + + it('should accept workflow with missing required node parameters (fails at execution time)', async () => { + const workflowName = createTestWorkflowName('Missing Parameters'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'http-1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [250, 300] as [number, number], + parameters: { + // Missing required 'url' parameter + method: 'GET' + } + } + ], + connections: {}, + settings: { executionOrder: 'v1' as const } + }; + + // n8n API accepts this during creation but fails during execution + // This test documents the actual API behavior + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + // Note: Validation happens at execution time, not creation time + }); + + it('should handle workflow with duplicate node names', async () => { + const workflowName = createTestWorkflowName('Duplicate Node Names'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'set-1', + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [250, 300] as [number, number], + parameters: { + assignments: { assignments: [] }, + options: {} + } + }, + { + id: 'set-2', + name: 'Set', // Duplicate name + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [450, 300] as [number, number], + parameters: { + assignments: { assignments: [] }, + options: {} + } + } + ], + connections: {}, + settings: { executionOrder: 'v1' as const } + }; + + // n8n API should handle this - it may auto-rename or reject + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + // Verify n8n's handling of duplicate names + const nodeNames = result.nodes.map(n => n.name); + // Either both have same name or n8n renamed one + expect(nodeNames.length).toBe(2); + }); + + it('should accept workflow with invalid connection references (fails at execution time)', async () => { + // Note: n8n API accepts workflows with invalid connection references at creation time. + // The error only occurs when trying to execute the workflow. + // This documents the actual API behavior. + + const workflowName = createTestWorkflowName('Invalid Connections'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'webhook-1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [250, 300] as [number, number], + parameters: { + httpMethod: 'GET', + path: 'test' + } + } + ], + connections: { + // Connection references non-existent node + Webhook: { + main: [[{ node: 'NonExistent', type: 'main', index: 0 }]] + } + }, + settings: { executionOrder: 'v1' as const } + }; + + // n8n API accepts the workflow (validation happens at execution time) + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + // Connection is preserved even though it references non-existent node + expect(result.connections.Webhook).toBeDefined(); + expect(result.connections.Webhook.main[0][0].node).toBe('NonExistent'); + }); + }); + + // ====================================================================== + // Additional Edge Cases + // ====================================================================== + + describe('Edge Cases', () => { + it('should create minimal workflow with single node', async () => { + const workflowName = createTestWorkflowName('Minimal Single Node'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'manual-1', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {} + } + ], + connections: {}, + settings: { executionOrder: 'v1' as const } + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].type).toBe('n8n-nodes-base.manualTrigger'); + }); + + it('should create workflow with empty connections object', async () => { + const workflowName = createTestWorkflowName('Empty Connections'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'set-1', + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [250, 300] as [number, number], + parameters: { + assignments: { assignments: [] }, + options: {} + } + } + ], + connections: {}, // Explicitly empty + settings: { executionOrder: 'v1' as const } + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + expect(result.connections).toEqual({}); + }); + + it('should create workflow without settings object', async () => { + const workflowName = createTestWorkflowName('No Settings'); + const workflow = { + name: workflowName, + nodes: [ + { + id: 'manual-1', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {} + } + ], + connections: {} + // No settings property + }; + + const result = await client.createWorkflow(workflow); + + expect(result).toBeDefined(); + expect(result.id).toBeTruthy(); + if (!result.id) throw new Error('Workflow ID is missing'); + context.trackWorkflow(result.id); + // n8n should apply default settings + expect(result.settings).toBeDefined(); + }); + }); +}); diff --git a/tests/setup/test-env.ts b/tests/setup/test-env.ts index 629ccbe..a5a453a 100644 --- a/tests/setup/test-env.ts +++ b/tests/setup/test-env.ts @@ -13,17 +13,27 @@ import { existsSync } from 'fs'; export function loadTestEnvironment(): void { // CI Debug logging const isCI = process.env.CI === 'true'; - + + // First, load the main .env file (for integration tests that need real credentials) + const mainEnvPath = path.resolve(process.cwd(), '.env'); + if (existsSync(mainEnvPath)) { + dotenv.config({ path: mainEnvPath }); + if (isCI) { + console.log('[CI-DEBUG] Loaded .env file from:', mainEnvPath); + } + } + // Load base test environment const testEnvPath = path.resolve(process.cwd(), '.env.test'); - + if (isCI) { console.log('[CI-DEBUG] Looking for .env.test at:', testEnvPath); console.log('[CI-DEBUG] File exists?', existsSync(testEnvPath)); } - + if (existsSync(testEnvPath)) { - const result = dotenv.config({ path: testEnvPath }); + // Don't override values from .env + const result = dotenv.config({ path: testEnvPath, override: false }); if (isCI && result.error) { console.error('[CI-DEBUG] Failed to load .env.test:', result.error); } else if (isCI && result.parsed) { @@ -39,9 +49,9 @@ export function loadTestEnvironment(): void { dotenv.config({ path: localEnvPath, override: true }); } - // Set test-specific defaults + // Set test-specific defaults (only if not already set) setTestDefaults(); - + // Validate required environment variables validateTestEnvironment(); } diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 62401e9..463a54a 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -5,8 +5,9 @@ export default mergeConfig( baseConfig, defineConfig({ test: { - // Include both global setup and integration-specific MSW setup - setupFiles: ['./tests/setup/global-setup.ts', './tests/integration/setup/integration-setup.ts'], + // Include global setup, but NOT integration-setup.ts for n8n-api tests + // (they need real network requests, not MSW mocks) + setupFiles: ['./tests/setup/global-setup.ts'], // Only include integration tests include: ['tests/integration/**/*.test.ts'], // Integration tests might need more time