mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
Adds soft‑deletion support for workflows through a new boolean column `isArchived`. When a workflow is archived we now set `isArchived` flag to true and the workflows stays in the database and is omitted from the default workflow listing query. Archived workflows can be viewed in read-only mode, but they cannot be activated. Archived workflows are still available by ID and can be invoked as sub-executions, so existing Execute Workflow nodes continue to work. Execution engine doesn't care about isArchived flag. Users can restore workflows via Unarchive action at the UI.
1787 lines
42 KiB
TypeScript
1787 lines
42 KiB
TypeScript
import { mock } from 'jest-mock-extended';
|
||
import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
|
||
|
||
import { STICKY_NODE_TYPE } from '@/Constants';
|
||
import { ApplicationError, ExpressionError, NodeApiError } from '@/errors';
|
||
import type { INode, INodeTypeDescription, IRun, IRunData } from '@/Interfaces';
|
||
import { type NodeConnectionType, NodeConnectionTypes, type IWorkflowBase } from '@/Interfaces';
|
||
import * as nodeHelpers from '@/NodeHelpers';
|
||
import {
|
||
ANONYMIZATION_CHARACTER as CHAR,
|
||
extractLastExecutedNodeCredentialData,
|
||
generateNodesGraph,
|
||
getDomainBase,
|
||
getDomainPath,
|
||
resolveAIMetrics,
|
||
userInInstanceRanOutOfFreeAiCredits,
|
||
} from '@/TelemetryHelpers';
|
||
import { randomInt } from '@/utils';
|
||
|
||
import { nodeTypes } from './ExpressionExtensions/Helpers';
|
||
import type { NodeTypes } from './NodeTypes';
|
||
|
||
describe('getDomainBase should return protocol plus domain', () => {
|
||
test('in valid URLs', () => {
|
||
for (const url of validUrls(numericId)) {
|
||
const { full, protocolPlusDomain } = url;
|
||
expect(getDomainBase(full)).toBe(protocolPlusDomain);
|
||
}
|
||
});
|
||
|
||
test('in malformed URLs', () => {
|
||
for (const url of malformedUrls(numericId)) {
|
||
const { full, protocolPlusDomain } = url;
|
||
expect(getDomainBase(full)).toBe(protocolPlusDomain);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('getDomainPath should return pathname, excluding query string', () => {
|
||
describe('anonymizing strings containing at least one number', () => {
|
||
test('in valid URLs', () => {
|
||
for (const url of validUrls(alphanumericId)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
|
||
test('in malformed URLs', () => {
|
||
for (const url of malformedUrls(alphanumericId)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('anonymizing UUIDs', () => {
|
||
test('in valid URLs', () => {
|
||
for (const url of uuidUrls(validUrls)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
|
||
test('in malformed URLs', () => {
|
||
for (const url of uuidUrls(malformedUrls)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('anonymizing emails', () => {
|
||
test('in valid URLs', () => {
|
||
for (const url of validUrls(email)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
|
||
test('in malformed URLs', () => {
|
||
for (const url of malformedUrls(email)) {
|
||
const { full, pathname } = url;
|
||
expect(getDomainPath(full)).toBe(pathname);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('generateNodesGraph', () => {
|
||
test('should return node graph when node type is unknown', () => {
|
||
const workflow: IWorkflowBase = {
|
||
createdAt: new Date('2024-01-05T13:49:14.244Z'),
|
||
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
|
||
id: 'NfV4GV9aQTifSLc2',
|
||
name: 'My workflow 26',
|
||
active: false,
|
||
isArchived: false,
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
name: 'When clicking "Execute Workflow"',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [420, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
documentId: { __rl: true, mode: 'list', value: '' },
|
||
sheetName: { __rl: true, mode: 'list', value: '' },
|
||
},
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
name: 'Google Sheets',
|
||
type: 'test.unknown',
|
||
typeVersion: 4.2,
|
||
position: [640, 420],
|
||
},
|
||
],
|
||
connections: {
|
||
'When clicking "Execute Workflow"': {
|
||
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
|
||
},
|
||
},
|
||
settings: { executionOrder: 'v1' },
|
||
pinData: {},
|
||
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.manualTrigger', 'test.unknown'],
|
||
node_connections: [{ start: '0', end: '1' }],
|
||
nodes: {
|
||
'0': {
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
position: [420, 420],
|
||
},
|
||
'1': {
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
type: 'test.unknown',
|
||
version: 4.2,
|
||
position: [640, 420],
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph when workflow is empty', () => {
|
||
const workflow: IWorkflowBase = {
|
||
createdAt: new Date('2024-01-05T13:49:14.244Z'),
|
||
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
|
||
id: 'NfV4GV9aQTifSLc2',
|
||
name: 'My workflow 26',
|
||
active: false,
|
||
isArchived: false,
|
||
nodes: [],
|
||
connections: {},
|
||
settings: { executionOrder: 'v1' },
|
||
pinData: {},
|
||
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: [],
|
||
node_connections: [],
|
||
nodes: {},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: {},
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph when workflow keys are not set', () => {
|
||
const workflow: Partial<IWorkflowBase> = {};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: [],
|
||
node_connections: [],
|
||
nodes: {},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: {},
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph when node has multiple operation fields with different display options', () => {
|
||
const workflow: IWorkflowBase = {
|
||
createdAt: new Date('2024-01-05T13:49:14.244Z'),
|
||
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
|
||
id: 'NfV4GV9aQTifSLc2',
|
||
name: 'My workflow 26',
|
||
active: false,
|
||
isArchived: false,
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
name: 'When clicking "Execute Workflow"',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [420, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
documentId: { __rl: true, mode: 'list', value: '' },
|
||
sheetName: { __rl: true, mode: 'list', value: '' },
|
||
},
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
name: 'Google Sheets',
|
||
type: 'test.googleSheets',
|
||
typeVersion: 4.2,
|
||
position: [640, 420],
|
||
},
|
||
],
|
||
connections: {
|
||
'When clicking "Execute Workflow"': {
|
||
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
|
||
},
|
||
},
|
||
settings: { executionOrder: 'v1' },
|
||
pinData: {},
|
||
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
|
||
node_connections: [{ start: '0', end: '1' }],
|
||
nodes: {
|
||
'0': {
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
position: [420, 420],
|
||
},
|
||
'1': {
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
type: 'test.googleSheets',
|
||
version: 4.2,
|
||
position: [640, 420],
|
||
operation: 'read',
|
||
resource: 'sheet',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph with stickies of default size', () => {
|
||
const workflow: IWorkflowBase = {
|
||
createdAt: new Date('2024-01-05T13:49:14.244Z'),
|
||
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
|
||
id: 'NfV4GV9aQTifSLc2',
|
||
name: 'My workflow 26',
|
||
active: false,
|
||
isArchived: false,
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
name: 'When clicking "Execute Workflow"',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [420, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
documentId: { __rl: true, mode: 'list', value: '' },
|
||
sheetName: { __rl: true, mode: 'list', value: '' },
|
||
},
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
name: 'Google Sheets',
|
||
type: 'test.googleSheets',
|
||
typeVersion: 4.2,
|
||
position: [640, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
content:
|
||
"test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)",
|
||
},
|
||
id: '03e85c3e-4303-4f93-8d62-e05d457e8f70',
|
||
name: 'Sticky Note',
|
||
type: 'n8n-nodes-base.stickyNote',
|
||
typeVersion: 1,
|
||
position: [240, 140],
|
||
},
|
||
],
|
||
connections: {
|
||
'When clicking "Execute Workflow"': {
|
||
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
|
||
},
|
||
},
|
||
settings: { executionOrder: 'v1' },
|
||
pinData: {},
|
||
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
|
||
node_connections: [{ start: '0', end: '1' }],
|
||
nodes: {
|
||
'0': {
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
position: [420, 420],
|
||
},
|
||
'1': {
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
type: 'test.googleSheets',
|
||
version: 4.2,
|
||
position: [640, 420],
|
||
operation: 'read',
|
||
resource: 'sheet',
|
||
},
|
||
},
|
||
notes: { '0': { overlapping: false, position: [240, 140], height: 160, width: 240 } },
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph with stickies indicating overlap', () => {
|
||
const workflow: IWorkflowBase = {
|
||
createdAt: new Date('2024-01-05T13:49:14.244Z'),
|
||
updatedAt: new Date('2024-01-05T15:44:31.000Z'),
|
||
id: 'NfV4GV9aQTifSLc2',
|
||
name: 'My workflow 26',
|
||
active: false,
|
||
isArchived: false,
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
name: 'When clicking "Execute Workflow"',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [420, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
documentId: { __rl: true, mode: 'list', value: '' },
|
||
sheetName: { __rl: true, mode: 'list', value: '' },
|
||
},
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
name: 'Google Sheets',
|
||
type: 'test.googleSheets',
|
||
typeVersion: 4.2,
|
||
position: [640, 420],
|
||
},
|
||
{
|
||
parameters: {
|
||
content:
|
||
"test\n\n## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)",
|
||
height: 488,
|
||
width: 645,
|
||
},
|
||
id: '03e85c3e-4303-4f93-8d62-e05d457e8f70',
|
||
name: 'Sticky Note',
|
||
type: 'n8n-nodes-base.stickyNote',
|
||
typeVersion: 1,
|
||
position: [240, 140],
|
||
},
|
||
],
|
||
connections: {
|
||
'When clicking "Execute Workflow"': {
|
||
main: [[{ node: 'Google Sheets', type: NodeConnectionTypes.Main, index: 0 }]],
|
||
},
|
||
},
|
||
settings: { executionOrder: 'v1' },
|
||
pinData: {},
|
||
versionId: '70b92d94-0e9a-4b41-9976-a654df420af5',
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.manualTrigger', 'test.googleSheets'],
|
||
node_connections: [{ start: '0', end: '1' }],
|
||
nodes: {
|
||
'0': {
|
||
id: 'fa7d5628-5a47-4c8f-98ef-fb3532e5a9f5',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
position: [420, 420],
|
||
},
|
||
'1': {
|
||
id: '266128b9-e5db-4c26-9555-185d48946afb',
|
||
type: 'test.googleSheets',
|
||
version: 4.2,
|
||
position: [640, 420],
|
||
operation: 'read',
|
||
resource: 'sheet',
|
||
},
|
||
},
|
||
notes: { '0': { overlapping: true, position: [240, 140], height: 488, width: 645 } },
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'When clicking "Execute Workflow"': '0', 'Google Sheets': '1' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return node graph indicating pinned data', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce',
|
||
name: 'When clicking "Execute Workflow"',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [460, 460],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {
|
||
'When clicking "Execute Workflow"': [],
|
||
},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nameIndices: {
|
||
'When clicking "Execute Workflow"': '0',
|
||
},
|
||
nodeGraph: {
|
||
is_pinned: true,
|
||
node_connections: [],
|
||
node_types: ['n8n-nodes-base.manualTrigger'],
|
||
nodes: {
|
||
'0': {
|
||
id: 'e59d3ad9-3448-4899-9f47-d2922c8727ce',
|
||
position: [460, 460],
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
},
|
||
},
|
||
notes: {},
|
||
},
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return graph with webhook node', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
path: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8',
|
||
options: {},
|
||
},
|
||
id: '5e49e129-2c59-4650-95ea-14d4b94db1f3',
|
||
name: 'Webhook',
|
||
type: 'n8n-nodes-base.webhook',
|
||
typeVersion: 1.1,
|
||
position: [520, 380],
|
||
webhookId: 'bf4c0699-cff8-4440-8964-8e97fda8b4f8',
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.webhook'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: '5e49e129-2c59-4650-95ea-14d4b94db1f3',
|
||
type: 'n8n-nodes-base.webhook',
|
||
version: 1.1,
|
||
position: [520, 380],
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { Webhook: '0' },
|
||
webhookNodeNames: ['Webhook'],
|
||
});
|
||
});
|
||
|
||
test('should return graph with http v4 node with generic auth', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
url: 'google.com/path/test',
|
||
authentication: 'genericCredentialType',
|
||
genericAuthType: 'httpBasicAuth',
|
||
options: {},
|
||
},
|
||
id: '04d6e44f-09c1-454d-9225-60aeed7f022c',
|
||
name: 'HTTP Request V4 with generic auth',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
typeVersion: 4.1,
|
||
position: [780, 120],
|
||
credentials: {
|
||
httpBasicAuth: {
|
||
id: 'yuuJAO2Ang5B64wd',
|
||
name: 'Unnamed credential',
|
||
},
|
||
},
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.httpRequest'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: '04d6e44f-09c1-454d-9225-60aeed7f022c',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
version: 4.1,
|
||
position: [780, 120],
|
||
credential_type: 'httpBasicAuth',
|
||
credential_set: true,
|
||
domain_base: 'google.com',
|
||
domain_path: '/path/test',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'HTTP Request V4 with generic auth': '0' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return graph with HTTP V4 with predefined cred', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
url: 'google.com/path/test',
|
||
authentication: 'predefinedCredentialType',
|
||
nodeCredentialType: 'activeCampaignApi',
|
||
options: {},
|
||
},
|
||
id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77',
|
||
name: 'HTTP Request V4 with predefined cred',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
typeVersion: 4.1,
|
||
position: [320, 220],
|
||
credentials: {
|
||
httpBasicAuth: {
|
||
id: 'yuuJAO2Ang5B64wd',
|
||
name: 'Unnamed credential',
|
||
},
|
||
activeCampaignApi: {
|
||
id: 'SFCbnfgRBuSzRu6N',
|
||
name: 'ActiveCampaign account',
|
||
},
|
||
},
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.httpRequest'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'dcc4a9e1-c2c5-4d7e-aec0-2a23adabbb77',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
version: 4.1,
|
||
position: [320, 220],
|
||
credential_type: 'activeCampaignApi',
|
||
credential_set: true,
|
||
domain_base: 'google.com',
|
||
domain_path: '/path/test',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'HTTP Request V4 with predefined cred': '0' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
workflow: {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
mode: 'combineBySql',
|
||
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
|
||
},
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
name: 'Merge Node V3',
|
||
type: 'n8n-nodes-base.merge',
|
||
typeVersion: 3,
|
||
position: [320, 460],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
} as Partial<IWorkflowBase>,
|
||
isCloudDeployment: false,
|
||
expected: {
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.merge'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
type: 'n8n-nodes-base.merge',
|
||
version: 3,
|
||
position: [320, 460],
|
||
operation: 'combineBySql',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'Merge Node V3': '0' },
|
||
webhookNodeNames: [],
|
||
},
|
||
},
|
||
{
|
||
workflow: {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
mode: 'append',
|
||
},
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
name: 'Merge Node V3',
|
||
type: 'n8n-nodes-base.merge',
|
||
typeVersion: 3,
|
||
position: [320, 460],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
} as Partial<IWorkflowBase>,
|
||
isCloudDeployment: true,
|
||
expected: {
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.merge'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
type: 'n8n-nodes-base.merge',
|
||
version: 3,
|
||
position: [320, 460],
|
||
operation: 'append',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'Merge Node V3': '0' },
|
||
webhookNodeNames: [],
|
||
},
|
||
},
|
||
{
|
||
workflow: {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
mode: 'combineBySql',
|
||
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
|
||
},
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
name: 'Merge Node V3',
|
||
type: 'n8n-nodes-base.merge',
|
||
typeVersion: 3,
|
||
position: [320, 460],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
} as Partial<IWorkflowBase>,
|
||
isCloudDeployment: true,
|
||
expected: {
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.merge'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
type: 'n8n-nodes-base.merge',
|
||
version: 3,
|
||
position: [320, 460],
|
||
operation: 'combineBySql',
|
||
sql: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.name = input2.id',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'Merge Node V3': '0' },
|
||
webhookNodeNames: [],
|
||
},
|
||
},
|
||
])('should return graph with merge v3 node', ({ workflow, expected, isCloudDeployment }) => {
|
||
expect(generateNodesGraph(workflow, nodeTypes, { isCloudDeployment })).toEqual(expected);
|
||
});
|
||
|
||
test('should return graph with http v1 node', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
url: 'https://google.com',
|
||
options: {},
|
||
},
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
name: 'HTTP Request V1',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
typeVersion: 1,
|
||
position: [320, 460],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.httpRequest'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'b468b603-3e59-4515-b555-90cfebd64d47',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
version: 1,
|
||
position: [320, 460],
|
||
domain: 'google.com',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'HTTP Request V1': '0' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should return graph with http v4 node with no parameters and no credentials', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {
|
||
options: {},
|
||
},
|
||
id: 'd002e66f-deba-455c-9f8b-65239db453c3',
|
||
name: 'HTTP Request v4 with defaults',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
typeVersion: 4.1,
|
||
position: [600, 240],
|
||
},
|
||
],
|
||
connections: {},
|
||
pinData: {},
|
||
};
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: ['n8n-nodes-base.httpRequest'],
|
||
node_connections: [],
|
||
nodes: {
|
||
'0': {
|
||
id: 'd002e66f-deba-455c-9f8b-65239db453c3',
|
||
type: 'n8n-nodes-base.httpRequest',
|
||
version: 4.1,
|
||
position: [600, 240],
|
||
credential_set: false,
|
||
domain_base: '',
|
||
domain_path: '',
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: { 'HTTP Request v4 with defaults': '0' },
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should support custom connections like in AI nodes', () => {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
|
||
name: 'When clicking ‘Test workflow’',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
typeVersion: 1,
|
||
position: [540, 220],
|
||
},
|
||
{
|
||
parameters: {},
|
||
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
|
||
name: 'Chain',
|
||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||
typeVersion: 1,
|
||
position: [760, 320],
|
||
},
|
||
{
|
||
parameters: {
|
||
options: {},
|
||
},
|
||
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
|
||
name: 'Model',
|
||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||
typeVersion: 1,
|
||
position: [780, 500],
|
||
},
|
||
],
|
||
connections: {
|
||
'When clicking ‘Test workflow’': {
|
||
main: [
|
||
[
|
||
{
|
||
node: 'Chain',
|
||
type: NodeConnectionTypes.Main,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
Model: {
|
||
ai_languageModel: [
|
||
[
|
||
{
|
||
node: 'Chain',
|
||
type: NodeConnectionTypes.AiLanguageModel,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
},
|
||
};
|
||
|
||
expect(generateNodesGraph(workflow, nodeTypes)).toEqual({
|
||
nodeGraph: {
|
||
node_types: [
|
||
'n8n-nodes-base.manualTrigger',
|
||
'@n8n/n8n-nodes-langchain.chainLlm',
|
||
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||
],
|
||
node_connections: [
|
||
{
|
||
start: '0',
|
||
end: '1',
|
||
},
|
||
{
|
||
start: '2',
|
||
end: '1',
|
||
},
|
||
],
|
||
nodes: {
|
||
'0': {
|
||
id: 'fe69383c-e418-4f98-9c0e-924deafa7f93',
|
||
type: 'n8n-nodes-base.manualTrigger',
|
||
version: 1,
|
||
position: [540, 220],
|
||
},
|
||
'1': {
|
||
id: 'c5c374f1-6fad-46bb-8eea-ceec126b300a',
|
||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||
version: 1,
|
||
position: [760, 320],
|
||
},
|
||
'2': {
|
||
id: '198133b6-95dd-4f7e-90e5-e16c4cdbad12',
|
||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||
version: 1,
|
||
position: [780, 500],
|
||
},
|
||
},
|
||
notes: {},
|
||
is_pinned: false,
|
||
},
|
||
nameIndices: {
|
||
'When clicking ‘Test workflow’': '0',
|
||
Chain: '1',
|
||
Model: '2',
|
||
},
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
|
||
test('should not fail on error to resolve a node parameter for sticky node type', () => {
|
||
const workflow = mock<IWorkflowBase>({ nodes: [{ type: STICKY_NODE_TYPE }] });
|
||
|
||
jest.spyOn(nodeHelpers, 'getNodeParameters').mockImplementationOnce(() => {
|
||
throw new ApplicationError('Could not find property option');
|
||
});
|
||
|
||
expect(() => generateNodesGraph(workflow, nodeTypes)).not.toThrow();
|
||
});
|
||
|
||
test('should add run and items count', () => {
|
||
const { workflow, runData } = generateTestWorkflowAndRunData();
|
||
|
||
expect(generateNodesGraph(workflow, nodeTypes, { runData })).toEqual({
|
||
nameIndices: {
|
||
DebugHelper: '4',
|
||
'Edit Fields': '1',
|
||
'Edit Fields1': '2',
|
||
'Edit Fields2': '3',
|
||
'Execute Workflow Trigger': '0',
|
||
Switch: '5',
|
||
},
|
||
nodeGraph: {
|
||
is_pinned: false,
|
||
node_connections: [
|
||
{
|
||
end: '1',
|
||
start: '0',
|
||
},
|
||
{
|
||
end: '4',
|
||
start: '0',
|
||
},
|
||
{
|
||
end: '5',
|
||
start: '1',
|
||
},
|
||
{
|
||
end: '1',
|
||
start: '4',
|
||
},
|
||
{
|
||
end: '2',
|
||
start: '5',
|
||
},
|
||
{
|
||
end: '3',
|
||
start: '5',
|
||
},
|
||
],
|
||
node_types: [
|
||
'n8n-nodes-base.executeWorkflowTrigger',
|
||
'n8n-nodes-base.set',
|
||
'n8n-nodes-base.set',
|
||
'n8n-nodes-base.set',
|
||
'n8n-nodes-base.debugHelper',
|
||
'n8n-nodes-base.switch',
|
||
],
|
||
nodes: {
|
||
'0': {
|
||
id: 'a2372c14-87de-42de-9f9e-1c499aa2c279',
|
||
items_total: 1,
|
||
position: [1000, 240],
|
||
runs: 1,
|
||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||
version: 1,
|
||
},
|
||
'1': {
|
||
id: '0f7aa00e-248c-452c-8cd0-62cb55941633',
|
||
items_total: 4,
|
||
position: [1460, 640],
|
||
runs: 2,
|
||
type: 'n8n-nodes-base.set',
|
||
version: 3.1,
|
||
},
|
||
'2': {
|
||
id: '9165c185-9f1c-4ec1-87bf-76ca66dfae38',
|
||
items_total: 4,
|
||
position: [1860, 260],
|
||
runs: 2,
|
||
type: 'n8n-nodes-base.set',
|
||
version: 3.4,
|
||
},
|
||
'3': {
|
||
id: '7a915fd5-5987-4ff1-9509-06b24a0a4613',
|
||
position: [1940, 680],
|
||
type: 'n8n-nodes-base.set',
|
||
version: 3.4,
|
||
},
|
||
'4': {
|
||
id: '63050e7c-8ad5-4f44-8fdd-da555e40471b',
|
||
items_total: 3,
|
||
position: [1220, 240],
|
||
runs: 1,
|
||
type: 'n8n-nodes-base.debugHelper',
|
||
version: 1,
|
||
},
|
||
'5': {
|
||
id: 'fbf7525d-2d1d-4dcf-97a0-43b53d087ef3',
|
||
items_total: 4,
|
||
position: [1680, 640],
|
||
runs: 2,
|
||
type: 'n8n-nodes-base.switch',
|
||
version: 3.2,
|
||
},
|
||
},
|
||
notes: {},
|
||
},
|
||
webhookNodeNames: [],
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('extractLastExecutedNodeCredentialData', () => {
|
||
const cases: Array<[string, IRun]> = [
|
||
['no data', mock<IRun>({ data: {} })],
|
||
['no executionData', mock<IRun>({ data: { executionData: undefined } })],
|
||
[
|
||
'no nodeExecutionStack',
|
||
mock<IRun>({ data: { executionData: { nodeExecutionStack: undefined } } }),
|
||
],
|
||
[
|
||
'no node',
|
||
mock<IRun>({
|
||
data: { executionData: { nodeExecutionStack: [{ node: undefined }] } },
|
||
}),
|
||
],
|
||
[
|
||
'no credentials',
|
||
mock<IRun>({
|
||
data: { executionData: { nodeExecutionStack: [{ node: { credentials: undefined } }] } },
|
||
}),
|
||
],
|
||
];
|
||
|
||
test.each(cases)(
|
||
'should return credentialId and credentialsType with null if %s',
|
||
(_, runData) => {
|
||
expect(extractLastExecutedNodeCredentialData(runData)).toBeNull();
|
||
},
|
||
);
|
||
|
||
it('should return correct credentialId and credentialsType when last node executed has credential', () => {
|
||
const runData = mock<IRun>({
|
||
data: {
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(extractLastExecutedNodeCredentialData(runData)).toMatchObject(
|
||
expect.objectContaining({ credentialId: 'nhu-l8E4hX', credentialType: 'openAiApi' }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('userInInstanceRanOutOfFreeAiCredits', () => {
|
||
it('should return false if could not find node credentials', () => {
|
||
const runData = {
|
||
status: 'error',
|
||
mode: 'manual',
|
||
data: {
|
||
startData: {
|
||
destinationNode: 'OpenAI',
|
||
runNodeFilter: ['OpenAI'],
|
||
},
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: {} } }],
|
||
},
|
||
resultData: {
|
||
runData: {},
|
||
lastNodeExecuted: 'OpenAI',
|
||
error: new NodeApiError(
|
||
{
|
||
id: '1',
|
||
typeVersion: 1,
|
||
name: 'OpenAI',
|
||
type: 'n8n-nodes-base.openAi',
|
||
parameters: {},
|
||
position: [100, 200],
|
||
},
|
||
{
|
||
message: `400 - ${JSON.stringify({
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 200,
|
||
},
|
||
})}`,
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 200,
|
||
},
|
||
},
|
||
{
|
||
httpCode: '400',
|
||
},
|
||
),
|
||
},
|
||
},
|
||
} as unknown as IRun;
|
||
|
||
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
|
||
});
|
||
|
||
it('should return false if could not credential type it is not openAiApi', () => {
|
||
const runData = {
|
||
status: 'error',
|
||
mode: 'manual',
|
||
data: {
|
||
startData: {
|
||
destinationNode: 'OpenAI',
|
||
runNodeFilter: ['OpenAI'],
|
||
},
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: { jiraApi: { id: 'nhu-l8E4hX' } } } }],
|
||
},
|
||
resultData: {
|
||
runData: {},
|
||
lastNodeExecuted: 'OpenAI',
|
||
error: new NodeApiError(
|
||
{
|
||
id: '1',
|
||
typeVersion: 1,
|
||
name: 'OpenAI',
|
||
type: 'n8n-nodes-base.openAi',
|
||
parameters: {},
|
||
position: [100, 200],
|
||
},
|
||
{
|
||
message: `400 - ${JSON.stringify({
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 200,
|
||
},
|
||
})}`,
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 200,
|
||
},
|
||
},
|
||
{
|
||
httpCode: '400',
|
||
},
|
||
),
|
||
},
|
||
},
|
||
} as unknown as IRun;
|
||
|
||
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
|
||
});
|
||
|
||
it('should return false if error is not NodeApiError', () => {
|
||
const runData = {
|
||
status: 'error',
|
||
mode: 'manual',
|
||
data: {
|
||
startData: {
|
||
destinationNode: 'OpenAI',
|
||
runNodeFilter: ['OpenAI'],
|
||
},
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||
},
|
||
resultData: {
|
||
runData: {},
|
||
lastNodeExecuted: 'OpenAI',
|
||
error: new ExpressionError('error'),
|
||
},
|
||
},
|
||
} as unknown as IRun;
|
||
|
||
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
|
||
});
|
||
|
||
it('should return false if error is not a free ai credit error', () => {
|
||
const runData = {
|
||
status: 'error',
|
||
mode: 'manual',
|
||
data: {
|
||
startData: {
|
||
destinationNode: 'OpenAI',
|
||
runNodeFilter: ['OpenAI'],
|
||
},
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||
},
|
||
resultData: {
|
||
runData: {},
|
||
lastNodeExecuted: 'OpenAI',
|
||
error: new NodeApiError(
|
||
{
|
||
id: '1',
|
||
typeVersion: 1,
|
||
name: 'OpenAI',
|
||
type: 'n8n-nodes-base.openAi',
|
||
parameters: {},
|
||
position: [100, 200],
|
||
},
|
||
{
|
||
message: `400 - ${JSON.stringify({
|
||
error: {
|
||
message: 'error message',
|
||
type: 'error_type',
|
||
code: 200,
|
||
},
|
||
})}`,
|
||
error: {
|
||
message: 'error message',
|
||
type: 'error_type',
|
||
code: 200,
|
||
},
|
||
},
|
||
{
|
||
httpCode: '400',
|
||
},
|
||
),
|
||
},
|
||
},
|
||
} as unknown as IRun;
|
||
|
||
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(false);
|
||
});
|
||
|
||
it('should return true if the user has ran out of free AI credits', () => {
|
||
const runData = {
|
||
status: 'error',
|
||
mode: 'manual',
|
||
data: {
|
||
startData: {
|
||
destinationNode: 'OpenAI',
|
||
runNodeFilter: ['OpenAI'],
|
||
},
|
||
executionData: {
|
||
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
|
||
},
|
||
resultData: {
|
||
runData: {},
|
||
lastNodeExecuted: 'OpenAI',
|
||
error: new NodeApiError(
|
||
{
|
||
id: '1',
|
||
typeVersion: 1,
|
||
name: 'OpenAI',
|
||
type: 'n8n-nodes-base.openAi',
|
||
parameters: {},
|
||
position: [100, 200],
|
||
},
|
||
{
|
||
message: `400 - ${JSON.stringify({
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 400,
|
||
},
|
||
})}`,
|
||
error: {
|
||
message: 'error message',
|
||
type: 'free_ai_credits_request_error',
|
||
code: 400,
|
||
},
|
||
},
|
||
{
|
||
httpCode: '400',
|
||
},
|
||
),
|
||
},
|
||
},
|
||
} as unknown as IRun;
|
||
|
||
expect(userInInstanceRanOutOfFreeAiCredits(runData)).toBe(true);
|
||
});
|
||
});
|
||
|
||
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
|
||
const firstId = idMaker();
|
||
const secondId = idMaker();
|
||
const firstIdObscured = char.repeat(firstId.length);
|
||
const secondIdObscured = char.repeat(secondId.length);
|
||
|
||
return [
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}`,
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}/`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/`,
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users?id=${firstId}`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: '/api/v1/users',
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: '/api/v1/users',
|
||
},
|
||
{
|
||
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
|
||
protocolPlusDomain: 'https://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
|
||
},
|
||
];
|
||
}
|
||
|
||
function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) {
|
||
const firstId = idMaker();
|
||
const secondId = idMaker();
|
||
const firstIdObscured = char.repeat(firstId.length);
|
||
const secondIdObscured = char.repeat(secondId.length);
|
||
|
||
return [
|
||
{
|
||
full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`,
|
||
protocolPlusDomain: 'test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
|
||
},
|
||
{
|
||
full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
|
||
protocolPlusDomain: 'htp://test.com',
|
||
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
|
||
},
|
||
{
|
||
full: `test.com/api/v1/users?id=${firstId}`,
|
||
protocolPlusDomain: 'test.com',
|
||
pathname: '/api/v1/users',
|
||
},
|
||
{
|
||
full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`,
|
||
protocolPlusDomain: 'test.com',
|
||
pathname: '/api/v1/users',
|
||
},
|
||
];
|
||
}
|
||
|
||
const email = () => encodeURIComponent('test@test.com');
|
||
|
||
function uuidUrls(
|
||
urlsMaker: typeof validUrls | typeof malformedUrls,
|
||
baseName = 'test',
|
||
namespaceUuid = uuidv4(),
|
||
) {
|
||
return [
|
||
...urlsMaker(() => uuidv5(baseName, namespaceUuid)),
|
||
...urlsMaker(uuidv4),
|
||
...urlsMaker(() => uuidv3(baseName, namespaceUuid)),
|
||
...urlsMaker(uuidv1),
|
||
];
|
||
}
|
||
|
||
function numericId(length = randomInt(1, 10)) {
|
||
return Array.from({ length }, () => randomInt(10)).join('');
|
||
}
|
||
|
||
function alphanumericId() {
|
||
return chooseRandomly([`john${numericId()}`, `title${numericId(1)}`, numericId()]);
|
||
}
|
||
|
||
const chooseRandomly = <T>(array: T[]) => array[randomInt(array.length)];
|
||
|
||
function generateTestWorkflowAndRunData(): { workflow: Partial<IWorkflowBase>; runData: IRunData } {
|
||
const workflow: Partial<IWorkflowBase> = {
|
||
nodes: [
|
||
{
|
||
parameters: {},
|
||
id: 'a2372c14-87de-42de-9f9e-1c499aa2c279',
|
||
name: 'Execute Workflow Trigger',
|
||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||
typeVersion: 1,
|
||
position: [1000, 240],
|
||
},
|
||
{
|
||
parameters: {
|
||
options: {},
|
||
},
|
||
id: '0f7aa00e-248c-452c-8cd0-62cb55941633',
|
||
name: 'Edit Fields',
|
||
type: 'n8n-nodes-base.set',
|
||
typeVersion: 3.1,
|
||
position: [1460, 640],
|
||
},
|
||
{
|
||
parameters: {
|
||
options: {},
|
||
},
|
||
id: '9165c185-9f1c-4ec1-87bf-76ca66dfae38',
|
||
name: 'Edit Fields1',
|
||
type: 'n8n-nodes-base.set',
|
||
typeVersion: 3.4,
|
||
position: [1860, 260],
|
||
},
|
||
{
|
||
parameters: {
|
||
options: {},
|
||
},
|
||
id: '7a915fd5-5987-4ff1-9509-06b24a0a4613',
|
||
name: 'Edit Fields2',
|
||
type: 'n8n-nodes-base.set',
|
||
typeVersion: 3.4,
|
||
position: [1940, 680],
|
||
},
|
||
{
|
||
parameters: {
|
||
category: 'randomData',
|
||
randomDataSeed: '0',
|
||
randomDataCount: 3,
|
||
},
|
||
id: '63050e7c-8ad5-4f44-8fdd-da555e40471b',
|
||
name: 'DebugHelper',
|
||
type: 'n8n-nodes-base.debugHelper',
|
||
typeVersion: 1,
|
||
position: [1220, 240],
|
||
},
|
||
{
|
||
id: 'fbf7525d-2d1d-4dcf-97a0-43b53d087ef3',
|
||
name: 'Switch',
|
||
type: 'n8n-nodes-base.switch',
|
||
typeVersion: 3.2,
|
||
position: [1680, 640],
|
||
parameters: {},
|
||
},
|
||
],
|
||
connections: {
|
||
'Execute Workflow Trigger': {
|
||
main: [
|
||
[
|
||
{
|
||
node: 'Edit Fields',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
{
|
||
node: 'DebugHelper',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
'Edit Fields': {
|
||
main: [
|
||
[
|
||
{
|
||
node: 'Switch',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
DebugHelper: {
|
||
main: [
|
||
[
|
||
{
|
||
node: 'Edit Fields',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
Switch: {
|
||
main: [
|
||
null,
|
||
null,
|
||
[
|
||
{
|
||
node: 'Edit Fields1',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
],
|
||
[
|
||
{
|
||
node: 'Edit Fields2',
|
||
type: 'main' as NodeConnectionType,
|
||
index: 0,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
},
|
||
pinData: {},
|
||
};
|
||
|
||
const runData: IRunData = {
|
||
'Execute Workflow Trigger': [
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340927,
|
||
executionTime: 0,
|
||
executionIndex: 0,
|
||
source: [],
|
||
executionStatus: 'success',
|
||
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
|
||
},
|
||
],
|
||
DebugHelper: [
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340928,
|
||
executionTime: 0,
|
||
executionIndex: 1,
|
||
source: [{ previousNode: 'Execute Workflow Trigger' }],
|
||
executionStatus: 'success',
|
||
data: {
|
||
main: [
|
||
[
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 0 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 0 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 0 },
|
||
},
|
||
],
|
||
],
|
||
},
|
||
},
|
||
],
|
||
'Edit Fields': [
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340928,
|
||
executionTime: 1,
|
||
executionIndex: 2,
|
||
source: [{ previousNode: 'DebugHelper' }],
|
||
executionStatus: 'success',
|
||
data: {
|
||
main: [
|
||
[
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 0 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 1 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 2 },
|
||
},
|
||
],
|
||
],
|
||
},
|
||
},
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340931,
|
||
executionTime: 0,
|
||
executionIndex: 3,
|
||
source: [{ previousNode: 'Execute Workflow Trigger' }],
|
||
executionStatus: 'success',
|
||
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
|
||
},
|
||
],
|
||
Switch: [
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340929,
|
||
executionTime: 1,
|
||
executionIndex: 4,
|
||
source: [{ previousNode: 'Edit Fields' }],
|
||
executionStatus: 'success',
|
||
data: {
|
||
main: [
|
||
[],
|
||
[],
|
||
[
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 0 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 1 },
|
||
},
|
||
{
|
||
json: {
|
||
test: 'abc',
|
||
},
|
||
pairedItem: { item: 2 },
|
||
},
|
||
],
|
||
[],
|
||
],
|
||
},
|
||
},
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340931,
|
||
executionTime: 0,
|
||
executionIndex: 5,
|
||
source: [{ previousNode: 'Edit Fields', previousNodeRun: 1 }],
|
||
executionStatus: 'success',
|
||
data: { main: [[], [], [{ json: {}, pairedItem: { item: 0 } }], []] },
|
||
},
|
||
],
|
||
'Edit Fields1': [
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340930,
|
||
executionTime: 0,
|
||
executionIndex: 6,
|
||
source: [{ previousNode: 'Switch', previousNodeOutput: 2 }],
|
||
executionStatus: 'success',
|
||
data: {
|
||
main: [
|
||
[
|
||
{ json: {}, pairedItem: { item: 0 } },
|
||
{ json: {}, pairedItem: { item: 1 } },
|
||
{ json: {}, pairedItem: { item: 2 } },
|
||
],
|
||
],
|
||
},
|
||
},
|
||
{
|
||
hints: [],
|
||
startTime: 1727793340932,
|
||
executionTime: 1,
|
||
executionIndex: 7,
|
||
source: [{ previousNode: 'Switch', previousNodeOutput: 2, previousNodeRun: 1 }],
|
||
executionStatus: 'success',
|
||
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
|
||
},
|
||
],
|
||
};
|
||
|
||
return { workflow, runData };
|
||
}
|
||
|
||
describe('makeAIMetrics', () => {
|
||
const makeNode = (parameters: object, type: string) =>
|
||
({
|
||
parameters,
|
||
type,
|
||
typeVersion: 2.1,
|
||
id: '7cb0b373-715c-4a89-8bbb-3f238907bc86',
|
||
name: 'a name',
|
||
position: [0, 0],
|
||
}) as INode;
|
||
|
||
it('should count applicable nodes and parameters', async () => {
|
||
const nodes = [
|
||
makeNode(
|
||
{
|
||
sendTo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
|
||
sendTwo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
|
||
subject: "={{ $fromAI('Subject', ``, 'string') }}",
|
||
},
|
||
'n8n-nodes-base.gmailTool',
|
||
),
|
||
makeNode(
|
||
{
|
||
subject: "={{ $fromAI('Subject', ``, 'string') }}",
|
||
verb: "={{ $fromAI('Verb', ``, 'string') }}",
|
||
},
|
||
'n8n-nodes-base.gmailTool',
|
||
),
|
||
makeNode(
|
||
{
|
||
subject: "'A Subject'",
|
||
},
|
||
'n8n-nodes-base.gmailTool',
|
||
),
|
||
];
|
||
|
||
const nodeTypes = mock<NodeTypes>({
|
||
getByNameAndVersion: () => ({
|
||
description: {
|
||
codex: {
|
||
categories: ['AI'],
|
||
subcategories: { AI: ['Tools'] },
|
||
},
|
||
} as unknown as INodeTypeDescription,
|
||
}),
|
||
});
|
||
|
||
const result = resolveAIMetrics(nodes, nodeTypes);
|
||
expect(result).toMatchObject({
|
||
aiNodeCount: 3,
|
||
aiToolCount: 3,
|
||
fromAIOverrideCount: 2,
|
||
fromAIExpressionCount: 3,
|
||
});
|
||
});
|
||
|
||
it('should not count non-applicable nodes and parameters', async () => {
|
||
const nodes = [
|
||
makeNode(
|
||
{
|
||
sendTo: 'someone',
|
||
},
|
||
'n8n-nodes-base.gmail',
|
||
),
|
||
];
|
||
|
||
const nodeTypes = mock<NodeTypes>({
|
||
getByNameAndVersion: () => ({
|
||
description: {} as unknown as INodeTypeDescription,
|
||
}),
|
||
});
|
||
|
||
const result = resolveAIMetrics(nodes, nodeTypes);
|
||
expect(result).toMatchObject({});
|
||
});
|
||
|
||
it('should count ai nodes without tools', async () => {
|
||
const nodes = [
|
||
makeNode(
|
||
{
|
||
sendTo: 'someone',
|
||
},
|
||
'n8n-nodes-base.gmailTool',
|
||
),
|
||
];
|
||
|
||
const nodeTypes = mock<NodeTypes>({
|
||
getByNameAndVersion: () => ({
|
||
description: {
|
||
codex: {
|
||
categories: ['AI'],
|
||
},
|
||
} as unknown as INodeTypeDescription,
|
||
}),
|
||
});
|
||
|
||
const result = resolveAIMetrics(nodes, nodeTypes);
|
||
expect(result).toMatchObject({
|
||
aiNodeCount: 1,
|
||
aiToolCount: 0,
|
||
fromAIOverrideCount: 0,
|
||
fromAIExpressionCount: 0,
|
||
});
|
||
});
|
||
});
|