feat(core): Coordinate workflow activation in multiple main scenario in internal API (#7566)

Story: https://linear.app/n8n/issue/PAY-926

This PR coordinates workflow activation on instance startup and on
leadership change in multiple main scenario in the internal API. Part 3
on manual workflow activation and deactivation will be a separate PR.

### Part 1: Instance startup

In multi-main scenario, on starting an instance...
- [x] If the instance is the leader, it should add webhooks, triggers
and pollers.
- [x] If the instance is the follower, it should not add webhooks,
triggers or pollers.
- [x] Unit tests.

### Part 2: Leadership change 

In multi-main scenario, if the main instance leader dies…

- [x] The new main instance leader must activate all trigger- and
poller-based workflows, excluding webhook-based workflows.
- [x] The old main instance leader must deactivate all trigger- and
poller-based workflows, excluding webhook-based workflows.
- [x] Unit tests.

To test, start two instances and check behavior on startup and
leadership change:

```
EXECUTIONS_MODE=queue N8N_LEADER_SELECTION_ENABLED=true N8N_LICENSE_TENANT_ID=... N8N_LICENSE_ACTIVATION_KEY=... N8N_LOG_LEVEL=debug npm run start

EXECUTIONS_MODE=queue N8N_LEADER_SELECTION_ENABLED=true N8N_LICENSE_TENANT_ID=... N8N_LICENSE_ACTIVATION_KEY=... N8N_LOG_LEVEL=debug N8N_PORT=5679 npm run start
```
This commit is contained in:
Iván Ovejero
2023-11-07 13:48:48 +01:00
committed by GitHub
parent 151e60f829
commit c857e42677
15 changed files with 839 additions and 618 deletions

View File

@@ -0,0 +1,356 @@
import { Container } from 'typedi';
import { NodeApiError, NodeOperationError, Workflow } from 'n8n-workflow';
import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow';
import { ActiveWorkflows } from 'n8n-core';
import { ActiveExecutions } from '@/ActiveExecutions';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import config from '@/config';
import { ExternalHooks } from '@/ExternalHooks';
import { Push } from '@/push';
import { SecretsHelper } from '@/SecretsHelpers';
import { WebhookService } from '@/services/webhook.service';
import * as WebhookHelpers from '@/WebhookHelpers';
import * as AdditionalData from '@/WorkflowExecuteAdditionalData';
import { WorkflowRunner } from '@/WorkflowRunner';
import { mockInstance, setSchedulerAsLoadedNode } from './shared/utils';
import * as testDb from './shared/testDb';
import type { User } from '@/databases/entities/User';
import type { WebhookEntity } from '@/databases/entities/WebhookEntity';
import { NodeTypes } from '@/NodeTypes';
import { chooseRandomly } from './shared/random';
import { MultiMainInstancePublisher } from '@/services/orchestration/main/MultiMainInstance.publisher.ee';
mockInstance(ActiveExecutions);
mockInstance(ActiveWorkflows);
mockInstance(Push);
mockInstance(SecretsHelper);
mockInstance(MultiMainInstancePublisher);
const webhookService = mockInstance(WebhookService);
setSchedulerAsLoadedNode();
const externalHooks = mockInstance(ExternalHooks);
let activeWorkflowRunner: ActiveWorkflowRunner;
let owner: User;
const NON_LEADERSHIP_CHANGE_MODES: WorkflowActivateMode[] = [
'init',
'create',
'update',
'activate',
'manual',
];
beforeAll(async () => {
await testDb.init();
activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
owner = await testDb.createOwner();
});
afterEach(async () => {
await activeWorkflowRunner.removeAll();
activeWorkflowRunner.removeAllQueuedWorkflowActivations();
await testDb.truncate(['Workflow']);
config.load(config.default);
jest.restoreAllMocks();
});
afterAll(async () => {
await testDb.terminate();
});
describe('init()', () => {
test('should call `ExternalHooks.run()`', async () => {
const runSpy = jest.spyOn(externalHooks, 'run');
await activeWorkflowRunner.init();
expect(runSpy).toHaveBeenCalledTimes(1);
const [hook, arg] = runSpy.mock.calls[0];
expect(hook).toBe('activeWorkflows.initialized');
expect(arg).toBeEmptyArray();
});
test('should start with no active workflows', async () => {
await activeWorkflowRunner.init();
const inStorage = activeWorkflowRunner.allActiveInStorage();
await expect(inStorage).resolves.toHaveLength(0);
const inMemory = activeWorkflowRunner.allActiveInMemory();
expect(inMemory).toHaveLength(0);
});
test('should start with one active workflow', async () => {
await testDb.createWorkflow({ active: true }, owner);
await activeWorkflowRunner.init();
const inStorage = activeWorkflowRunner.allActiveInStorage();
await expect(inStorage).resolves.toHaveLength(1);
const inMemory = activeWorkflowRunner.allActiveInMemory();
expect(inMemory).toHaveLength(1);
});
test('should start with multiple active workflows', async () => {
await testDb.createWorkflow({ active: true }, owner);
await testDb.createWorkflow({ active: true }, owner);
await activeWorkflowRunner.init();
const inStorage = activeWorkflowRunner.allActiveInStorage();
await expect(inStorage).resolves.toHaveLength(2);
const inMemory = activeWorkflowRunner.allActiveInMemory();
expect(inMemory).toHaveLength(2);
});
test('should pre-check that every workflow can be activated', async () => {
await testDb.createWorkflow({ active: true }, owner);
await testDb.createWorkflow({ active: true }, owner);
const precheckSpy = jest
.spyOn(Workflow.prototype, 'checkIfWorkflowCanBeActivated')
.mockReturnValue(true);
await activeWorkflowRunner.init();
expect(precheckSpy).toHaveBeenCalledTimes(2);
});
});
describe('removeAll()', () => {
test('should remove all active workflows from memory', async () => {
await testDb.createWorkflow({ active: true }, owner);
await testDb.createWorkflow({ active: true }, owner);
await activeWorkflowRunner.init();
await activeWorkflowRunner.removeAll();
const inMemory = activeWorkflowRunner.allActiveInMemory();
expect(inMemory).toHaveLength(0);
});
});
describe('remove()', () => {
test('should call `ActiveWorkflowRunner.clearWebhooks()`', async () => {
const workflow = await testDb.createWorkflow({ active: true }, owner);
const clearWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'clearWebhooks');
await activeWorkflowRunner.init();
await activeWorkflowRunner.remove(workflow.id);
expect(clearWebhooksSpy).toHaveBeenCalledTimes(1);
});
});
describe('isActive()', () => {
test('should return `true` for active workflow in storage', async () => {
const workflow = await testDb.createWorkflow({ active: true }, owner);
await activeWorkflowRunner.init();
const isActiveInStorage = activeWorkflowRunner.isActive(workflow.id);
await expect(isActiveInStorage).resolves.toBe(true);
});
test('should return `false` for inactive workflow in storage', async () => {
const workflow = await testDb.createWorkflow({ active: false }, owner);
await activeWorkflowRunner.init();
const isActiveInStorage = activeWorkflowRunner.isActive(workflow.id);
await expect(isActiveInStorage).resolves.toBe(false);
});
});
describe('runWorkflow()', () => {
test('should call `WorkflowRunner.run()`', async () => {
const workflow = await testDb.createWorkflow({ active: true }, owner);
await activeWorkflowRunner.init();
const additionalData = await AdditionalData.getBase('fake-user-id');
const runSpy = jest
.spyOn(WorkflowRunner.prototype, 'run')
.mockResolvedValue('fake-execution-id');
const [node] = workflow.nodes;
await activeWorkflowRunner.runWorkflow(workflow, node, [[]], additionalData, 'trigger');
expect(runSpy).toHaveBeenCalledTimes(1);
});
});
describe('executeErrorWorkflow()', () => {
test('should call `WorkflowExecuteAdditionalData.executeErrorWorkflow()`', async () => {
const workflow = await testDb.createWorkflow({ active: true }, owner);
const [node] = workflow.nodes;
const error = new NodeOperationError(node, 'Fake error message');
const executeSpy = jest.spyOn(AdditionalData, 'executeErrorWorkflow');
await activeWorkflowRunner.init();
activeWorkflowRunner.executeErrorWorkflow(error, workflow, 'trigger');
expect(executeSpy).toHaveBeenCalledTimes(1);
});
test('should be called on failure to activate due to 401', async () => {
const storedWorkflow = await testDb.createWorkflow({ active: true }, owner);
const [node] = storedWorkflow.nodes;
const executeSpy = jest.spyOn(activeWorkflowRunner, 'executeErrorWorkflow');
jest.spyOn(activeWorkflowRunner, 'add').mockImplementation(() => {
throw new NodeApiError(node, {
httpCode: '401',
message: 'Authorization failed - please check your credentials',
});
});
await activeWorkflowRunner.init();
expect(executeSpy).toHaveBeenCalledTimes(1);
const [error, workflow] = executeSpy.mock.calls[0];
expect(error.message).toContain('Authorization');
expect(workflow.id).toBe(storedWorkflow.id);
});
});
describe('add()', () => {
describe('in single-main scenario', () => {
test('leader should add webhooks, triggers and pollers', async () => {
const mode = chooseRandomly(NON_LEADERSHIP_CHANGE_MODES);
const workflow = await testDb.createWorkflow({ active: true }, owner);
const addWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'addWebhooks');
const addTriggersAndPollersSpy = jest.spyOn(activeWorkflowRunner, 'addTriggersAndPollers');
await activeWorkflowRunner.init();
addWebhooksSpy.mockReset();
addTriggersAndPollersSpy.mockReset();
await activeWorkflowRunner.add(workflow.id, mode);
expect(addWebhooksSpy).toHaveBeenCalledTimes(1);
expect(addTriggersAndPollersSpy).toHaveBeenCalledTimes(1);
});
});
describe('in multi-main scenario', () => {
describe('leader', () => {
test('on regular activation mode, leader should add webhooks only', async () => {
const mode = chooseRandomly(NON_LEADERSHIP_CHANGE_MODES);
jest.replaceProperty(activeWorkflowRunner, 'isMultiMainScenario', true);
mockInstance(MultiMainInstancePublisher, { isLeader: true });
const workflow = await testDb.createWorkflow({ active: true }, owner);
const addWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'addWebhooks');
const addTriggersAndPollersSpy = jest.spyOn(activeWorkflowRunner, 'addTriggersAndPollers');
await activeWorkflowRunner.init();
addWebhooksSpy.mockReset();
addTriggersAndPollersSpy.mockReset();
await activeWorkflowRunner.add(workflow.id, mode);
expect(addWebhooksSpy).toHaveBeenCalledTimes(1);
expect(addTriggersAndPollersSpy).toHaveBeenCalledTimes(1);
});
test('on activation via leadership change, leader should add triggers and pollers only', async () => {
const mode = 'leadershipChange';
jest.replaceProperty(activeWorkflowRunner, 'isMultiMainScenario', true);
mockInstance(MultiMainInstancePublisher, { isLeader: true });
const workflow = await testDb.createWorkflow({ active: true }, owner);
const addWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'addWebhooks');
const addTriggersAndPollersSpy = jest.spyOn(activeWorkflowRunner, 'addTriggersAndPollers');
await activeWorkflowRunner.init();
addWebhooksSpy.mockReset();
addTriggersAndPollersSpy.mockReset();
await activeWorkflowRunner.add(workflow.id, mode);
expect(addWebhooksSpy).not.toHaveBeenCalled();
expect(addTriggersAndPollersSpy).toHaveBeenCalledTimes(1);
});
});
describe('follower', () => {
test('on regular activation mode, follower should not add webhooks, triggers or pollers', async () => {
const mode = chooseRandomly(NON_LEADERSHIP_CHANGE_MODES);
jest.replaceProperty(activeWorkflowRunner, 'isMultiMainScenario', true);
mockInstance(MultiMainInstancePublisher, { isLeader: false });
const workflow = await testDb.createWorkflow({ active: true }, owner);
const addWebhooksSpy = jest.spyOn(activeWorkflowRunner, 'addWebhooks');
const addTriggersAndPollersSpy = jest.spyOn(activeWorkflowRunner, 'addTriggersAndPollers');
await activeWorkflowRunner.init();
addWebhooksSpy.mockReset();
addTriggersAndPollersSpy.mockReset();
await activeWorkflowRunner.add(workflow.id, mode);
expect(addWebhooksSpy).not.toHaveBeenCalled();
expect(addTriggersAndPollersSpy).not.toHaveBeenCalled();
});
});
});
});
describe('addWebhooks()', () => {
test('should call `WebhookService.storeWebhook()`', async () => {
const mockWebhook = { path: 'fake-path' } as unknown as IWebhookData;
const mockWebhookEntity = { webhookPath: 'fake-path' } as unknown as WebhookEntity;
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([mockWebhook]);
webhookService.createWebhook.mockReturnValue(mockWebhookEntity);
const additionalData = await AdditionalData.getBase('fake-user-id');
const dbWorkflow = await testDb.createWorkflow({ active: true }, owner);
const workflow = new Workflow({
id: dbWorkflow.id,
name: dbWorkflow.name,
nodes: dbWorkflow.nodes,
connections: dbWorkflow.connections,
active: dbWorkflow.active,
nodeTypes: Container.get(NodeTypes),
staticData: dbWorkflow.staticData,
settings: dbWorkflow.settings,
});
const [node] = dbWorkflow.nodes;
jest.spyOn(Workflow.prototype, 'getNode').mockReturnValue(node);
jest.spyOn(Workflow.prototype, 'checkIfWorkflowCanBeActivated').mockReturnValue(true);
jest.spyOn(Workflow.prototype, 'createWebhookIfNotExists').mockResolvedValue(undefined);
await activeWorkflowRunner.addWebhooks(workflow, additionalData, 'trigger', 'init');
expect(webhookService.storeWebhook).toHaveBeenCalledTimes(1);
});
});

