diff --git a/tests/unit/mcp/handlers-workflow-diff.test.ts b/tests/unit/mcp/handlers-workflow-diff.test.ts index 31c1874..4616e8a 100644 --- a/tests/unit/mcp/handlers-workflow-diff.test.ts +++ b/tests/unit/mcp/handlers-workflow-diff.test.ts @@ -635,5 +635,211 @@ describe('handlers-workflow-diff', () => { }, }); }); + + describe('Workflow Activation/Deactivation', () => { + it('should activate workflow after successful update', async () => { + const testWorkflow = createTestWorkflow({ active: false }); + const updatedWorkflow = { ...testWorkflow, active: false }; + const activatedWorkflow = { ...testWorkflow, active: true }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldActivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.activateWorkflow = vi.fn().mockResolvedValue(activatedWorkflow); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'activateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(true); + expect(result.data).toEqual(activatedWorkflow); + expect(result.message).toContain('Workflow activated'); + expect(result.details?.active).toBe(true); + expect(mockApiClient.activateWorkflow).toHaveBeenCalledWith('test-workflow-id'); + }); + + it('should deactivate workflow after successful update', async () => { + const testWorkflow = createTestWorkflow({ active: true }); + const updatedWorkflow = { ...testWorkflow, active: true }; + const deactivatedWorkflow = { ...testWorkflow, active: false }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldDeactivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.deactivateWorkflow = vi.fn().mockResolvedValue(deactivatedWorkflow); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'deactivateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(true); + expect(result.data).toEqual(deactivatedWorkflow); + expect(result.message).toContain('Workflow deactivated'); + expect(result.details?.active).toBe(false); + expect(mockApiClient.deactivateWorkflow).toHaveBeenCalledWith('test-workflow-id'); + }); + + it('should handle activation failure after successful update', async () => { + const testWorkflow = createTestWorkflow({ active: false }); + const updatedWorkflow = { ...testWorkflow, active: false }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldActivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.activateWorkflow = vi.fn().mockRejectedValue(new Error('Activation failed: No trigger nodes')); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'activateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow updated successfully but activation failed'); + expect(result.details).toEqual({ + workflowUpdated: true, + activationError: 'Activation failed: No trigger nodes', + }); + }); + + it('should handle deactivation failure after successful update', async () => { + const testWorkflow = createTestWorkflow({ active: true }); + const updatedWorkflow = { ...testWorkflow, active: true }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldDeactivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.deactivateWorkflow = vi.fn().mockRejectedValue(new Error('Deactivation failed')); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'deactivateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow updated successfully but deactivation failed'); + expect(result.details).toEqual({ + workflowUpdated: true, + deactivationError: 'Deactivation failed', + }); + }); + + it('should update workflow without activation when shouldActivate is false', async () => { + const testWorkflow = createTestWorkflow({ active: false }); + const updatedWorkflow = { ...testWorkflow, active: false }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldActivate: false, + shouldDeactivate: false, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.activateWorkflow = vi.fn(); + mockApiClient.deactivateWorkflow = vi.fn(); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'updateName', name: 'Updated' }], + }, mockRepository); + + expect(result.success).toBe(true); + expect(result.message).not.toContain('activated'); + expect(result.message).not.toContain('deactivated'); + expect(mockApiClient.activateWorkflow).not.toHaveBeenCalled(); + expect(mockApiClient.deactivateWorkflow).not.toHaveBeenCalled(); + }); + + it('should handle non-Error activation failures', async () => { + const testWorkflow = createTestWorkflow({ active: false }); + const updatedWorkflow = { ...testWorkflow, active: false }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldActivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.activateWorkflow = vi.fn().mockRejectedValue('String error'); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'activateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow updated successfully but activation failed'); + expect(result.details).toEqual({ + workflowUpdated: true, + activationError: 'Unknown error', + }); + }); + + it('should handle non-Error deactivation failures', async () => { + const testWorkflow = createTestWorkflow({ active: true }); + const updatedWorkflow = { ...testWorkflow, active: true }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + shouldDeactivate: true, + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + mockApiClient.deactivateWorkflow = vi.fn().mockRejectedValue({ code: 'UNKNOWN' }); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-workflow-id', + operations: [{ type: 'deactivateWorkflow' }], + }, mockRepository); + + expect(result.success).toBe(false); + expect(result.error).toBe('Workflow updated successfully but deactivation failed'); + expect(result.details).toEqual({ + workflowUpdated: true, + deactivationError: 'Unknown error', + }); + }); + }); }); }); \ No newline at end of file diff --git a/tests/unit/services/n8n-api-client.test.ts b/tests/unit/services/n8n-api-client.test.ts index 4dad651..6ff1bf3 100644 --- a/tests/unit/services/n8n-api-client.test.ts +++ b/tests/unit/services/n8n-api-client.test.ts @@ -362,19 +362,19 @@ describe('N8nApiClient', () => { it('should delete workflow successfully', async () => { mockAxiosInstance.delete.mockResolvedValue({ data: {} }); - + await client.deleteWorkflow('123'); - + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/workflows/123'); }); it('should handle deletion error', async () => { - const error = { + const error = { message: 'Request failed', - response: { status: 404, data: { message: 'Not found' } } + response: { status: 404, data: { message: 'Not found' } } }; await mockAxiosInstance.simulateError('delete', error); - + try { await client.deleteWorkflow('123'); expect.fail('Should have thrown an error'); @@ -386,6 +386,178 @@ describe('N8nApiClient', () => { }); }); + describe('activateWorkflow', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should activate workflow successfully', async () => { + const workflow = { id: '123', name: 'Test', active: false, nodes: [], connections: {} }; + const activatedWorkflow = { ...workflow, active: true }; + mockAxiosInstance.post.mockResolvedValue({ data: activatedWorkflow }); + + const result = await client.activateWorkflow('123'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/workflows/123/activate'); + expect(result).toEqual(activatedWorkflow); + expect(result.active).toBe(true); + }); + + it('should handle activation error - no trigger nodes', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Workflow must have at least one trigger node' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.activateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).message).toContain('trigger node'); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + + it('should handle activation error - workflow not found', async () => { + const error = { + message: 'Request failed', + response: { status: 404, data: { message: 'Workflow not found' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.activateWorkflow('non-existent'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nNotFoundError); + expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).statusCode).toBe(404); + } + }); + + it('should handle activation error - workflow already active', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Workflow is already active' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.activateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).message).toContain('already active'); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + + it('should handle server error during activation', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.activateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).message).toBe('Internal server error'); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + }); + + describe('deactivateWorkflow', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should deactivate workflow successfully', async () => { + const workflow = { id: '123', name: 'Test', active: true, nodes: [], connections: {} }; + const deactivatedWorkflow = { ...workflow, active: false }; + mockAxiosInstance.post.mockResolvedValue({ data: deactivatedWorkflow }); + + const result = await client.deactivateWorkflow('123'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/workflows/123/deactivate'); + expect(result).toEqual(deactivatedWorkflow); + expect(result.active).toBe(false); + }); + + it('should handle deactivation error - workflow not found', async () => { + const error = { + message: 'Request failed', + response: { status: 404, data: { message: 'Workflow not found' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.deactivateWorkflow('non-existent'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nNotFoundError); + expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).statusCode).toBe(404); + } + }); + + it('should handle deactivation error - workflow already inactive', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Workflow is already inactive' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.deactivateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).message).toContain('already inactive'); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + + it('should handle server error during deactivation', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.deactivateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).message).toBe('Internal server error'); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + + it('should handle authentication error during deactivation', async () => { + const error = { + message: 'Request failed', + response: { status: 401, data: { message: 'Invalid API key' } } + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.deactivateWorkflow('123'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nAuthenticationError); + expect((err as N8nAuthenticationError).message).toBe('Invalid API key'); + expect((err as N8nAuthenticationError).statusCode).toBe(401); + } + }); + }); + describe('listWorkflows', () => { beforeEach(() => { client = new N8nApiClient(defaultConfig); diff --git a/tests/unit/services/workflow-diff-engine.test.ts b/tests/unit/services/workflow-diff-engine.test.ts index 42dddb1..940bf69 100644 --- a/tests/unit/services/workflow-diff-engine.test.ts +++ b/tests/unit/services/workflow-diff-engine.test.ts @@ -4269,4 +4269,354 @@ describe('WorkflowDiffEngine', () => { expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined(); }); }); + + describe('Workflow Activation/Deactivation Operations', () => { + it('should activate workflow with activatable trigger nodes', async () => { + // Create workflow with webhook trigger (activatable) + const workflowWithTrigger = createWorkflow('Test Workflow') + .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('webhook-1', 'http-1') + .build() as Workflow; + + // Fix connections to use node names + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) { + const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithTrigger.connections = newConnections; + + const operation: any = { + type: 'activateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithTrigger, request); + + expect(result.success).toBe(true); + expect(result.shouldActivate).toBe(true); + expect((result.workflow as any)._shouldActivate).toBeUndefined(); // Flag should be cleaned up + }); + + it('should reject activation if no activatable trigger nodes', async () => { + // Create workflow with no trigger nodes at all + const workflowWithoutActivatableTrigger = createWorkflow('Test Workflow') + .addNode({ + id: 'set-1', + name: 'Set Node', + type: 'n8n-nodes-base.set', + position: [100, 100], + parameters: {} + }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('set-1', 'http-1') + .build() as Workflow; + + // Fix connections to use node names + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithoutActivatableTrigger.connections)) { + const node = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithoutActivatableTrigger.connections = newConnections; + + const operation: any = { + type: 'activateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithoutActivatableTrigger, request); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toContain('No activatable trigger nodes found'); + expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows'); + }); + + it('should reject activation if all trigger nodes are disabled', async () => { + // Create workflow with disabled webhook trigger + const workflowWithDisabledTrigger = createWorkflow('Test Workflow') + .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger', disabled: true }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('webhook-1', 'http-1') + .build() as Workflow; + + // Fix connections to use node names + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithDisabledTrigger.connections)) { + const node = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithDisabledTrigger.connections = newConnections; + + const operation: any = { + type: 'activateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithDisabledTrigger, request); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toContain('No activatable trigger nodes found'); + }); + + it('should activate workflow with schedule trigger', async () => { + // Create workflow with schedule trigger (activatable) + const workflowWithSchedule = createWorkflow('Test Workflow') + .addNode({ + id: 'schedule-1', + name: 'Schedule', + type: 'n8n-nodes-base.scheduleTrigger', + position: [100, 100], + parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 1 }] } } + }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('schedule-1', 'http-1') + .build() as Workflow; + + // Fix connections + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithSchedule.connections)) { + const node = workflowWithSchedule.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithSchedule.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithSchedule.connections = newConnections; + + const operation: any = { + type: 'activateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithSchedule, request); + + expect(result.success).toBe(true); + expect(result.shouldActivate).toBe(true); + }); + + it('should deactivate workflow successfully', async () => { + // Any workflow can be deactivated + const operation: any = { + type: 'deactivateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(baseWorkflow, request); + + expect(result.success).toBe(true); + expect(result.shouldDeactivate).toBe(true); + expect((result.workflow as any)._shouldDeactivate).toBeUndefined(); // Flag should be cleaned up + }); + + it('should deactivate workflow without trigger nodes', async () => { + // Create workflow without any trigger nodes + const workflowWithoutTrigger = createWorkflow('Test Workflow') + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .addNode({ + id: 'set-1', + name: 'Set', + type: 'n8n-nodes-base.set', + position: [300, 100], + parameters: {} + }) + .connect('http-1', 'set-1') + .build() as Workflow; + + // Fix connections + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithoutTrigger.connections)) { + const node = workflowWithoutTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithoutTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithoutTrigger.connections = newConnections; + + const operation: any = { + type: 'deactivateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithoutTrigger, request); + + expect(result.success).toBe(true); + expect(result.shouldDeactivate).toBe(true); + }); + + it('should combine activation with other operations', async () => { + // Create workflow with webhook trigger + const workflowWithTrigger = createWorkflow('Test Workflow') + .addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('webhook-1', 'http-1') + .build() as Workflow; + + // Fix connections + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) { + const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithTrigger.connections = newConnections; + + const operations: any[] = [ + { + type: 'updateName', + name: 'Updated Workflow Name' + }, + { + type: 'addTag', + tag: 'production' + }, + { + type: 'activateWorkflow' + } + ]; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations + }; + + const result = await diffEngine.applyDiff(workflowWithTrigger, request); + + expect(result.success).toBe(true); + expect(result.operationsApplied).toBe(3); + expect(result.workflow!.name).toBe('Updated Workflow Name'); + expect(result.workflow!.tags).toContain('production'); + expect(result.shouldActivate).toBe(true); + }); + + it('should reject activation if workflow has executeWorkflowTrigger only', async () => { + // Create workflow with executeWorkflowTrigger (not activatable - Issue #351) + const workflowWithExecuteTrigger = createWorkflow('Test Workflow') + .addNode({ + id: 'execute-1', + name: 'Execute Workflow Trigger', + type: 'n8n-nodes-base.executeWorkflowTrigger', + position: [100, 100], + parameters: {} + }) + .addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' }) + .connect('execute-1', 'http-1') + .build() as Workflow; + + // Fix connections + const newConnections: any = {}; + for (const [nodeId, outputs] of Object.entries(workflowWithExecuteTrigger.connections)) { + const node = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === nodeId); + if (node) { + newConnections[node.name] = {}; + for (const [outputName, connections] of Object.entries(outputs)) { + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === conn.node); + return { ...conn, node: targetNode ? targetNode.name : conn.node }; + }) + ); + } + } + } + workflowWithExecuteTrigger.connections = newConnections; + + const operation: any = { + type: 'activateWorkflow' + }; + + const request: WorkflowDiffRequest = { + id: 'test-workflow', + operations: [operation] + }; + + const result = await diffEngine.applyDiff(workflowWithExecuteTrigger, request); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toContain('No activatable trigger nodes found'); + expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows'); + }); + }); }); \ No newline at end of file