test(core): Move unit tests closer to testable components (no-changelog) (#10287)

This commit is contained in:
Tomi Turtiainen
2024-08-05 12:12:25 +03:00
committed by GitHub
parent 8131d66f8c
commit afa43e75f6
80 changed files with 95 additions and 105 deletions

View File

@@ -0,0 +1,163 @@
import { ActiveExecutions } from '@/ActiveExecutions';
import PCancelable from 'p-cancelable';
import { v4 as uuid } from 'uuid';
import type { IExecuteResponsePromiseData, IRun } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-workflow';
import type { IWorkflowExecutionDataProcess } from '@/Interfaces';
import type { ExecutionRepository } from '@db/repositories/execution.repository';
import { mock } from 'jest-mock-extended';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import { mockInstance } from '@test/mocking';
const FAKE_EXECUTION_ID = '15';
const FAKE_SECOND_EXECUTION_ID = '20';
const updateExistingExecution = jest.fn();
const createNewExecution = jest.fn(async () => FAKE_EXECUTION_ID);
const executionRepository = mock<ExecutionRepository>({
updateExistingExecution,
createNewExecution,
});
const concurrencyControl = mockInstance(ConcurrencyControlService, {
// @ts-expect-error Private property
isEnabled: false,
});
describe('ActiveExecutions', () => {
let activeExecutions: ActiveExecutions;
beforeEach(() => {
activeExecutions = new ActiveExecutions(mock(), executionRepository, concurrencyControl);
});
afterEach(() => {
jest.clearAllMocks();
});
test('Should initialize activeExecutions with empty list', () => {
expect(activeExecutions.getActiveExecutions().length).toBe(0);
});
test('Should add execution to active execution list', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
expect(executionId).toBe(FAKE_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions().length).toBe(1);
expect(createNewExecution).toHaveBeenCalledTimes(1);
expect(updateExistingExecution).toHaveBeenCalledTimes(0);
});
test('Should update execution if add is called with execution ID', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution, FAKE_SECOND_EXECUTION_ID);
expect(executionId).toBe(FAKE_SECOND_EXECUTION_ID);
expect(activeExecutions.getActiveExecutions().length).toBe(1);
expect(createNewExecution).toHaveBeenCalledTimes(0);
expect(updateExistingExecution).toHaveBeenCalledTimes(1);
});
test('Should fail attaching execution to invalid executionId', async () => {
const deferredPromise = mockCancelablePromise();
expect(() => {
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise);
}).toThrow();
});
test('Should successfully attach execution to valid executionId', async () => {
const newExecution = mockExecutionData();
await activeExecutions.add(newExecution, FAKE_EXECUTION_ID);
const deferredPromise = mockCancelablePromise();
expect(() =>
activeExecutions.attachWorkflowExecution(FAKE_EXECUTION_ID, deferredPromise),
).not.toThrow();
});
test('Should attach and resolve response promise to existing execution', async () => {
const newExecution = mockExecutionData();
await activeExecutions.add(newExecution, FAKE_EXECUTION_ID);
const deferredPromise = await mockDeferredPromise();
activeExecutions.attachResponsePromise(FAKE_EXECUTION_ID, deferredPromise);
const fakeResponse = { data: { resultData: { runData: {} } } };
activeExecutions.resolveResponsePromise(FAKE_EXECUTION_ID, fakeResponse);
await expect(deferredPromise.promise()).resolves.toEqual(fakeResponse);
});
test('Should remove an existing execution', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
activeExecutions.remove(executionId);
expect(activeExecutions.getActiveExecutions().length).toBe(0);
});
test('Should resolve post execute promise on removal', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
const postExecutePromise = activeExecutions.getPostExecutePromise(executionId);
// Force the above to be executed since we cannot await it
await new Promise((res) => {
setTimeout(res, 100);
});
const fakeOutput = mockFullRunData();
activeExecutions.remove(executionId, fakeOutput);
await expect(postExecutePromise).resolves.toEqual(fakeOutput);
});
test('Should throw error when trying to create a promise with invalid execution', async () => {
await expect(activeExecutions.getPostExecutePromise(FAKE_EXECUTION_ID)).rejects.toThrow();
});
test('Should call function to cancel execution when asked to stop', async () => {
const newExecution = mockExecutionData();
const executionId = await activeExecutions.add(newExecution);
const cancelExecution = jest.fn();
const cancellablePromise = mockCancelablePromise();
cancellablePromise.cancel = cancelExecution;
activeExecutions.attachWorkflowExecution(executionId, cancellablePromise);
void activeExecutions.stopExecution(executionId);
expect(cancelExecution).toHaveBeenCalledTimes(1);
});
});
function mockExecutionData(): IWorkflowExecutionDataProcess {
return {
executionMode: 'manual',
workflowData: {
id: '123',
name: 'Test workflow 1',
active: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
connections: {},
},
userId: uuid(),
};
}
function mockFullRunData(): IRun {
return {
data: {
resultData: {
runData: {},
},
},
mode: 'manual',
startedAt: new Date(),
status: 'new',
};
}
// eslint-disable-next-line @typescript-eslint/promise-function-async
const mockCancelablePromise = () => new PCancelable<IRun>((resolve) => resolve());
// eslint-disable-next-line @typescript-eslint/promise-function-async
const mockDeferredPromise = () => createDeferredPromise<IExecuteResponsePromiseData>();