View File

@@ -459,10 +459,10 @@ export async function createWorkflow(attributes: Partial<WorkflowEntity> = {}, u
nodes: nodes ?? [
{
id: 'uuid-1234',
name: 'Start',
name: 'Schedule Trigger',
parameters: {},
position: [-20, 260],
type: 'n8n-nodes-base.start',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
},
],

View File

@@ -1,6 +1,6 @@
import { Container } from 'typedi';
import { BinaryDataService } from 'n8n-core';
import type { INode } from 'n8n-workflow';
import { type INode } from 'n8n-workflow';
import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials';
import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials';
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
@@ -16,6 +16,8 @@ import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AUTH_COOKIE_NAME } from '@/constants';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { mockInstance } from './mocking';
import { mockNodeTypesData } from '../../../unit/Helpers';
export { mockInstance } from './mocking';
export { setupTestServer } from './testServer';
@@ -166,3 +168,15 @@ export function makeWorkflow(options?: {
}
export const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };
export function setSchedulerAsLoadedNode() {
const nodesAndCredentials = mockInstance(LoadNodesAndCredentials);
Object.assign(nodesAndCredentials, {
loadedNodes: mockNodeTypesData(['scheduleTrigger'], {
addTrigger: true,
}),
known: { nodes: {}, credentials: {} },
types: { nodes: [], credentials: [] },
});
}

