refactor(editor): Use typed-mocks to speed up tests and type-checking (no-changelog) (#9796)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-06-19 07:41:27 +02:00 committed by GitHub
parent 41e06be6fd
commit e3e20b48eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 444 additions and 8684 deletions

View file

@ -1,59 +1,4 @@
import type { INodeTypeData, INodeTypeDescription, IN8nUISettings } from 'n8n-workflow';
import {
AGENT_NODE_TYPE,
SET_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import nodeTypesJson from '../../../nodes-base/dist/types/nodes.json';
import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json';
const allNodeTypes = [...nodeTypesJson, ...aiNodeTypesJson];
export function findNodeTypeDescriptionByName(name: string): INodeTypeDescription {
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
}
export const testingNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeTypeDescriptionByName(MANUAL_TRIGGER_NODE_TYPE),
},
},
[SET_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeTypeDescriptionByName(SET_NODE_TYPE),
},
},
[CHAT_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeTypeDescriptionByName(CHAT_TRIGGER_NODE_TYPE),
},
},
[AGENT_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeTypeDescriptionByName(AGENT_NODE_TYPE),
},
},
};
export const defaultMockNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: testingNodeTypes[MANUAL_TRIGGER_NODE_TYPE],
[SET_NODE_TYPE]: testingNodeTypes[SET_NODE_TYPE],
};
export function mockNodeTypesToArray(nodeTypes: INodeTypeData): INodeTypeDescription[] {
return Object.values(nodeTypes).map(
(nodeType) => nodeType.type.description as INodeTypeDescription,
);
}
export const defaultMockNodeTypesArray: INodeTypeDescription[] =
mockNodeTypesToArray(defaultMockNodeTypes);
import type { IN8nUISettings } from 'n8n-workflow';
export const defaultSettings: IN8nUISettings = {
allowedModules: {},

View file

@ -2,48 +2,76 @@ import type {
INodeType,
INodeTypeData,
INodeTypes,
IVersionedNodeType,
IConnections,
IDataObject,
INode,
IPinData,
IWorkflowSettings,
LoadedClass,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeHelpers, Workflow } from 'n8n-workflow';
import { uuid } from '@jsplumb/util';
import { defaultMockNodeTypes } from '@/__tests__/defaults';
import type { INodeUi, ITag, IUsedCredential, IWorkflowDb, WorkflowMetadata } from '@/Interface';
import type { ProjectSharingData } from '@/types/projects.types';
import type { RouteLocationNormalized } from 'vue-router';
import { mock } from 'vitest-mock-extended';
export function createTestNodeTypes(data: INodeTypeData = {}): INodeTypes {
const nodeTypes = {
...defaultMockNodeTypes,
...Object.keys(data).reduce<INodeTypeData>((acc, key) => {
acc[key] = data[key];
import {
AGENT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
CODE_NODE_TYPE,
EXECUTABLE_TRIGGER_NODE_TYPES,
MANUAL_TRIGGER_NODE_TYPE,
NO_OP_NODE_TYPE,
SET_NODE_TYPE,
} from '@/constants';
const mockNode = (name: string, type: string, props: Partial<INode> = {}) =>
mock<INode>({ name, type, ...props });
const mockLoadedClass = (name: string) =>
mock<LoadedClass<INodeType>>({
type: mock<INodeType>({
// @ts-expect-error
description: mock<INodeTypeDescription>({
name,
displayName: name,
version: 1,
properties: [],
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
inputs: ['main'],
outputs: ['main'],
documentationUrl: 'https://docs',
webhooks: undefined,
}),
}),
});
export const mockNodes = [
mockNode('Manual Trigger', MANUAL_TRIGGER_NODE_TYPE),
mockNode('Set', SET_NODE_TYPE),
mockNode('Code', CODE_NODE_TYPE),
mockNode('Rename', SET_NODE_TYPE),
mockNode('Chat Trigger', CHAT_TRIGGER_NODE_TYPE),
mockNode('Agent', AGENT_NODE_TYPE),
mockNode('End', NO_OP_NODE_TYPE),
];
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
acc[type] = mockLoadedClass(type);
return acc;
}, {}),
};
}, {});
function getKnownTypes(): IDataObject {
return {};
}
export const defaultNodeDescriptions = Object.values(defaultNodeTypes).map(
({ type }) => type.description,
) as INodeTypeDescription[];
function getByName(nodeType: string): INodeType | IVersionedNodeType {
return nodeTypes[nodeType].type;
}
function getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(getByName(nodeType), version);
}
return {
getKnownTypes,
getByName,
getByNameAndVersion,
};
}
const nodeTypes = mock<INodeTypes>({
getByName(nodeType) {
return defaultNodeTypes[nodeType].type;
},
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(defaultNodeTypes[nodeType].type, version);
},
});
export function createTestWorkflowObject({
id = uuid(),
@ -51,7 +79,6 @@ export function createTestWorkflowObject({
nodes = [],
connections = {},
active = false,
nodeTypes = {},
staticData = {},
settings = {},
pinData = {},
@ -61,7 +88,6 @@ export function createTestWorkflowObject({
nodes?: INode[];
connections?: IConnections;
active?: boolean;
nodeTypes?: INodeTypeData;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
@ -75,38 +101,10 @@ export function createTestWorkflowObject({
staticData,
settings,
pinData,
nodeTypes: createTestNodeTypes(nodeTypes),
nodeTypes,
});
}
export function createTestWorkflow(options: {
id?: string;
name: string;
active?: boolean;
createdAt?: number | string;
updatedAt?: number | string;
nodes?: INodeUi[];
connections?: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[];
pinData?: IPinData;
sharedWithProjects?: ProjectSharingData[];
homeProject?: ProjectSharingData;
versionId?: string;
usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata;
}): IWorkflowDb {
return {
...options,
createdAt: options.createdAt ?? '',
updatedAt: options.updatedAt ?? '',
versionId: options.versionId ?? '',
id: options.id ?? uuid(),
active: options.active ?? false,
connections: options.connections ?? {},
} as IWorkflowDb;
}
export function createTestNode(node: Partial<INode> = {}): INode {
return {
id: uuid(),
@ -118,27 +116,3 @@ export function createTestNode(node: Partial<INode> = {}): INode {
...node,
};
}
export function createTestRouteLocation({
path = '',
params = {},
fullPath = path,
hash = '',
matched = [],
redirectedFrom = undefined,
name = path,
meta = {},
query = {},
}: Partial<RouteLocationNormalized> = {}): RouteLocationNormalized {
return {
path,
params,
fullPath,
hash,
matched,
redirectedFrom,
name,
meta,
query,
};
}

View file

@ -1,20 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mock } from 'vitest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import CredentialIcon from '@/components/CredentialIcon.vue';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import * as testNodeTypes from './testData/nodeTypesTestData';
import merge from 'lodash-es/merge';
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
const defaultState = {
import { createComponentRenderer } from '@/__tests__/render';
const twitterV1 = mock<INodeTypeDescription>({
version: 1,
credentials: [{ name: 'twitterOAuth1Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const twitterV2 = mock<INodeTypeDescription>({
version: 2,
credentials: [{ name: 'twitterOAuth2Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const nodeTypes = groupNodeTypesByNameAndType([twitterV1, twitterV2]);
const initialState = {
[STORES.CREDENTIALS]: {},
[STORES.NODE_TYPES]: {},
[STORES.NODE_TYPES]: { nodeTypes },
};
const renderComponent = createComponentRenderer(CredentialIcon, {
pinia: createTestingPinia({
initialState: defaultState,
}),
pinia: createTestingPinia({ initialState }),
global: {
stubs: ['n8n-tooltip'],
},
@ -25,17 +38,7 @@ describe('CredentialIcon', () => {
it('shows correct icon for credential type that is for the latest node type version', () => {
const { baseElement } = renderComponent({
pinia: createTestingPinia({
initialState: merge(defaultState, {
[STORES.CREDENTIALS]: {},
[STORES.NODE_TYPES]: {
nodeTypes: groupNodeTypesByNameAndType([
testNodeTypes.twitterV1,
testNodeTypes.twitterV2,
]),
},
}),
}),
pinia: createTestingPinia({ initialState }),
props: {
credentialTypeName: 'twitterOAuth2Api',
},
@ -49,17 +52,7 @@ describe('CredentialIcon', () => {
it('shows correct icon for credential type that is for an older node type version', () => {
const { baseElement } = renderComponent({
pinia: createTestingPinia({
initialState: merge(defaultState, {
[STORES.CREDENTIALS]: {},
[STORES.NODE_TYPES]: {
nodeTypes: groupNodeTypesByNameAndType([
testNodeTypes.twitterV1,
testNodeTypes.twitterV2,
]),
},
}),
}),
pinia: createTestingPinia({ initialState }),
props: {
credentialTypeName: 'twitterOAuth1Api',
},

View file

@ -1,24 +1,23 @@
import { createPinia, setActivePinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import { mock } from 'vitest-mock-extended';
import NodeDetailsView from '@/components/NodeDetailsView.vue';
import { VIEWS } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { uuid } from '@jsplumb/util';
import type { INode } from 'n8n-workflow';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import type { IWorkflowDb } from '@/Interface';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createPinia, setActivePinia } from 'pinia';
import { defaultMockNodeTypesArray } from '@/__tests__/defaults';
import { setupServer } from '@/__tests__/server';
async function createPiniaWithActiveNode(node: INode) {
const workflowId = uuid();
const workflow = createTestWorkflow({
id: workflowId,
name: 'Test Workflow',
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
async function createPiniaWithActiveNode() {
const node = mockNodes[0];
const workflow = mock<IWorkflowDb>({
connections: {},
active: true,
nodes: [node],
@ -31,7 +30,7 @@ async function createPiniaWithActiveNode(node: INode) {
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
nodeTypesStore.setNodeTypes(defaultMockNodeTypesArray);
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
ndvStore.activeNodeName = node.name;
@ -72,12 +71,7 @@ describe('NodeDetailsView', () => {
it('should render correctly', async () => {
const wrapper = renderComponent({
pinia: await createPiniaWithActiveNode(
createTestNode({
name: 'Manual Trigger',
type: 'manualTrigger',
}),
),
pinia: await createPiniaWithActiveNode(),
});
await waitFor(() =>

View file

@ -1,30 +1,22 @@
import WorkflowLMChatModal from '@/components/WorkflowLMChat.vue';
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { fireEvent, waitFor } from '@testing-library/vue';
import { uuid } from '@jsplumb/util';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { createPinia, setActivePinia } from 'pinia';
import { fireEvent, waitFor } from '@testing-library/vue';
import { mock } from 'vitest-mock-extended';
import { NodeConnectionType } from 'n8n-workflow';
import type { IConnections, INode } from 'n8n-workflow';
import WorkflowLMChatModal from '@/components/WorkflowLMChat.vue';
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { testingNodeTypes, mockNodeTypesToArray } from '@/__tests__/defaults';
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import { NodeConnectionType } from 'n8n-workflow';
import type { IConnections } from 'n8n-workflow';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
props: {
teleported: false,
appendToBody: false,
},
});
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
const { withConnections, withAgentNode } = options;
const workflowId = uuid();
const connections: IConnections = {
'Chat Trigger': {
main: [
@ -39,25 +31,23 @@ async function createPiniaWithAINodes(options = { withConnections: true, withAge
},
};
const workflow = createTestWorkflow({
id: workflowId,
name: 'Test Workflow',
active: true,
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
props: {
teleported: false,
appendToBody: false,
},
});
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
const { withConnections, withAgentNode } = options;
const chatTriggerNode = mockNodes[4];
const agentNode = mockNodes[5];
const nodes: INode[] = [chatTriggerNode];
if (withAgentNode) nodes.push(agentNode);
const workflow = mock<IWorkflowDb>({
nodes,
...(withConnections ? { connections } : {}),
nodes: [
createTestNode({
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
}),
...(withAgentNode
? [
createTestNode({
name: 'Agent',
type: AGENT_NODE_TYPE,
}),
]
: []),
],
});
const pinia = createPinia();
@ -67,12 +57,7 @@ async function createPiniaWithAINodes(options = { withConnections: true, withAge
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
nodeTypesStore.setNodeTypes(
mockNodeTypesToArray({
[CHAT_TRIGGER_NODE_TYPE]: testingNodeTypes[CHAT_TRIGGER_NODE_TYPE],
[AGENT_NODE_TYPE]: testingNodeTypes[AGENT_NODE_TYPE],
}),
);
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
await useSettingsStore().getSettings();

View file

@ -1,964 +0,0 @@
import type { INodeTypeDescription } from 'n8n-workflow';
export const twitterV2: INodeTypeDescription = {
displayName: 'X (Formerly Twitter)',
name: 'n8n-nodes-base.twitter',
group: ['output'],
subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}',
description: 'Post, like, and search tweets, send messages, search users, and add users to lists',
defaultVersion: 2,
version: 2,
defaults: { name: 'X' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'twitterOAuth2Api', required: true }],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Direct Message',
value: 'directMessage',
description: 'Send a direct message to a user',
},
{ name: 'List', value: 'list', description: 'Add a user to a list' },
{ name: 'Tweet', value: 'tweet', description: 'Create, like, search, or delete a tweet' },
{ name: 'User', value: 'user', description: 'Search users by username' },
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
],
default: 'tweet',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['directMessage'] } },
options: [
{
name: 'Create',
value: 'create',
description: 'Send a direct message to a user',
action: 'Create Direct Message',
},
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
],
default: 'create',
},
{
displayName: 'User',
name: 'user',
type: 'resourceLocator',
default: { mode: 'username', value: '' },
required: true,
description: 'The user you want to send the message to',
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
modes: [
{
displayName: 'By Username',
name: 'username',
type: 'string',
validation: [],
placeholder: 'e.g. n8n',
url: '',
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1068479892537384960',
url: '',
},
],
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '',
typeOptions: { rows: 2 },
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
description:
'The text of the direct message. URL encoding is required. Max length of 10,000 characters.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
options: [
{
displayName: 'Attachment ID',
name: 'attachments',
type: 'string',
default: '',
placeholder: '1664279886239010824',
description: 'The attachment ID to associate with the message',
},
],
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['list'] } },
options: [
{
name: 'Add Member',
value: 'add',
description: 'Add a member to a list',
action: 'Add Member to List',
},
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
],
default: 'add',
},
{
displayName: 'List',
name: 'list',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
required: true,
description: 'The list you want to add the user to',
displayOptions: { show: { operation: ['add'], resource: ['list'] } },
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 99923132',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/i/lists/99923132',
url: '',
},
],
},
{
displayName: 'User',
name: 'user',
type: 'resourceLocator',
default: { mode: 'username', value: '' },
required: true,
description: 'The user you want to add to the list',
displayOptions: { show: { operation: ['add'], resource: ['list'] } },
modes: [
{
displayName: 'By Username',
name: 'username',
type: 'string',
validation: [],
placeholder: 'e.g. n8n',
url: '',
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1068479892537384960',
url: '',
},
],
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['tweet'] } },
options: [
{
name: 'Create',
value: 'create',
description: 'Create, quote, or reply to a tweet',
action: 'Create Tweet',
},
{ name: 'Delete', value: 'delete', description: 'Delete a tweet', action: 'Delete Tweet' },
{ name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like Tweet' },
{
name: 'Retweet',
value: 'retweet',
description: 'Retweet a tweet',
action: 'Retweet Tweet',
},
{
name: 'Search',
value: 'search',
description: 'Search for tweets from the last seven days',
action: 'Search Tweets',
},
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
],
default: 'create',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
typeOptions: { rows: 2 },
default: '',
required: true,
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
description:
'The text of the status update. URLs must be encoded. Links wrapped with the t.co shortener will affect character count',
},
{
displayName: 'Options',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
options: [
{
displayName: 'Location ID',
name: 'location',
type: 'string',
placeholder: '4e696bef7e24d378',
default: '',
description: 'Location information for the tweet',
},
{
displayName: 'Media ID',
name: 'attachments',
type: 'string',
default: '',
placeholder: '1664279886239010824',
description: 'The attachment ID to associate with the message',
},
{
displayName: 'Quote a Tweet',
name: 'inQuoteToStatusId',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
description: 'The tweet being quoted',
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1187836157394112513',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
url: '',
},
],
},
{
displayName: 'Reply to Tweet',
name: 'inReplyToStatusId',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
description: 'The tweet being replied to',
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1187836157394112513',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
url: '',
},
],
},
],
},
{
displayName: 'Locations are not supported due to Twitter V2 API limitations',
name: 'noticeLocation',
type: 'notice',
displayOptions: { show: { '/additionalFields.location': [''] } },
default: '',
},
{
displayName: 'Attachements are not supported due to Twitter V2 API limitations',
name: 'noticeAttachments',
type: 'notice',
displayOptions: { show: { '/additionalFields.attachments': [''] } },
default: '',
},
{
displayName: 'Tweet',
name: 'tweetDeleteId',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
required: true,
description: 'The tweet to delete',
displayOptions: { show: { resource: ['tweet'], operation: ['delete'] } },
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1187836157394112513',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
url: '',
},
],
},
{
displayName: 'Tweet',
name: 'tweetId',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
required: true,
description: 'The tweet to like',
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1187836157394112513',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
url: '',
},
],
},
{
displayName: 'Search Term',
name: 'searchText',
type: 'string',
required: true,
default: '',
placeholder: 'e.g. automation',
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
description:
'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples <a href="https://developer.twitter.com/en/docs/tweets/search/guides/standard-operators">here</a>.',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
displayOptions: { show: { resource: ['tweet'], operation: ['search'] } },
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 50,
description: 'Max number of results to return',
typeOptions: { minValue: 1 },
displayOptions: { show: { resource: ['tweet'], operation: ['search'], returnAll: [false] } },
},
{
displayName: 'Options',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
options: [
{
displayName: 'Sort Order',
name: 'sortOrder',
type: 'options',
options: [
{ name: 'Recent', value: 'recency' },
{ name: 'Relevant', value: 'relevancy' },
],
description: 'The order in which to return results',
default: 'recency',
},
{
displayName: 'After',
name: 'startTime',
type: 'dateTime',
default: '',
description:
"Tweets before this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.",
},
{
displayName: 'Before',
name: 'endTime',
type: 'dateTime',
default: '',
description:
"Tweets after this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.",
},
{
displayName: 'Tweet Fields',
name: 'tweetFieldsObject',
type: 'multiOptions',
options: [
{ name: 'Attachments', value: 'attachments' },
{ name: 'Author ID', value: 'author_id' },
{ name: 'Context Annotations', value: 'context_annotations' },
{ name: 'Conversation ID', value: 'conversation_id' },
{ name: 'Created At', value: 'created_at' },
{ name: 'Edit Controls', value: 'edit_controls' },
{ name: 'Entities', value: 'entities' },
{ name: 'Geo', value: 'geo' },
{ name: 'ID', value: 'id' },
{ name: 'In Reply To User ID', value: 'in_reply_to_user_id' },
{ name: 'Lang', value: 'lang' },
{ name: 'Non Public Metrics', value: 'non_public_metrics' },
{ name: 'Public Metrics', value: 'public_metrics' },
{ name: 'Organic Metrics', value: 'organic_metrics' },
{ name: 'Promoted Metrics', value: 'promoted_metrics' },
{ name: 'Possibly Sensitive', value: 'possibly_sensitive' },
{ name: 'Referenced Tweets', value: 'referenced_tweets' },
{ name: 'Reply Settings', value: 'reply_settings' },
{ name: 'Source', value: 'source' },
{ name: 'Text', value: 'text' },
{ name: 'Withheld', value: 'withheld' },
],
default: [],
description:
'The fields to add to each returned tweet object. Default fields are: ID, text, edit_history_tweet_ids.',
},
],
},
{
displayName: 'Tweet',
name: 'tweetId',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
required: true,
description: 'The tweet to retweet',
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
modes: [
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1187836157394112513',
url: '',
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
validation: [],
placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513',
url: '',
},
],
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['user'] } },
options: [
{
name: 'Get',
value: 'searchUser',
description: 'Retrieve a user by username',
action: 'Get User',
},
{ name: 'Custom API Call', value: '__CUSTOM_API_CALL__' },
],
default: 'searchUser',
},
{
displayName: 'User',
name: 'user',
type: 'resourceLocator',
default: { mode: 'username', value: '' },
required: true,
description: 'The user you want to search',
displayOptions: {
show: { operation: ['searchUser'], resource: ['user'] },
hide: { me: [true] },
},
modes: [
{
displayName: 'By Username',
name: 'username',
type: 'string',
validation: [],
placeholder: 'e.g. n8n',
url: '',
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [],
placeholder: 'e.g. 1068479892537384960',
url: '',
},
],
},
{
displayName: 'Me',
name: 'me',
type: 'boolean',
displayOptions: { show: { operation: ['searchUser'], resource: ['user'] } },
default: false,
description: 'Whether you want to search the authenticated user',
},
],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
codex: {
categories: ['Marketing & Content'],
resources: {
primaryDocumentation: [
{ url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.twitter/' },
],
credentialDocumentation: [{ url: 'https://docs.n8n.io/credentials/twitter' }],
},
alias: ['Tweet', 'Twitter', 'X', 'X API'],
},
};
export const twitterV1: INodeTypeDescription = {
displayName: 'X (Formerly Twitter)',
name: 'n8n-nodes-base.twitter',
group: ['output'],
subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}',
description: 'Consume Twitter API',
defaultVersion: 2,
version: 1,
defaults: { name: 'Twitter' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'twitterOAuth1Api', required: true }],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Direct Message', value: 'directMessage' },
{ name: 'Tweet', value: 'tweet' },
],
default: 'tweet',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['directMessage'] } },
options: [
{
name: 'Create',
value: 'create',
description: 'Create a direct message',
action: 'Create a direct message',
},
],
default: 'create',
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
description: 'The ID of the user who should receive the direct message',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
description:
'The text of your Direct Message. URL encode as necessary. Max length of 10,000 characters.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['create'], resource: ['directMessage'] } },
options: [
{
displayName: 'Attachment',
name: 'attachment',
type: 'string',
default: 'data',
description:
'Name of the binary property which contain data that should be added to the direct message as attachment',
},
],
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['tweet'] } },
options: [
{
name: 'Create',
value: 'create',
description: 'Create or reply a tweet',
action: 'Create a tweet',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a tweet',
action: 'Delete a tweet',
},
{ name: 'Like', value: 'like', description: 'Like a tweet', action: 'Like a tweet' },
{
name: 'Retweet',
value: 'retweet',
description: 'Retweet a tweet',
action: 'Retweet a tweet',
},
{
name: 'Search',
value: 'search',
description: 'Search tweets',
action: 'Search for tweets',
},
],
default: 'create',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
description:
'The text of the status update. URL encode as necessary. t.co link wrapping will affect character counts.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['create'], resource: ['tweet'] } },
options: [
{
displayName: 'Attachments',
name: 'attachments',
type: 'string',
default: 'data',
description:
'Name of the binary properties which contain data which should be added to tweet as attachment. Multiple ones can be comma-separated.',
},
{
displayName: 'Display Coordinates',
name: 'displayCoordinates',
type: 'boolean',
default: false,
description:
'Whether or not to put a pin on the exact coordinates a Tweet has been sent from',
},
{
displayName: 'In Reply to Tweet',
name: 'inReplyToStatusId',
type: 'string',
default: '',
description: 'The ID of an existing status that the update is in reply to',
},
{
displayName: 'Location',
name: 'locationFieldsUi',
type: 'fixedCollection',
placeholder: 'Add Location',
default: {},
description: 'Subscriber location information.n',
options: [
{
name: 'locationFieldsValues',
displayName: 'Location',
values: [
{
displayName: 'Latitude',
name: 'latitude',
type: 'string',
required: true,
description: 'The location latitude',
default: '',
},
{
displayName: 'Longitude',
name: 'longitude',
type: 'string',
required: true,
description: 'The location longitude',
default: '',
},
],
},
],
},
{
displayName: 'Possibly Sensitive',
name: 'possiblySensitive',
type: 'boolean',
default: false,
description:
'Whether you are uploading Tweet media that might be considered sensitive content such as nudity, or medical procedures',
},
],
},
{
displayName: 'Tweet ID',
name: 'tweetId',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['delete'], resource: ['tweet'] } },
description: 'The ID of the tweet to delete',
},
{
displayName: 'Search Text',
name: 'searchText',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
description:
'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples <a href="https://developer.twitter.com/en/docs/tweets/search/guides/standard-operators">here</a>.',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: { show: { operation: ['search'], resource: ['tweet'], returnAll: [false] } },
typeOptions: { minValue: 1 },
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['search'], resource: ['tweet'] } },
options: [
{
displayName: 'Include Entities',
name: 'includeEntities',
type: 'boolean',
default: false,
description: 'Whether the entities node will be included',
},
{
displayName: 'Language Name or ID',
name: 'lang',
type: 'options',
typeOptions: { loadOptionsMethod: 'getLanguages' },
default: '',
description:
'Restricts tweets to the given language, given by an ISO 639-1 code. Language detection is best-effort. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
{
displayName: 'Location',
name: 'locationFieldsUi',
type: 'fixedCollection',
placeholder: 'Add Location',
default: {},
description: 'Subscriber location information.n',
options: [
{
name: 'locationFieldsValues',
displayName: 'Location',
values: [
{
displayName: 'Latitude',
name: 'latitude',
type: 'string',
required: true,
description: 'The location latitude',
default: '',
},
{
displayName: 'Longitude',
name: 'longitude',
type: 'string',
required: true,
description: 'The location longitude',
default: '',
},
{
displayName: 'Radius',
name: 'radius',
type: 'options',
options: [
{ name: 'Milles', value: 'mi' },
{ name: 'Kilometers', value: 'km' },
],
required: true,
description:
'Returns tweets by users located within a given radius of the given latitude/longitude',
default: '',
},
{
displayName: 'Distance',
name: 'distance',
type: 'number',
typeOptions: { minValue: 0 },
required: true,
default: '',
},
],
},
],
},
{
displayName: 'Result Type',
name: 'resultType',
type: 'options',
options: [
{
name: 'Mixed',
value: 'mixed',
description: 'Include both popular and real time results in the response',
},
{
name: 'Recent',
value: 'recent',
description: 'Return only the most recent results in the response',
},
{
name: 'Popular',
value: 'popular',
description: 'Return only the most popular results in the response',
},
],
default: 'mixed',
description: 'Specifies what type of search results you would prefer to receive',
},
{
displayName: 'Tweet Mode',
name: 'tweetMode',
type: 'options',
options: [
{ name: 'Compatibility', value: 'compat' },
{ name: 'Extended', value: 'extended' },
],
default: 'compat',
description:
'When the extended mode is selected, the response contains the entire untruncated text of the Tweet',
},
{
displayName: 'Until',
name: 'until',
type: 'dateTime',
default: '',
description: 'Returns tweets created before the given date',
},
],
},
{
displayName: 'Tweet ID',
name: 'tweetId',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
description: 'The ID of the tweet',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['like'], resource: ['tweet'] } },
options: [
{
displayName: 'Include Entities',
name: 'includeEntities',
type: 'boolean',
default: false,
description: 'Whether the entities will be omitted',
},
],
},
{
displayName: 'Tweet ID',
name: 'tweetId',
type: 'string',
required: true,
default: '',
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
description: 'The ID of the tweet',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: { show: { operation: ['retweet'], resource: ['tweet'] } },
options: [
{
displayName: 'Trim User',
name: 'trimUser',
type: 'boolean',
default: false,
description:
'Whether each tweet returned in a timeline will include a user object including only the status authors numerical ID',
},
],
},
],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
};

View file

@ -1,13 +1,12 @@
import { computed } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { useActiveNode } from '@/composables/useActiveNode';
import { useNodeType } from '@/composables/useNodeType';
import { createTestNode } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import { computed } from 'vue';
import { defaultMockNodeTypes } from '@/__tests__/defaults';
const node = computed(() => createTestNode({ name: 'Node', type: MANUAL_TRIGGER_NODE_TYPE }));
const nodeType = computed(() => defaultMockNodeTypes[MANUAL_TRIGGER_NODE_TYPE]);
const node = computed(() => mock());
const nodeType = computed(() => mock());
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({

View file

@ -1,11 +1,13 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
import type { IConnections, Workflow } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { NodeConnectionType } from 'n8n-workflow';
import type { Workflow } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
@ -30,11 +32,8 @@ afterEach(() => {
describe('useCanvasMapping', () => {
it('should initialize with default props', () => {
const workflow = createTestWorkflow({
id: '1',
name: 'Test Workflow',
const workflow = mock<IWorkflowDb>({
nodes: [],
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
@ -49,14 +48,9 @@ describe('useCanvasMapping', () => {
describe('elements', () => {
it('should map nodes to canvas elements', () => {
const node = createTestNode({
name: 'Node',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [node],
connections: {},
const manualTriggerNode = mockNodes[0];
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
const workflowObject = createTestWorkflowObject(workflow);
@ -67,14 +61,14 @@ describe('useCanvasMapping', () => {
expect(elements.value).toEqual([
{
id: node.id,
label: node.name,
id: manualTriggerNode.id,
label: manualTriggerNode.name,
type: 'canvas-node',
position: { x: 0, y: 0 },
position: expect.anything(),
data: {
id: node.id,
type: node.type,
typeVersion: 1,
id: manualTriggerNode.id,
type: manualTriggerNode.type,
typeVersion: expect.anything(),
inputs: [],
outputs: [],
renderType: 'default',
@ -86,24 +80,16 @@ describe('useCanvasMapping', () => {
describe('connections', () => {
it('should map connections to canvas connections', () => {
const nodeA = createTestNode({
name: 'Node A',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const nodeB = createTestNode({
name: 'Node B',
type: SET_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [nodeA, nodeB],
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode, setNode],
connections: {
[nodeA.name]: {
[manualTriggerNode.name]: {
[NodeConnectionType.Main]: [
[{ node: nodeB.name, type: NodeConnectionType.Main, index: 0 }],
[{ node: setNode.name, type: NodeConnectionType.Main, index: 0 }],
],
},
} as IConnections,
},
});
const workflowObject = createTestWorkflowObject(workflow);
@ -115,7 +101,7 @@ describe('useCanvasMapping', () => {
expect(connections.value).toEqual([
{
data: {
fromNodeName: nodeA.name,
fromNodeName: manualTriggerNode.name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -125,11 +111,11 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.Main,
},
},
id: `[${nodeA.id}/${NodeConnectionType.Main}/0][${nodeB.id}/${NodeConnectionType.Main}/0]`,
id: `[${manualTriggerNode.id}/${NodeConnectionType.Main}/0][${setNode.id}/${NodeConnectionType.Main}/0]`,
label: '',
source: nodeA.id,
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
target: nodeB.id,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
type: 'canvas-edge',
},
@ -137,24 +123,16 @@ describe('useCanvasMapping', () => {
});
it('should map multiple input types to canvas connections', () => {
const nodeA = createTestNode({
name: 'Node A',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const nodeB = createTestNode({
name: 'Node B',
type: SET_NODE_TYPE,
});
const workflow = createTestWorkflow({
name: 'Test Workflow',
nodes: [nodeA, nodeB],
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode, setNode],
connections: {
'Node A': {
[manualTriggerNode.name]: {
[NodeConnectionType.AiTool]: [
[{ node: nodeB.name, type: NodeConnectionType.AiTool, index: 0 }],
[{ node: setNode.name, type: NodeConnectionType.AiTool, index: 0 }],
],
[NodeConnectionType.AiDocument]: [
[{ node: nodeB.name, type: NodeConnectionType.AiDocument, index: 1 }],
[{ node: setNode.name, type: NodeConnectionType.AiDocument, index: 1 }],
],
},
},
@ -169,7 +147,7 @@ describe('useCanvasMapping', () => {
expect(connections.value).toEqual([
{
data: {
fromNodeName: nodeA.name,
fromNodeName: manualTriggerNode.name,
source: {
index: 0,
type: NodeConnectionType.AiTool,
@ -179,17 +157,17 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.AiTool,
},
},
id: `[${nodeA.id}/${NodeConnectionType.AiTool}/0][${nodeB.id}/${NodeConnectionType.AiTool}/0]`,
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiTool}/0][${setNode.id}/${NodeConnectionType.AiTool}/0]`,
label: '',
source: nodeA.id,
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`,
target: nodeB.id,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
type: 'canvas-edge',
},
{
data: {
fromNodeName: nodeA.name,
fromNodeName: manualTriggerNode.name,
source: {
index: 0,
type: NodeConnectionType.AiDocument,
@ -199,11 +177,11 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.AiDocument,
},
},
id: `[${nodeA.id}/${NodeConnectionType.AiDocument}/0][${nodeB.id}/${NodeConnectionType.AiDocument}/1]`,
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiDocument}/0][${setNode.id}/${NodeConnectionType.AiDocument}/1]`,
label: '',
source: nodeA.id,
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`,
target: nodeB.id,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
type: 'canvas-edge',
},

View file

@ -1,3 +1,8 @@
import { createPinia, setActivePinia } from 'pinia';
import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types';
import type { INodeUi } from '@/Interface';
@ -5,12 +10,8 @@ import { RemoveNodeCommand } from '@/models/history';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useHistoryStore } from '@/stores/history.store';
import { createPinia, setActivePinia } from 'pinia';
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
describe('useCanvasOperations', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@ -255,23 +256,6 @@ describe('useCanvasOperations', () => {
expect(uiStore.stateIsDirty).toBe(false);
});
// @TODO Implement once the isConnectionAllowed method is implemented
it.skip('should not create a connection if connection is not allowed', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'sourceNode', target: 'targetNode' };
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(createTestNode())
.mockReturnValueOnce(createTestNode());
canvasOperations.createConnection(connection);
expect(addConnectionSpy).not.toHaveBeenCalled();
expect(uiStore.stateIsDirty).toBe(false);
});
it('should create a connection if source and target nodes exist and connection is allowed', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')

View file

@ -1,43 +1,39 @@
import { useNodeBase } from '@/composables/useNodeBase';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import { createPinia, setActivePinia } from 'pinia';
import { mock, mockClear } from 'vitest-mock-extended';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
import { useNodeBase } from '@/composables/useNodeBase';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { findNodeTypeDescriptionByName } from '@/__tests__/defaults';
import { SET_NODE_TYPE } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { Mock } from '@vitest/spy';
describe('useNodeBase', () => {
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let instance: Record<string, Mock>;
let workflowObject: Workflow;
let emit: (event: string, ...args: unknown[]) => void;
let node: INodeUi;
let nodeTypeDescription: INodeTypeDescription;
let nodeBase: ReturnType<typeof useNodeBase>;
const jsPlumbInstance = mock<BrowserJsPlumbInstance>();
const nodeTypeDescription = mock<INodeTypeDescription>({
inputs: ['main'],
outputs: ['main'],
});
const workflowObject = mock<Workflow>();
const node = mock<INode>();
beforeEach(() => {
mockClear(jsPlumbInstance);
pinia = createPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
uiStore = useUIStore();
instance = {
addEndpoint: vi.fn().mockReturnValue({}),
};
workflowObject = createTestWorkflowObject({ nodes: [], connections: {} });
node = createTestNode();
nodeTypeDescription = findNodeTypeDescriptionByName(SET_NODE_TYPE);
emit = vi.fn();
nodeBase = useNodeBase({
instance: instance as unknown as BrowserJsPlumbInstance,
instance: jsPlumbInstance,
name: node.name,
workflowObject,
isReadOnly: false,
@ -54,83 +50,89 @@ describe('useNodeBase', () => {
describe('addInputEndpoints', () => {
it('should add input endpoints correctly', () => {
const { addInputEndpoints } = nodeBase;
jsPlumbInstance.addEndpoint.mockReturnValue(mock());
vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
addInputEndpoints(node, nodeTypeDescription);
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
nodeBase.addInputEndpoints(node, nodeTypeDescription);
expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name);
expect(addEndpointCall.anchor).toEqual([0.01, 0.5, -1, 0]);
expect(addEndpointCall.cssClass).toEqual('rect-input-endpoint');
expect(addEndpointCall.dragAllowedWhenFull).toEqual(true);
expect(addEndpointCall.enabled).toEqual(true);
expect(addEndpointCall.endpoint).toEqual('Rectangle');
expect(addEndpointCall.hoverClass).toEqual('rect-input-endpoint-hover');
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
fill: 'var(--color-primary)',
height: 20,
lineWidth: 0,
stroke: 'var(--color-primary)',
expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, {
anchor: [0.01, 0.5, -1, 0],
maxConnections: -1,
endpoint: 'Rectangle',
paintStyle: {
width: 8,
});
expect(addEndpointCall.maxConnections).toEqual(-1);
expect(addEndpointCall.paintStyle).toMatchObject({
height: 20,
fill: 'var(--node-type-main-color)',
height: 20,
lineWidth: 0,
stroke: 'var(--node-type-main-color)',
lineWidth: 0,
},
hoverPaintStyle: {
width: 8,
});
expect(addEndpointCall.parameters).toMatchObject({
height: 20,
fill: 'var(--color-primary)',
stroke: 'var(--color-primary)',
lineWidth: 0,
},
source: false,
target: false,
parameters: {
connection: 'target',
index: 0,
nodeId: node.id,
type: 'main',
index: 0,
},
enabled: true,
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
hoverClass: 'rect-input-endpoint-hover',
uuid: `${node.id}-input0`,
});
expect(addEndpointCall.scope).toBeUndefined();
expect(addEndpointCall.source).toBeFalsy();
expect(addEndpointCall.target).toBeFalsy();
});
});
describe('addOutputEndpoints', () => {
it('should add output endpoints correctly', () => {
const { addOutputEndpoints } = nodeBase;
const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
addOutputEndpoints(node, nodeTypeDescription);
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
nodeBase.addOutputEndpoints(node, nodeTypeDescription);
expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name);
expect(addEndpointCall.anchor).toEqual([0.99, 0.5, 1, 0]);
expect(addEndpointCall.cssClass).toEqual('dot-output-endpoint');
expect(addEndpointCall.dragAllowedWhenFull).toEqual(false);
expect(addEndpointCall.enabled).toEqual(true);
expect(addEndpointCall.endpoint).toEqual({
expect(jsPlumbInstance.addEndpoint).toHaveBeenCalledWith(undefined, {
anchor: [0.99, 0.5, 1, 0],
connectionsDirected: true,
cssClass: 'dot-output-endpoint',
dragAllowedWhenFull: false,
enabled: true,
endpoint: {
options: {
radius: 9,
},
type: 'Dot',
});
expect(addEndpointCall.hoverClass).toEqual('dot-output-endpoint-hover');
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
},
hoverClass: 'dot-output-endpoint-hover',
hoverPaintStyle: {
fill: 'var(--color-primary)',
});
expect(addEndpointCall.maxConnections).toEqual(-1);
expect(addEndpointCall.paintStyle).toMatchObject({
outlineStroke: 'none',
strokeWidth: 9,
},
maxConnections: -1,
paintStyle: {
fill: 'var(--node-type-main-color)',
});
expect(addEndpointCall.parameters).toMatchObject({
outlineStroke: 'none',
strokeWidth: 9,
},
parameters: {
connection: 'source',
index: 0,
nodeId: node.id,
type: 'main',
},
scope: undefined,
source: true,
target: false,
uuid: `${node.id}-output0`,
});
expect(addEndpointCall.scope).toBeUndefined();
expect(addEndpointCall.source).toBeTruthy();
expect(addEndpointCall.target).toBeFalsy();
});
});

View file

@ -1,14 +1,15 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useRouter } from 'vue-router';
import type router from 'vue-router';
import { ExpressionError, type IPinData, type IRunData, type Workflow } from 'n8n-workflow';
import { useRootStore } from '@/stores/root.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { IStartRunData, IWorkflowData } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { ExpressionError, type IPinData, type IRunData, type Workflow } from 'n8n-workflow';
import type * as router from 'vue-router';
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({

View file

@ -231,7 +231,7 @@ export function useNodeBase({
const endpoint = instance?.addEndpoint(
refs.value[data.value?.name ?? ''] as Element,
newEndpointData,
) as Endpoint;
);
addEndpointTestingData(endpoint, 'input', typeIndex);
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
// Apply input names if they got set

View file

@ -12,7 +12,7 @@ import {
natives,
} from '@/plugins/codemirror/completions/datatype.completions';
import { mockNodes, mockProxy } from './mock';
import { mockProxy } from './mock';
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
@ -30,6 +30,7 @@ import {
STRING_RECOMMENDED_OPTIONS,
} from '../constants';
import { set, uniqBy } from 'lodash-es';
import { mockNodes } from '@/__tests__/mocks';
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
let uiStore: ReturnType<typeof useUIStore>;

View file

@ -1,126 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import type {
INode,
IConnections,
IRunExecutionData,
IExecuteData,
INodeTypeData,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { INode, IRunExecutionData, IExecuteData } from 'n8n-workflow';
import { WorkflowDataProxy } from 'n8n-workflow';
import { createTestWorkflowObject } from '@/__tests__/mocks';
const nodeTypes: INodeTypeData = {
'n8n-nodes-base.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'n8n-nodes-base.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 nodes: INode[] = [
{
name: 'Start',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
id: 'uuid-1',
position: [100, 200],
},
{
name: 'Function',
type: 'n8n-nodes-base.set',
parameters: {
functionCode:
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
},
typeVersion: 1,
id: 'uuid-2',
position: [280, 200],
},
{
name: 'Rename',
type: 'n8n-nodes-base.set',
parameters: {
value1: 'data',
value2: 'initialName',
},
typeVersion: 1,
id: 'uuid-3',
position: [460, 200],
},
{
name: 'End',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
id: 'uuid-4',
position: [640, 200],
},
];
const connections: IConnections = {
Start: {
main: [
[
{
node: 'Function',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
Function: {
main: [
[
{
node: 'Rename',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
Rename: {
main: [
[
{
node: 'End',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
};
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
import { mock } from 'vitest-mock-extended';
const runExecutionData: IRunExecutionData = {
resultData: {
@ -265,20 +146,19 @@ const runExecutionData: IRunExecutionData = {
const workflow = createTestWorkflowObject({
id: '123',
name: 'test workflow',
nodes,
connections,
nodes: mockNodes,
connections: mock(),
active: false,
nodeTypes,
});
const lastNodeName = 'End';
const lastNodeName = mockNodes[mockNodes.length - 1].name;
const lastNodeConnectionInputData =
runExecutionData.resultData.runData[lastNodeName][0].data!.main[0];
const executeData: IExecuteData = {
data: runExecutionData.resultData.runData[lastNodeName][0].data!,
node: nodes.find((node) => node.name === lastNodeName) as INode,
node: mockNodes.find((node) => node.name === lastNodeName) as INode,
source: {
main: runExecutionData.resultData.runData[lastNodeName][0].source,
},
@ -298,20 +178,3 @@ const dataProxy = new WorkflowDataProxy(
);
export const mockProxy = dataProxy.getDataProxy();
export const mockNodes = [
{
id: uuidv4(),
name: 'Manual',
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: 'Set',
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];

View file

@ -1,20 +1,21 @@
import { mock } from 'vitest-mock-extended';
import type { RouteLocationNormalized } from 'vue-router';
import {
inferProjectIdFromRoute,
inferResourceTypeFromRoute,
inferResourceIdFromRoute,
} from '../rbacUtils';
import { createTestRouteLocation } from '@/__tests__/mocks';
} from '@/utils/rbacUtils';
describe('rbacUtils', () => {
describe('inferProjectIdFromRoute()', () => {
it('should infer project ID from route correctly', () => {
const route = createTestRouteLocation({ path: '/dashboard/projects/123/settings' });
const route = mock<RouteLocationNormalized>({ path: '/dashboard/projects/123/settings' });
const projectId = inferProjectIdFromRoute(route);
expect(projectId).toBe('123');
});
it('should return undefined for project ID if not found', () => {
const route = createTestRouteLocation({ path: '/dashboard/settings' });
const route = mock<RouteLocationNormalized>({ path: '/dashboard/settings' });
const projectId = inferProjectIdFromRoute(route);
expect(projectId).toBeUndefined();
});
@ -31,13 +32,13 @@ describe('rbacUtils', () => {
['/source-control', 'sourceControl'],
['/external-secrets', 'externalSecret'],
])('should infer resource type from %s correctly to %s', (path, type) => {
const route = createTestRouteLocation({ path });
const route = mock<RouteLocationNormalized>({ path });
const resourceType = inferResourceTypeFromRoute(route);
expect(resourceType).toBe(type);
});
it('should return undefined for resource type if not found', () => {
const route = createTestRouteLocation({ path: '/dashboard/settings' });
const route = mock<RouteLocationNormalized>({ path: '/dashboard/settings' });
const resourceType = inferResourceTypeFromRoute(route);
expect(resourceType).toBeUndefined();
});
@ -45,19 +46,21 @@ describe('rbacUtils', () => {
describe('inferResourceIdFromRoute()', () => {
it('should infer resource ID from params.id', () => {
const route = createTestRouteLocation({ params: { id: 'abc123' } });
const route = mock<RouteLocationNormalized>({ params: { id: 'abc123' } });
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBe('abc123');
});
it('should infer resource ID from params.name if id is not present', () => {
const route = createTestRouteLocation({ params: { name: 'my-resource' } });
const route = mock<RouteLocationNormalized>();
route.params = { name: 'my-resource' };
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBe('my-resource');
});
it('should return undefined for resource ID if neither id nor name is present', () => {
const route = createTestRouteLocation({ params: {} });
const route = mock<RouteLocationNormalized>();
route.params = {};
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBeUndefined();
});

View file

@ -1,4 +1,11 @@
import type { Router } from 'vue-router';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { VIEWS } from '@/constants';
import type { ITemplatesWorkflowFull } from '@/Interface';
import { Telemetry } from '@/plugins/telemetry';
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -7,23 +14,33 @@ import { usePostHog } from '@/stores/posthog.store';
import type { TemplatesStore } from '@/stores/templates.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useTemplateWorkflow } from '@/utils/templates/templateActions';
import {
nodeTypeRespondToWebhookV1,
nodeTypeShopifyTriggerV1,
nodeTypeTelegramV1,
nodeTypeTwitterV1,
nodeTypeWebhookV1,
nodeTypeWebhookV1_1,
nodeTypesSet,
} from '@/utils/testData/nodeTypeTestData';
import {
fullCreateApiEndpointTemplate,
fullShopifyTelegramTwitterTemplate,
} from '@/utils/testData/templateTestData';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { vi } from 'vitest';
import type { Router } from 'vue-router';
import { nodeTypeTelegram } from '@/utils/testData/nodeTypeTestData';
const testTemplate1 = mock<ITemplatesWorkflowFull>({
id: 1,
workflow: {
nodes: [],
},
full: true,
});
export const testTemplate2 = mock<ITemplatesWorkflowFull>({
id: 2,
workflow: {
nodes: [
{
name: 'Telegram',
type: 'n8n-nodes-base.telegram',
typeVersion: 1,
position: [0, 0],
credentials: {
telegramApi: 'telegram_habot',
},
},
],
},
full: true,
});
describe('templateActions', () => {
describe('useTemplateWorkflow', () => {
@ -80,16 +97,12 @@ describe('templateActions', () => {
});
describe('When feature flag is enabled and template has nodes requiring credentials', () => {
const templateId = fullShopifyTelegramTwitterTemplate.id.toString();
const templateId = testTemplate2.id.toString();
beforeEach(async () => {
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
templatesStore.addWorkflows([fullShopifyTelegramTwitterTemplate]);
nodeTypesStore.setNodeTypes([
nodeTypeTelegramV1,
nodeTypeTwitterV1,
nodeTypeShopifyTriggerV1,
]);
templatesStore.addWorkflows([testTemplate2]);
nodeTypesStore.setNodeTypes([nodeTypeTelegram]);
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
await useTemplateWorkflow({
@ -113,17 +126,11 @@ describe('templateActions', () => {
});
describe("When feature flag is enabled and template doesn't have nodes requiring credentials", () => {
const templateId = fullCreateApiEndpointTemplate.id.toString();
const templateId = testTemplate1.id.toString();
beforeEach(async () => {
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
templatesStore.addWorkflows([fullCreateApiEndpointTemplate]);
nodeTypesStore.setNodeTypes([
nodeTypeWebhookV1,
nodeTypeWebhookV1_1,
nodeTypeRespondToWebhookV1,
...Object.values(nodeTypesSet),
]);
templatesStore.addWorkflows([testTemplate1]);
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();
await useTemplateWorkflow({

View file

@ -1,8 +1,9 @@
import { mock } from 'vitest-mock-extended';
import type { IWorkflowTemplateNode } from '@/Interface';
import {
keyFromCredentialTypeAndName,
replaceAllTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
describe('templateTransforms', () => {
describe('replaceAllTemplateNodeCredentials', () => {
@ -10,7 +11,7 @@ describe('templateTransforms', () => {
const nodeTypeProvider = {
getNodeType: vitest.fn(),
};
const node = newWorkflowTemplateNode({
const node = mock<IWorkflowTemplateNode>({
id: 'twitter',
type: 'n8n-nodes-base.twitter',
credentials: {
@ -40,7 +41,7 @@ describe('templateTransforms', () => {
const nodeTypeProvider = {
getNodeType: vitest.fn(),
};
const node = newWorkflowTemplateNode({
const node = mock<IWorkflowTemplateNode>({
id: 'twitter',
type: 'n8n-nodes-base.twitter',
});

View file

@ -1,43 +0,0 @@
/**
* Credential type test data
*/
import type { ICredentialType } from 'n8n-workflow';
export const newCredentialType = (name: string): ICredentialType => ({
name,
displayName: name,
documentationUrl: name,
properties: [],
});
export const credentialTypeTelegram = {
name: 'telegramApi',
displayName: 'Telegram API',
documentationUrl: 'telegram',
properties: [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: {
password: true,
},
default: '',
description:
'Chat with the <a href="https://telegram.me/botfather">bot father</a> to obtain the access token',
},
{
displayName: 'Base URL',
name: 'baseUrl',
type: 'string',
default: 'https://api.telegram.org',
description: 'Base URL for Telegram Bot API',
},
],
test: {
request: {
baseURL: '={{$credentials.baseUrl}}/bot{{$credentials.accessToken}}',
url: '/getMe',
},
},
} satisfies ICredentialType;

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,71 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { mock } from 'vitest-mock-extended';
import type { ICredentialType } from 'n8n-workflow';
import type {
ICredentialsResponse,
ITemplatesWorkflowFull,
IWorkflowTemplateNode,
} from '@/Interface';
import { useTemplatesStore } from '@/stores/templates.store';
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { setActivePinia } from 'pinia';
import * as testData from './setupTemplate.store.testData';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import {
nodeTypeHttpRequest,
nodeTypeNextCloud,
nodeTypeReadImap,
nodeTypeTelegram,
nodeTypeTwitter,
} from '@/utils/testData/nodeTypeTestData';
import * as testData from './setupTemplate.store.testData';
const mockCredentialsResponse = (id: string) =>
mock<ICredentialsResponse>({
id,
name: 'Telegram account',
type: 'telegramApi',
});
const mockCredentialType = (name: string) => mock<ICredentialType>({ name });
const mockTemplateNode = (name: string, type: string) =>
mock<IWorkflowTemplateNode>({
name,
type,
typeVersion: 1,
position: [0, 0],
});
const testTemplate1 = mock<ITemplatesWorkflowFull>({
id: 1,
workflow: {
nodes: [
mockTemplateNode('IMAP Email', 'n8n-nodes-base.emailReadImap'),
mockTemplateNode('Nextcloud', 'n8n-nodes-base.nextCloud'),
],
},
full: true,
});
const testTemplate2 = mock<ITemplatesWorkflowFull>({
id: 2,
workflow: {
nodes: [
{
...mockTemplateNode('Telegram', 'n8n-nodes-base.telegram'),
credentials: {
telegramApi: 'telegram_habot',
},
},
],
},
full: true,
});
describe('SetupWorkflowFromTemplateView store', () => {
describe('setInitialCredentialsSelection', () => {
beforeEach(() => {
@ -17,18 +76,13 @@ describe('SetupWorkflowFromTemplateView store', () => {
);
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
credentialsStore.setCredentialTypes([mockCredentialType('telegramApi')]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
templatesStore.addWorkflows([testTemplate2]);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeTelegramV1,
testData.nodeTypeTwitterV1,
testData.nodeTypeShopifyTriggerV1,
testData.nodeTypeHttpRequestV1,
]);
nodeTypesStore.setNodeTypes([nodeTypeTelegram, nodeTypeTwitter, nodeTypeHttpRequest]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
setupTemplateStore.setTemplateId(testTemplate2.id.toString());
});
it("should select no credentials when there isn't any available", () => {
@ -43,8 +97,9 @@ describe('SetupWorkflowFromTemplateView store', () => {
it("should select credential when there's only one", () => {
// Setup
const credentialId = 'YaSKdvEcT1TSFrrr1';
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentials([testData.credentialsTelegram1]);
credentialsStore.setCredentials([mockCredentialsResponse(credentialId)]);
const setupTemplateStore = useSetupTemplateStore();
@ -52,17 +107,14 @@ describe('SetupWorkflowFromTemplateView store', () => {
setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({
[keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: 'YaSKdvEcT1TSFrrr1',
[keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: credentialId,
});
});
it('should select no credentials when there are more than 1 available', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentials([
testData.credentialsTelegram1,
testData.credentialsTelegram2,
]);
credentialsStore.setCredentials([mockCredentialsResponse('1'), mockCredentialsResponse('2')]);
const setupTemplateStore = useSetupTemplateStore();
@ -83,7 +135,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
])('should not auto-select credentials for %s', (credentialType, auth) => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.newCredentialType(credentialType)]);
credentialsStore.setCredentialTypes([mockCredentialType(credentialType)]);
credentialsStore.setCredentials([
testData.newCredential({
name: `${credentialType}Credential`,
@ -128,24 +180,17 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
credentialsStore.setCredentialTypes([mockCredentialType('telegramApi')]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullSaveEmailAttachmentsToNextCloudTemplate]);
templatesStore.addWorkflows([testTemplate1]);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeReadImapV1,
testData.nodeTypeReadImapV2,
testData.nodeTypeNextCloudV1,
]);
nodeTypesStore.setNodeTypes([nodeTypeReadImap, nodeTypeNextCloud]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(
testData.fullSaveEmailAttachmentsToNextCloudTemplate.id.toString(),
);
setupTemplateStore.setTemplateId(testTemplate1.id.toString());
});
const templateImapNode = testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[0];
const templateNextcloudNode =
testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[1];
const templateImapNode = testTemplate1.workflow.nodes[0];
const templateNextcloudNode = testTemplate1.workflow.nodes[1];
it('should return correct credential usages', () => {
const setupTemplateStore = useSetupTemplateStore();

View file

@ -53,52 +53,3 @@ export const newCredential = (
name: faker.commerce.productName(),
...opts,
});
export const credentialsTelegram1: ICredentialsResponse = {
createdAt: '2023-11-23T14:26:07.969Z',
updatedAt: '2023-11-23T14:26:07.964Z',
id: 'YaSKdvEcT1TSFrrr1',
name: 'Telegram account',
type: 'telegramApi',
ownedBy: {
id: '713ef3e7-9e65-4b0a-893c-8a653cbb2c4f',
email: 'user@n8n.io',
firstName: 'Player',
lastName: 'One',
},
sharedWithProjects: [],
};
export const credentialsTelegram2: ICredentialsResponse = {
createdAt: '2023-11-23T14:26:07.969Z',
updatedAt: '2023-11-23T14:26:07.964Z',
id: 'YaSKdvEcT1TSFrrr2',
name: 'Telegram account',
type: 'telegramApi',
ownedBy: {
id: '713ef3e7-9e65-4b0a-893c-8a653cbb2c4f',
email: 'user@n8n.io',
firstName: 'Player',
lastName: 'One',
},
sharedWithProjects: [],
};
export {
fullSaveEmailAttachmentsToNextCloudTemplate,
fullShopifyTelegramTwitterTemplate,
} from '@/utils/testData/templateTestData';
export { credentialTypeTelegram, newCredentialType } from '@/utils/testData/credentialTypeTestData';
export {
nodeTypeHttpRequestV1,
nodeTypeNextCloudV1,
nodeTypeReadImapV1,
nodeTypeReadImapV2,
nodeTypeShopifyTriggerV1,
nodeTypeTelegramV1,
nodeTypeTelegramV1_1,
nodeTypeTwitterV1,
nodeTypeTwitterV2,
} from '@/utils/testData/nodeTypeTestData';

View file

@ -1,3 +1,5 @@
import { mock } from 'vitest-mock-extended';
import type { IWorkflowTemplateNode } from '@/Interface';
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
@ -5,8 +7,7 @@ import {
getAppCredentials,
groupNodeCredentialsByKey,
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
import * as testData from './setupTemplate.store.testData';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
import { nodeTypeTelegram, nodeTypeTwitter } from '@/utils/testData/nodeTypeTestData';
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map(Object.entries(obj)) as Map<TKey, T>;
@ -40,7 +41,7 @@ describe('useCredentialSetupState', () => {
groupNodeCredentialsByKey([
{
node: nodesByName.Twitter,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
requiredCredentials: nodeTypeTwitter.credentials!,
},
]),
).toEqual(
@ -58,14 +59,14 @@ describe('useCredentialSetupState', () => {
it('returns credentials grouped when the credential names are the same', () => {
const [node1, node2] = [
newWorkflowTemplateNode({
mock<IWorkflowTemplateNode>({
id: 'twitter',
type: 'n8n-nodes-base.twitter',
credentials: {
twitterOAuth1Api: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
newWorkflowTemplateNode({
mock<IWorkflowTemplateNode>({
id: 'telegram',
type: 'n8n-nodes-base.telegram',
credentials: {
@ -78,11 +79,11 @@ describe('useCredentialSetupState', () => {
groupNodeCredentialsByKey([
{
node: node1,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
requiredCredentials: nodeTypeTwitter.credentials!,
},
{
node: node2,
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
requiredCredentials: nodeTypeTelegram.credentials!,
},
]),
).toEqual(