View File

@@ -0,0 +1,40 @@
import { CredentialTypes } from '@/CredentialTypes';
import { Container } from 'typedi';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { mockInstance } from '@test/mocking';
describe('CredentialTypes', () => {
const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, {
loadedCredentials: {
fakeFirstCredential: {
type: {
name: 'fakeFirstCredential',
displayName: 'Fake First Credential',
properties: [],
},
sourcePath: '',
},
fakeSecondCredential: {
type: {
name: 'fakeSecondCredential',
displayName: 'Fake Second Credential',
properties: [],
},
sourcePath: '',
},
},
});
const credentialTypes = Container.get(CredentialTypes);
test('Should throw error when calling invalid credential name', () => {
expect(() => credentialTypes.getByName('fakeThirdCredential')).toThrowError();
});
test('Should return correct credential type for valid name', () => {
const mockedCredentialTypes = mockNodesAndCredentials.loadedCredentials;
expect(credentialTypes.getByName('fakeFirstCredential')).toStrictEqual(
mockedCredentialTypes.fakeFirstCredential.type,
);
});
});

View File

@@ -0,0 +1,296 @@
import Container from 'typedi';
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialType,
IHttpRequestOptions,
INode,
INodeProperties,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { Workflow } from 'n8n-workflow';
import { CredentialsHelper } from '@/CredentialsHelper';
import { NodeTypes } from '@/NodeTypes';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { mockInstance } from '@test/mocking';
describe('CredentialsHelper', () => {
mockInstance(CredentialsRepository);
mockInstance(SharedCredentialsRepository);
const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, {
loadedNodes: {
'test.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
group: ['input'],
version: 1,
description: 'Sets a value',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Value1',
name: 'value1',
type: 'string',
default: 'default-value1',
},
{
displayName: 'Value2',
name: 'value2',
type: 'string',
default: 'default-value2',
},
],
},
},
},
},
});
const nodeTypes = mockInstance(NodeTypes);
describe('authenticate', () => {
const tests: Array<{
description: string;
input: {
credentials: ICredentialDataDecryptedObject;
credentialType: ICredentialType;
};
output: IHttpRequestOptions;
}> = [
{
description: 'basicAuth, default property names',
input: {
credentials: {
user: 'user1',
password: 'password1',
},
credentialType: new (class TestApi implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
{
displayName: 'User',
name: 'user',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
auth: {
username: '={{$credentials.user}}',
password: '={{$credentials.password}}',
},
},
};
})(),
},
output: {
url: '',
headers: {},
auth: { username: 'user1', password: 'password1' },
qs: {},
},
},
{
description: 'headerAuth',
input: {
credentials: {
accessToken: 'test',
},
credentialType: new (class TestApi implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.accessToken}}',
},
},
};
})(),
},
output: { url: '', headers: { Authorization: 'Bearer test' }, qs: {} },
},
{
description: 'headerAuth, key and value expressions',
input: {
credentials: {
accessToken: 'test',
},
credentialType: new (class TestApi implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
'={{$credentials.accessToken}}': '=Bearer {{$credentials.accessToken}}',
},
},
};
})(),
},
output: { url: '', headers: { test: 'Bearer test' }, qs: {} },
},
{
description: 'queryAuth',
input: {
credentials: {
accessToken: 'test',
},
credentialType: new (class TestApi implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
];
authenticate = {
type: 'generic',
properties: {
qs: {
accessToken: '={{$credentials.accessToken}}',
},
},
} as IAuthenticateGeneric;
})(),
},
output: { url: '', headers: {}, qs: { accessToken: 'test' } },
},
{
description: 'custom authentication',
input: {
credentials: {
accessToken: 'test',
user: 'testUser',
},
credentialType: new (class TestApi implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
{
displayName: 'My Token',
name: 'myToken',
type: 'string',
default: '',
},
];
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.headers!.Authorization = `Bearer ${credentials.accessToken}`;
requestOptions.qs!.user = credentials.user;
return requestOptions;
}
})(),
},
output: {
url: '',
headers: { Authorization: 'Bearer test' },
qs: { user: 'testUser' },
},
},
];
const node: INode = {
id: 'uuid-1',
parameters: {},
name: 'test',
type: 'test.set',
typeVersion: 1,
position: [0, 0],
};
const incomingRequestOptions = {
url: '',
headers: {},
qs: {},
};
const workflow = new Workflow({
nodes: [node],
connections: {},
active: false,
nodeTypes,
});
for (const testData of tests) {
test(testData.description, async () => {
//@ts-expect-error `loadedCredentials` is a getter and we are replacing it here with a property
mockNodesAndCredentials.loadedCredentials = {
[testData.input.credentialType.name]: {
type: testData.input.credentialType,
sourcePath: '',
},
};
const credentialsHelper = Container.get(CredentialsHelper);
const result = await credentialsHelper.authenticate(
testData.input.credentials,
testData.input.credentialType.name,
deepCopy(incomingRequestOptions),
workflow,
node,
);
expect(result).toEqual(testData.output);
});
}
});
});