View File

@@ -1,278 +0,0 @@
import { v4 as uuid } from 'uuid';
import { mocked } from 'jest-mock';
import { Container } from 'typedi';
import type { INode } from 'n8n-workflow';
import { NodeApiError, NodeOperationError, Workflow } from 'n8n-workflow';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import * as Db from '@/Db';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Role } from '@db/entities/Role';
import { User } from '@db/entities/User';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { WorkflowRunner } from '@/WorkflowRunner';
import { ExternalHooks } from '@/ExternalHooks';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { Push } from '@/push';
import { ActiveExecutions } from '@/ActiveExecutions';
import { SecretsHelper } from '@/SecretsHelpers';
import { WebhookService } from '@/services/webhook.service';
import { VariablesService } from '@/environments/variables/variables.service';
import { mockInstance } from '../integration/shared/utils/';
import { randomEmail, randomName } from '../integration/shared/random';
import * as Helpers from './Helpers';
/**
* TODO:
* - test workflow webhooks activation (that trigger `executeWebhook`and other webhook methods)
* - test activation error catching and getters such as `getActivationError` (requires building a workflow that fails to activate)
* - test queued workflow activation functions (might need to create a non-working workflow to test this)
*/
let databaseActiveWorkflowsCount = 0;
let databaseActiveWorkflowsList: WorkflowEntity[] = [];
const generateWorkflows = (count: number): WorkflowEntity[] => {
const workflows: WorkflowEntity[] = [];
const ownerRole = new Role();
ownerRole.scope = 'workflow';
ownerRole.name = 'owner';
ownerRole.id = '1';
const owner = new User();
owner.id = uuid();
owner.firstName = randomName();
owner.lastName = randomName();
owner.email = randomEmail();
for (let i = 0; i < count; i++) {
const workflow = new WorkflowEntity();
Object.assign(workflow, {
id: (i + 1).toString(),
name: randomName(),
active: true,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [
{
parameters: {
rule: {
interval: [{}],
},
},
id: uuid(),
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [900, 460],
},
],
connections: {},
tags: [],
});
const sharedWorkflow = new SharedWorkflow();
sharedWorkflow.workflowId = workflow.id;
sharedWorkflow.role = ownerRole;
sharedWorkflow.user = owner;
workflow.shared = [sharedWorkflow];
workflows.push(workflow);
}
databaseActiveWorkflowsList = workflows;
return workflows;
};
const MOCK_NODE_TYPES_DATA = Helpers.mockNodeTypesData(['scheduleTrigger'], {
addTrigger: true,
});
jest.mock('@/Db', () => {
return {
collections: {
Workflow: {
find: jest.fn(async () => generateWorkflows(databaseActiveWorkflowsCount)),
findOne: jest.fn(async (searchParams) => {
return databaseActiveWorkflowsList.find(
(workflow) => workflow.id === searchParams.where.id.toString(),
);
}),
update: jest.fn(),
createQueryBuilder: jest.fn(() => {
const fakeQueryBuilder = {
update: () => fakeQueryBuilder,
set: () => fakeQueryBuilder,
where: () => fakeQueryBuilder,
execute: async () => {},
};
return fakeQueryBuilder;
}),
},
},
};
});
const workflowCheckIfCanBeActivated = jest.fn(() => true);
jest
.spyOn(Workflow.prototype, 'checkIfWorkflowCanBeActivated')
.mockImplementation(workflowCheckIfCanBeActivated);
const removeFunction = jest.spyOn(ActiveWorkflowRunner.prototype, 'remove');
const removeWebhooksFunction = jest.spyOn(ActiveWorkflowRunner.prototype, 'removeWorkflowWebhooks');
const workflowRunnerRun = jest.spyOn(WorkflowRunner.prototype, 'run');
const workflowExecuteAdditionalDataExecuteErrorWorkflowSpy = jest.spyOn(
WorkflowExecuteAdditionalData,
'executeErrorWorkflow',
);
describe('ActiveWorkflowRunner', () => {
mockInstance(ActiveExecutions);
const externalHooks = mockInstance(ExternalHooks);
const webhookService = mockInstance(WebhookService);
mockInstance(Push);
mockInstance(SecretsHelper);
const variablesService = mockInstance(VariablesService);
const nodesAndCredentials = mockInstance(LoadNodesAndCredentials);
Object.assign(nodesAndCredentials, {
loadedNodes: MOCK_NODE_TYPES_DATA,
known: { nodes: {}, credentials: {} },
types: { nodes: [], credentials: [] },
});
const activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
beforeAll(async () => {
variablesService.getAllCached.mockResolvedValue([]);
});
afterEach(async () => {
await activeWorkflowRunner.removeAll();
databaseActiveWorkflowsCount = 0;
databaseActiveWorkflowsList = [];
jest.clearAllMocks();
});
test('Should initialize activeWorkflowRunner with empty list of active workflows and call External Hooks', async () => {
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.getActiveWorkflows()).toHaveLength(0);
expect(mocked(Db.collections.Workflow.find)).toHaveBeenCalled();
expect(externalHooks.run).toHaveBeenCalledTimes(1);
});
test('Should initialize activeWorkflowRunner with one active workflow', async () => {
databaseActiveWorkflowsCount = 1;
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.getActiveWorkflows()).toHaveLength(
databaseActiveWorkflowsCount,
);
expect(mocked(Db.collections.Workflow.find)).toHaveBeenCalled();
expect(externalHooks.run).toHaveBeenCalled();
});
test('Should make sure function checkIfWorkflowCanBeActivated was called for every workflow', async () => {
databaseActiveWorkflowsCount = 2;
await activeWorkflowRunner.init();
expect(workflowCheckIfCanBeActivated).toHaveBeenCalledTimes(databaseActiveWorkflowsCount);
});
test('Call to removeAll should remove every workflow', async () => {
databaseActiveWorkflowsCount = 2;
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.getActiveWorkflows()).toHaveLength(
databaseActiveWorkflowsCount,
);
await activeWorkflowRunner.removeAll();
expect(removeFunction).toHaveBeenCalledTimes(databaseActiveWorkflowsCount);
});
test('Call to remove should also call removeWorkflowWebhooks', async () => {
databaseActiveWorkflowsCount = 1;
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.getActiveWorkflows()).toHaveLength(
databaseActiveWorkflowsCount,
);
await activeWorkflowRunner.remove('1');
expect(removeWebhooksFunction).toHaveBeenCalledTimes(1);
});
test('Call to isActive should return true for valid workflow', async () => {
databaseActiveWorkflowsCount = 1;
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.isActive('1')).toBe(true);
});
test('Call to isActive should return false for invalid workflow', async () => {
databaseActiveWorkflowsCount = 1;
await activeWorkflowRunner.init();
expect(await activeWorkflowRunner.isActive('2')).toBe(false);
});
test('Calling add should call checkIfWorkflowCanBeActivated', async () => {
// Initialize with default (0) workflows
await activeWorkflowRunner.init();
generateWorkflows(1);
await activeWorkflowRunner.add('1', 'activate');
expect(workflowCheckIfCanBeActivated).toHaveBeenCalledTimes(1);
});
test('runWorkflow should call run method in WorkflowRunner', async () => {
await activeWorkflowRunner.init();
const workflow = generateWorkflows(1);
const additionalData = await WorkflowExecuteAdditionalData.getBase('fake-user-id');
workflowRunnerRun.mockResolvedValueOnce('invalid-execution-id');
await activeWorkflowRunner.runWorkflow(
workflow[0],
workflow[0].nodes[0],
[[]],
additionalData,
'trigger',
);
expect(workflowRunnerRun).toHaveBeenCalledTimes(1);
});
test('executeErrorWorkflow should call function with same name in WorkflowExecuteAdditionalData', async () => {
const workflowData = generateWorkflows(1)[0];
const error = new NodeOperationError(workflowData.nodes[0], 'Fake error message');
await activeWorkflowRunner.init();
activeWorkflowRunner.executeErrorWorkflow(error, workflowData, 'trigger');
expect(workflowExecuteAdditionalDataExecuteErrorWorkflowSpy).toHaveBeenCalledTimes(1);
});
describe('init()', () => {
it('should execute error workflow on failure to activate due to 401', async () => {
databaseActiveWorkflowsCount = 1;
jest.spyOn(ActiveWorkflowRunner.prototype, 'add').mockImplementation(() => {
throw new NodeApiError(
{
id: 'a75dcd1b-9fed-4643-90bd-75933d67936c',
name: 'Github Trigger',
type: 'n8n-nodes-base.githubTrigger',
typeVersion: 1,
position: [0, 0],
} as INode,
{
httpCode: '401',
message: 'Authorization failed - please check your credentials',
},
);
});
const executeSpy = jest.spyOn(ActiveWorkflowRunner.prototype, 'executeErrorWorkflow');
await activeWorkflowRunner.init();
const [error, workflow] = executeSpy.mock.calls[0];
expect(error.message).toContain('Authorization');
expect(workflow.id).toBe('1');
});
});
});