View File

@@ -0,0 +1,273 @@
import { LicenseManager } from '@n8n_io/license-sdk';
import { InstanceSettings } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import { License } from '@/License';
import { Logger } from '@/Logger';
import { N8N_VERSION } from '@/constants';
import { mockInstance } from '@test/mocking';
import { OrchestrationService } from '@/services/orchestration.service';
jest.mock('@n8n_io/license-sdk');
const MOCK_SERVER_URL = 'https://server.com/v1';
const MOCK_RENEW_OFFSET = 259200;
const MOCK_INSTANCE_ID = 'instance-id';
const MOCK_ACTIVATION_KEY = 'activation-key';
const MOCK_FEATURE_FLAG = 'feat:sharing';
const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26';
describe('License', () => {
beforeAll(() => {
config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
config.set('license.tenantId', 1);
});
let license: License;
const logger = mockInstance(Logger);
const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID });
mockInstance(OrchestrationService);
beforeEach(async () => {
license = new License(logger, instanceSettings, mock(), mock(), mock());
await license.init();
});
test('initializes license manager', async () => {
expect(LicenseManager).toHaveBeenCalledWith({
autoRenewEnabled: true,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: false,
renewOnInit: true,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
logger,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
});
});
test('initializes license manager for worker', async () => {
license = new License(logger, instanceSettings, mock(), mock(), mock());
await license.init('worker');
expect(LicenseManager).toHaveBeenCalledWith({
autoRenewEnabled: false,
autoRenewOffset: MOCK_RENEW_OFFSET,
offlineMode: true,
renewOnInit: false,
deviceFingerprint: expect.any(Function),
productIdentifier: `n8n-${N8N_VERSION}`,
logger,
loadCertStr: expect.any(Function),
saveCertStr: expect.any(Function),
onFeatureChange: expect.any(Function),
collectUsageMetrics: expect.any(Function),
collectPassthroughData: expect.any(Function),
server: MOCK_SERVER_URL,
tenantId: 1,
});
});
test('attempts to activate license with provided key', async () => {
await license.activate(MOCK_ACTIVATION_KEY);
expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY);
});
test('renews license', async () => {
await license.renew();
expect(LicenseManager.prototype.renew).toHaveBeenCalled();
});
test('check if feature is enabled', () => {
license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
test('check if sharing feature is enabled', () => {
license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
test('check fetching entitlements', () => {
license.getCurrentEntitlements();
expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled();
});
test('check fetching feature values', async () => {
license.getFeatureValue(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
});
test('check management jwt', async () => {
license.getManagementJwt();
expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled();
});
test('getMainPlan() returns the right entitlement', async () => {
// mock entitlements response
License.prototype.getCurrentEntitlements = jest.fn().mockReturnValue([
{
id: '84a9c852-1349-478d-9ad1-b3f55510e477',
productId: '670650f2-72d8-4397-898c-c249906e2cc2',
productMetadata: {},
features: {},
featureOverrides: {},
validFrom: new Date(),
validTo: new Date(),
},
{
id: MOCK_MAIN_PLAN_ID,
productId: '670650f2-72d8-4397-898c-c249906e2cc2',
productMetadata: {
terms: {
isMainPlan: true,
},
},
features: {},
featureOverrides: {},
validFrom: new Date(),
validTo: new Date(),
},
]);
jest.fn(license.getMainPlan).mockReset();
const mainPlan = license.getMainPlan();
expect(mainPlan?.id).toBe(MOCK_MAIN_PLAN_ID);
});
test('getMainPlan() returns undefined if there is no main plan', async () => {
// mock entitlements response
License.prototype.getCurrentEntitlements = jest.fn().mockReturnValue([
{
id: '84a9c852-1349-478d-9ad1-b3f55510e477',
productId: '670650f2-72d8-4397-898c-c249906e2cc2',
productMetadata: {}, // has no `productMetadata.terms.isMainPlan`!
features: {},
featureOverrides: {},
validFrom: new Date(),
validTo: new Date(),
},
{
id: 'c1aae471-c24e-4874-ad88-b97107de486c',
productId: '670650f2-72d8-4397-898c-c249906e2cc2',
productMetadata: {}, // has no `productMetadata.terms.isMainPlan`!
features: {},
featureOverrides: {},
validFrom: new Date(),
validTo: new Date(),
},
]);
jest.fn(license.getMainPlan).mockReset();
const mainPlan = license.getMainPlan();
expect(mainPlan).toBeUndefined();
});
});
describe('License', () => {
beforeEach(() => {
config.load(config.default);
});
describe('init', () => {
describe('in single-main setup', () => {
describe('with `license.autoRenewEnabled` enabled', () => {
it('should enable renewal', async () => {
config.set('multiMainSetup.enabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
});
describe('with `license.autoRenewEnabled` disabled', () => {
it('should disable renewal', async () => {
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
});
});
});
describe('in multi-main setup', () => {
describe('with `license.autoRenewEnabled` disabled', () => {
test.each(['unset', 'leader', 'follower'])(
'if %s status, should disable removal',
async (status) => {
config.set('multiMainSetup.enabled', true);
config.set('multiMainSetup.instanceType', status);
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
},
);
});
describe('with `license.autoRenewEnabled` enabled', () => {
test.each(['unset', 'follower'])('if %s status, should disable removal', async (status) => {
config.set('multiMainSetup.enabled', true);
config.set('multiMainSetup.instanceType', status);
config.set('license.autoRenewEnabled', false);
await new License(mock(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
});
it('if leader status, should enable renewal', async () => {
config.set('multiMainSetup.enabled', true);
config.set('multiMainSetup.instanceType', 'leader');
await new License(mock(), mock(), mock(), mock(), mock()).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
});
});
});
describe('reinit', () => {
it('should reinitialize license manager', async () => {
const license = new License(mock(), mock(), mock(), mock(), mock());
await license.init();
const initSpy = jest.spyOn(license, 'init');
await license.reinit();
expect(initSpy).toHaveBeenCalledWith('main', true);
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,134 @@
import { mock } from 'jest-mock-extended';
import { TestWebhooks } from '@/TestWebhooks';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
import { v4 as uuid } from 'uuid';
import { generateNanoId } from '@/databases/utils/generators';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import * as WebhookHelpers from '@/WebhookHelpers';
import type * as express from 'express';
import type { IWorkflowDb, WebhookRequest } from '@/Interfaces';
import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow';
import type {
TestWebhookRegistrationsService,
TestWebhookRegistration,
} from '@/services/test-webhook-registrations.service';
import * as AdditionalData from '@/WorkflowExecuteAdditionalData';
jest.mock('@/WorkflowExecuteAdditionalData');
const mockedAdditionalData = AdditionalData as jest.Mocked<typeof AdditionalData>;
const workflowEntity = mock<IWorkflowDb>({ id: generateNanoId(), nodes: [] });
const httpMethod = 'GET';
const path = uuid();
const userId = '04ab4baf-85df-478f-917b-d303934a97de';
const webhook = mock<IWebhookData>({
httpMethod,
path,
workflowId: workflowEntity.id,
userId,
});
const registrations = mock<TestWebhookRegistrationsService>();
let testWebhooks: TestWebhooks;
describe('TestWebhooks', () => {
beforeAll(() => {
testWebhooks = new TestWebhooks(mock(), mock(), registrations, mock());
jest.useFakeTimers();
});
describe('needsWebhook()', () => {
const args: Parameters<typeof testWebhooks.needsWebhook> = [
userId,
workflowEntity,
mock<IWorkflowExecuteAdditionalData>(),
];
test('if webhook is needed, should register then create webhook and return true', async () => {
const workflow = mock<Workflow>();
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const needsWebhook = await testWebhooks.needsWebhook(...args);
const [registerOrder] = registrations.register.mock.invocationCallOrder;
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
expect(registerOrder).toBeLessThan(createOrder);
expect(needsWebhook).toBe(true);
});
test('if webhook activation fails, should deactivate workflow webhooks', async () => {
const msg = 'Failed to add webhook to active webhooks';
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg));
registrations.getAllRegistrations.mockResolvedValue([]);
const needsWebhook = testWebhooks.needsWebhook(...args);
await expect(needsWebhook).rejects.toThrowError(msg);
});
test('if no webhook is found to start workflow, should return false', async () => {
webhook.webhookDescription.restartWebhook = true;
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const result = await testWebhooks.needsWebhook(...args);
expect(result).toBe(false);
});
});
describe('executeWebhook()', () => {
test('if webhook is not registered, should throw', async () => {
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(webhook);
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
const promise = testWebhooks.executeWebhook(
mock<WebhookRequest>({ params: { path } }),
mock(),
);
await expect(promise).rejects.toThrowError(WebhookNotFoundError);
});
test('if webhook is registered but missing from workflow, should throw', async () => {
jest.spyOn(testWebhooks, 'getActiveWebhook').mockResolvedValue(webhook);
jest.spyOn(testWebhooks, 'getWebhookMethods').mockResolvedValue([]);
const registration = mock<TestWebhookRegistration>({
pushRef: 'some-session-id',
workflowEntity,
});
await registrations.register(registration);
const promise = testWebhooks.executeWebhook(
mock<WebhookRequest>({ params: { path } }),
mock<express.Response>(),
);
await expect(promise).rejects.toThrowError(NotFoundError);
});
});
describe('deactivateWebhooks()', () => {
test('should add additional data to workflow', async () => {
registrations.getAllRegistrations.mockResolvedValue([{ workflowEntity, webhook }]);
const workflow = testWebhooks.toWorkflow(workflowEntity);
await testWebhooks.deactivateWebhooks(workflow);
expect(mockedAdditionalData.getBase).toHaveBeenCalledWith(userId);
});
});
});

View File

@@ -0,0 +1,142 @@
import { WaitTracker } from '@/WaitTracker';
import { mock } from 'jest-mock-extended';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IExecutionResponse } from '@/Interfaces';
import { OrchestrationService } from '@/services/orchestration.service';
import type { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee';
jest.useFakeTimers();
describe('WaitTracker', () => {
const executionRepository = mock<ExecutionRepository>();
const multiMainSetup = mock<MultiMainSetup>();
const orchestrationService = new OrchestrationService(mock(), mock(), mock(), multiMainSetup);
const execution = mock<IExecutionResponse>({
id: '123',
waitTill: new Date(Date.now() + 1000),
});
let waitTracker: WaitTracker;
beforeEach(() => {
waitTracker = new WaitTracker(
mock(),
executionRepository,
mock(),
mock(),
orchestrationService,
);
multiMainSetup.on.mockReturnThis();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('init()', () => {
it('should query DB for waiting executions if leader', async () => {
jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(true);
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
waitTracker.init();
expect(executionRepository.getWaitingExecutions).toHaveBeenCalledTimes(1);
});
it('if follower, should do nothing', () => {
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
expect(executionRepository.findSingleExecution).not.toHaveBeenCalled();
});
it('if no executions to start, should do nothing', () => {
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
expect(executionRepository.findSingleExecution).not.toHaveBeenCalled();
});
describe('if execution to start', () => {
it('if not enough time passed, should not start execution', async () => {
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
waitTracker.init();
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
await waitTracker.getWaitingExecutions();
const startExecutionSpy = jest.spyOn(waitTracker, 'startExecution');
jest.advanceTimersByTime(100);
expect(startExecutionSpy).not.toHaveBeenCalled();
});
it('if enough time passed, should start execution', async () => {
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
await waitTracker.getWaitingExecutions();
const startExecutionSpy = jest.spyOn(waitTracker, 'startExecution');
jest.advanceTimersByTime(2_000);
expect(startExecutionSpy).toHaveBeenCalledWith(execution.id);
});
});
});
describe('startExecution()', () => {
it('should query for execution to start', async () => {
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
executionRepository.findSingleExecution.mockResolvedValue(execution);
waitTracker.startExecution(execution.id);
jest.advanceTimersByTime(5);
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
includeData: true,
unflattenData: true,
});
});
});
describe('single-main setup', () => {
it('should start tracking', () => {
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
expect(executionRepository.getWaitingExecutions).toHaveBeenCalledTimes(1);
});
});
describe('multi-main setup', () => {
it('should start tracking if leader', () => {
jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(true);
jest.spyOn(orchestrationService, 'isSingleMainSetup', 'get').mockReturnValue(false);
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
expect(executionRepository.getWaitingExecutions).toHaveBeenCalledTimes(1);
});
it('should not start tracking if follower', () => {
jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(false);
jest.spyOn(orchestrationService, 'isSingleMainSetup', 'get').mockReturnValue(false);
executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init();
expect(executionRepository.getWaitingExecutions).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,142 @@
import { type Response } from 'express';
import { mock } from 'jest-mock-extended';
import { randomString } from 'n8n-workflow';
import type { IHttpRequestMethods } from 'n8n-workflow';
import type { IWebhookManager, WebhookCORSRequest, WebhookRequest } from '@/Interfaces';
import { webhookRequestHandler } from '@/WebhookHelpers';
describe('WebhookHelpers', () => {
describe('webhookRequestHandler', () => {
const webhookManager = mock<Required<IWebhookManager>>();
const handler = webhookRequestHandler(webhookManager);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw for unsupported methods', async () => {
const req = mock<WebhookRequest | WebhookCORSRequest>({
method: 'CONNECT' as IHttpRequestMethods,
});
const res = mock<Response>();
res.status.mockReturnValue(res);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
code: 0,
message: 'The method CONNECT is not supported.',
});
});
describe('preflight requests', () => {
it('should handle missing header for requested method', async () => {
const req = mock<WebhookRequest | WebhookCORSRequest>({
method: 'OPTIONS',
headers: {
origin: 'https://example.com',
'access-control-request-method': undefined,
},
params: { path: 'test' },
});
const res = mock<Response>();
res.status.mockReturnValue(res);
webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.header).toHaveBeenCalledWith(
'Access-Control-Allow-Methods',
'OPTIONS, GET, PATCH',
);
});
it('should handle default origin and max-age', async () => {
const req = mock<WebhookRequest | WebhookCORSRequest>({
method: 'OPTIONS',
headers: {
origin: 'https://example.com',
'access-control-request-method': 'GET',
},
params: { path: 'test' },
});
const res = mock<Response>();
res.status.mockReturnValue(res);
webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.header).toHaveBeenCalledWith(
'Access-Control-Allow-Methods',
'OPTIONS, GET, PATCH',
);
expect(res.header).toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
'https://example.com',
);
expect(res.header).toHaveBeenCalledWith('Access-Control-Max-Age', '300');
});
it('should handle wildcard origin', async () => {
const randomOrigin = randomString(10);
const req = mock<WebhookRequest | WebhookCORSRequest>({
method: 'OPTIONS',
headers: {
origin: randomOrigin,
'access-control-request-method': 'GET',
},
params: { path: 'test' },
});
const res = mock<Response>();
res.status.mockReturnValue(res);
webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
webhookManager.findAccessControlOptions.mockResolvedValue({
allowedOrigins: '*',
});
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.header).toHaveBeenCalledWith(
'Access-Control-Allow-Methods',
'OPTIONS, GET, PATCH',
);
expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', randomOrigin);
});
it('should handle custom origin', async () => {
const req = mock<WebhookRequest | WebhookCORSRequest>({
method: 'OPTIONS',
headers: {
origin: 'https://example.com',
'access-control-request-method': 'GET',
},
params: { path: 'test' },
});
const res = mock<Response>();
res.status.mockReturnValue(res);
webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
webhookManager.findAccessControlOptions.mockResolvedValue({
allowedOrigins: 'https://test.com',
});
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.header).toHaveBeenCalledWith(
'Access-Control-Allow-Methods',
'OPTIONS, GET, PATCH',
);
expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://test.com');
});
});
});
});

View File

@@ -0,0 +1,41 @@
import { VariablesService } from '@/environments/variables/variables.service.ee';
import { mockInstance } from '@test/mocking';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { getBase } from '@/WorkflowExecuteAdditionalData';
import Container from 'typedi';
import { CredentialsHelper } from '@/CredentialsHelper';
import { SecretsHelper } from '@/SecretsHelpers';
describe('WorkflowExecuteAdditionalData', () => {
const messageEventBus = mockInstance(MessageEventBus);
const variablesService = mockInstance(VariablesService);
variablesService.getAllCached.mockResolvedValue([]);
const credentialsHelper = mockInstance(CredentialsHelper);
const secretsHelper = mockInstance(SecretsHelper);
Container.set(MessageEventBus, messageEventBus);
Container.set(VariablesService, variablesService);
Container.set(CredentialsHelper, credentialsHelper);
Container.set(SecretsHelper, secretsHelper);
test('logAiEvent should call MessageEventBus', async () => {
const additionalData = await getBase('user-id');
const eventName = 'n8n.ai.memory.get.messages';
const payload = {
msg: 'test message',
executionId: '123',
nodeName: 'n8n-memory',
workflowId: 'workflow-id',
workflowName: 'workflow-name',
nodeType: 'n8n-memory',
};
await additionalData.logAiEvent(eventName, payload);
expect(messageEventBus.sendAiNodeEvent).toHaveBeenCalledTimes(1);
expect(messageEventBus.sendAiNodeEvent).toHaveBeenCalledWith({
eventName,
payload,
});
});
});

View File

@@ -0,0 +1,46 @@
import { type Workflow } from 'n8n-workflow';
import { getExecutionStartNode } from '@/WorkflowHelpers';
import type { IWorkflowExecutionDataProcess } from '@/Interfaces';
describe('WorkflowHelpers', () => {
describe('getExecutionStartNode', () => {
it('Should return undefined', () => {
const data = {
pinData: {},
startNodes: [],
} as unknown as IWorkflowExecutionDataProcess;
const workflow = {
getNode(nodeName: string) {
return {
name: nodeName,
};
},
} as unknown as Workflow;
const executionStartNode = getExecutionStartNode(data, workflow);
expect(executionStartNode).toBeUndefined();
});
it('Should return startNode', () => {
const data = {
pinData: {
node1: {},
node2: {},
},
startNodes: [{ name: 'node2' }],
} as unknown as IWorkflowExecutionDataProcess;
const workflow = {
getNode(nodeName: string) {
if (nodeName === 'node2') {
return {
name: 'node2',
};
}
return undefined;
},
} as unknown as Workflow;
const executionStartNode = getExecutionStartNode(data, workflow);
expect(executionStartNode).toEqual({
name: 'node2',
});
});
});
});

View File

@@ -0,0 +1,84 @@
import Container from 'typedi';
import { WorkflowHooks, type ExecutionError, type IWorkflowExecuteHooks } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import { WorkflowRunner } from '@/WorkflowRunner';
import config from '@/config';
import * as testDb from '@test-integration/testDb';
import { setupTestServer } from '@test-integration/utils';
import { createUser } from '@test-integration/db/users';
import { createWorkflow } from '@test-integration/db/workflows';
import { createExecution } from '@test-integration/db/executions';
import { mockInstance } from '@test/mocking';
import { Telemetry } from '@/telemetry';
let owner: User;
let runner: WorkflowRunner;
let hookFunctions: IWorkflowExecuteHooks;
setupTestServer({ endpointGroups: [] });
mockInstance(Telemetry);
class Watchers {
workflowExecuteAfter = jest.fn();
}
const watchers = new Watchers();
const watchedWorkflowExecuteAfter = jest.spyOn(watchers, 'workflowExecuteAfter');
beforeAll(async () => {
owner = await createUser({ role: 'global:owner' });
runner = Container.get(WorkflowRunner);
hookFunctions = {
workflowExecuteAfter: [watchers.workflowExecuteAfter],
};
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(async () => {
await testDb.truncate(['Workflow', 'SharedWorkflow']);
});
test('processError should return early in Bull stalled edge case', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
config.set('executions.mode', 'queue');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should process error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
config.set('executions.mode', 'regular');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
});