mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
test: Add testing guidelines for AI tools (no-changelog) (#20161)
This commit is contained in:
parent
6717fb3ba1
commit
09fda978b8
9 changed files with 2541 additions and 11 deletions
36
packages/nodes-base/TESTING.MD
Normal file
36
packages/nodes-base/TESTING.MD
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
To write unit tests it is suggested to use AI for
|
||||
help.
|
||||
|
||||
## Approach for standard unit tests
|
||||
|
||||
1. **Create the test file**: Decide which node you want to test and create a test file with the corresponding name in the test folder. For example, for a node in `nodeA/v2/NodeAV2.node.ts`, create a test file in `nodeA/v2/test/NodeAV2.test.ts`.
|
||||
|
||||
2. **Use AI assistance**: Send this prompt to your AI tool (Cursor, Copilot, Claude, etc.):
|
||||
```
|
||||
Using guidelines in @TESTING_PROMPT.md, write tests for @NodeAV2.node.ts in @NodeAV2.node.test.ts
|
||||
```
|
||||
Make sure file names after `@` are detected and referenced by your tool.
|
||||
You can improve the prompt by asking to cover specific test cases.
|
||||
|
||||
3. **Review and refine**: Thoroughly review the generated tests, make necessary fixes, and remove redundant tests. __Even if generated by AI, it's still your responsibility to ensure tests are working and reasonable.__
|
||||
|
||||
## Approach for workflow unit tests
|
||||
Workflow unit tests are tests that use user predefined workflows in json format and NodeTestHarness helper that runs the workflow. This is closer to integration tests.
|
||||
|
||||
For these tests you can follow the guidelines defined above, but with some modifications:
|
||||
- Use `TESTING_PROMPT_WORKFLOW.md` instead
|
||||
- Use a different prompt. It's also important to specify a credentials schema if any credentials are being used, because AI struggles with identifying the schema. You can use the following prompt:
|
||||
```
|
||||
I need you to write workflow unit tests for @NodeAV2.node.ts in @NodeAV2.node.test.ts
|
||||
using guidelines in @TESTING_PROMPT_WORKFLOW.md
|
||||
You should test each resource and operation in the node
|
||||
After writing a first test make sure it passes, then write other tests.
|
||||
|
||||
To mock credentials use this schema
|
||||
oauth2: {
|
||||
scope: '',
|
||||
oauthTokenData: {
|
||||
access_token: 'ACCESSTOKEN',
|
||||
},
|
||||
}
|
||||
```
|
||||
481
packages/nodes-base/TESTING_PROMPT.md
Normal file
481
packages/nodes-base/TESTING_PROMPT.md
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
# AI Agent Prompt: Writing Reliable Unit Tests for n8n Nodes
|
||||
|
||||
You are an expert AI agent specialized in writing comprehensive, reliable unit tests for n8n nodes in the `@packages/nodes-base` folder. Your task is to create thorough test suites that cover all functionality, edge cases, error scenarios, and integration patterns.
|
||||
|
||||
## Core Testing Principles
|
||||
|
||||
### 1. Test Structure and Organization
|
||||
- **File Naming**: Use `.test.ts` extension, place in `test/` or `__tests__/` directories
|
||||
- **Test Organization**: Group tests by functionality using `describe()` blocks. Test concrete operations and resources.
|
||||
- **Test Naming**: Use descriptive test names that explain the expected behavior
|
||||
- **Setup/Teardown**: Use `beforeEach()` and `afterEach()` for consistent test isolation
|
||||
|
||||
### 3. Testing guidelines
|
||||
|
||||
- **Don't add useless comments** such as "Arrange, Assert, Act" or "Mock something".
|
||||
- **Always work from within the package directory** when running tests
|
||||
- **Use `pnpm test`** for running tests
|
||||
- **Mock all external dependencies** in unit tests
|
||||
|
||||
|
||||
### 4. Essential Test Categories
|
||||
Always include tests for:
|
||||
- **Happy Path**: Normal operation with valid inputs
|
||||
- **Error Handling**: Invalid inputs, API failures, network errors
|
||||
- **Edge Cases**: Empty data, null values, boundary conditions
|
||||
- **Parameter Validation**: Required vs optional parameters
|
||||
- **Binary Data**: File uploads, downloads, data streams
|
||||
- **Authentication**: Credential handling, token refresh
|
||||
- **Rate Limiting**: API throttling, retry logic
|
||||
- **Data Transformation**: Input/output data processing
|
||||
- **Node Versioning**: Different node type versions
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### 1. Core n8n Interfaces Mocking
|
||||
```typescript
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, IWebhookFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
// Standard execute functions mock
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
|
||||
// Webhook functions mock
|
||||
const mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
|
||||
// Node mock
|
||||
const mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Common Mock Patterns
|
||||
```typescript
|
||||
// Input data mocking
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{ json: { test: 'data' } },
|
||||
{ json: { another: 'item' } }
|
||||
]);
|
||||
|
||||
// Node parameter mocking
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const mockParams = {
|
||||
'operation': 'create',
|
||||
'resource': 'user',
|
||||
'name': 'Test User',
|
||||
'email': 'test@example.com'
|
||||
};
|
||||
return mockParams[paramName];
|
||||
});
|
||||
|
||||
// Credentials mocking
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
accessToken: 'test-token',
|
||||
baseUrl: 'https://api.example.com'
|
||||
});
|
||||
|
||||
// Binary data mocking
|
||||
mockExecuteFunctions.helpers.prepareBinaryData.mockResolvedValue({
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'test.txt'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. External API Mocking
|
||||
```typescript
|
||||
// Using jest.spyOn for API functions
|
||||
const apiRequestSpy = jest.spyOn(GenericFunctions, 'apiRequest');
|
||||
apiRequestSpy.mockResolvedValue({
|
||||
id: '123',
|
||||
name: 'Test Item',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Using nock for HTTP mocking
|
||||
import nock from 'nock';
|
||||
|
||||
beforeEach(() => {
|
||||
nock('https://api.example.com')
|
||||
.get('/users')
|
||||
.reply(200, { users: [{ id: 1, name: 'John' }] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Database and External Service Mocking
|
||||
```typescript
|
||||
// Database mocking
|
||||
const mockDataTable = mock<IDataStoreProjectService>({
|
||||
getColumns: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
updateRow: jest.fn(),
|
||||
});
|
||||
|
||||
// Redis client mocking
|
||||
const mockClient = mock<RedisClient>();
|
||||
const createClient = jest.fn().mockReturnValue(mockClient);
|
||||
jest.mock('redis', () => ({ createClient }));
|
||||
```
|
||||
|
||||
## Test Implementation Patterns
|
||||
|
||||
### 1. Basic Node Execution Test
|
||||
```typescript
|
||||
describe('Node Execution', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
});
|
||||
|
||||
it('should execute successfully with valid parameters', async () => {
|
||||
// Setup mocks
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param) => {
|
||||
const params = { operation: 'create', name: 'Test' };
|
||||
return params[param];
|
||||
});
|
||||
|
||||
apiRequestSpy.mockResolvedValue({ id: '123', name: 'Test' });
|
||||
|
||||
// Execute
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
// Assertions
|
||||
expect(result).toEqual([[
|
||||
{ json: { id: '123', name: 'Test' }, pairedItem: { item: 0 } }
|
||||
]]);
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/items', { name: 'Test' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Error Handling Tests
|
||||
```typescript
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error for invalid credentials', async () => {
|
||||
mockExecuteFunctions.getCredentials.mockRejectedValue(
|
||||
new Error('Invalid credentials')
|
||||
);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions))
|
||||
.rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
apiRequestSpy.mockRejectedValue(new Error('API Error'));
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should validate required parameters', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions))
|
||||
.rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Binary Data Testing
|
||||
```typescript
|
||||
describe('Binary Data Handling', () => {
|
||||
it('should process binary files correctly', async () => {
|
||||
const mockBinaryData = {
|
||||
data: 'base64data',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'test.png'
|
||||
};
|
||||
|
||||
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
|
||||
mockExecuteFunctions.helpers.prepareBinaryData.mockResolvedValue(mockBinaryData);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].binary).toBeDefined();
|
||||
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle file upload operations', async () => {
|
||||
const fileBuffer = Buffer.from('test file content');
|
||||
mockExecuteFunctions.helpers.getBinaryStream.mockResolvedValue(fileBuffer);
|
||||
|
||||
// Test file upload logic
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('fileId');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Webhook Testing
|
||||
```typescript
|
||||
describe('Webhook Operations', () => {
|
||||
it('should handle GET requests', async () => {
|
||||
const mockRequest = { method: 'GET', query: { id: '123' } };
|
||||
const mockResponse = { render: jest.fn(), send: jest.fn() };
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest);
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
|
||||
|
||||
await node.webhook(mockWebhookFunctions);
|
||||
|
||||
expect(mockResponse.render).toHaveBeenCalledWith('template', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should process POST data', async () => {
|
||||
const mockRequest = {
|
||||
method: 'POST',
|
||||
body: { name: 'Test', email: 'test@example.com' }
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockRequest.body);
|
||||
|
||||
const result = await node.webhook(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(result.workflowData[0][0].json).toEqual(mockRequest.body);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Data Transformation Testing
|
||||
```typescript
|
||||
describe('Data Processing', () => {
|
||||
it('should transform input data correctly', async () => {
|
||||
const inputData = [
|
||||
{ json: { firstName: 'John', lastName: 'Doe' } },
|
||||
{ json: { firstName: 'Jane', lastName: 'Smith' } }
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toHaveProperty('fullName', 'John Doe');
|
||||
});
|
||||
|
||||
it('should handle empty input gracefully', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Testing Patterns
|
||||
|
||||
### 1. Using NodeTestHarness for Integration Tests
|
||||
```typescript
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials: {
|
||||
'testApi': { accessToken: 'test-token' }
|
||||
},
|
||||
nock: {
|
||||
baseUrl: 'https://api.example.com',
|
||||
mocks: [{
|
||||
method: 'get',
|
||||
path: '/users',
|
||||
statusCode: 200,
|
||||
responseBody: { users: [] }
|
||||
}]
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Testing Node Methods and Properties
|
||||
```typescript
|
||||
describe('Node Methods', () => {
|
||||
it('should have required methods defined', () => {
|
||||
expect(node.methods.credentialTest).toBeDefined();
|
||||
expect(node.methods.loadOptions).toBeDefined();
|
||||
expect(node.methods.listSearch).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate credential test method', async () => {
|
||||
const mockCredentialTestFunctions = mock<ICredentialTestFunctions>();
|
||||
mockCredentialTestFunctions.getCredentials.mockResolvedValue({
|
||||
accessToken: 'test-token'
|
||||
});
|
||||
|
||||
const result = await node.methods.credentialTest.testApiCredentialTest.call(
|
||||
mockCredentialTestFunctions
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'OK' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Testing Load Options
|
||||
```typescript
|
||||
describe('Load Options', () => {
|
||||
it('should load resource options', async () => {
|
||||
const mockLoadOptionsFunctions = mock<ILoadOptionsFunctions>();
|
||||
mockLoadOptionsFunctions.getCredentials.mockResolvedValue({
|
||||
accessToken: 'test-token'
|
||||
});
|
||||
|
||||
apiRequestSpy.mockResolvedValue([
|
||||
{ id: '1', name: 'Option 1' },
|
||||
{ id: '2', name: 'Option 2' }
|
||||
]);
|
||||
|
||||
const result = await node.methods.loadOptions.resourceOptions.call(
|
||||
mockLoadOptionsFunctions
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Option 1', value: '1' },
|
||||
{ name: 'Option 2', value: '2' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### 1. Test Coverage Requirements
|
||||
- **Minimum 80% code coverage** for all node files
|
||||
- **100% coverage** for critical error handling paths
|
||||
- **Test all public methods** and exported functions
|
||||
- **Cover all conditional branches** and edge cases
|
||||
|
||||
### 2. Test Data Management
|
||||
- Use **realistic test data** that mirrors production scenarios
|
||||
- Create **reusable test fixtures** for common data patterns
|
||||
- Use **factory functions** for generating test data
|
||||
- **Clean up test data** in afterEach hooks
|
||||
|
||||
### 3. Assertion Best Practices
|
||||
```typescript
|
||||
// Use specific assertions
|
||||
expect(result).toEqual(expectedData);
|
||||
expect(mockFunction).toHaveBeenCalledWith(expectedArgs);
|
||||
expect(mockFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Test error messages
|
||||
expect(() => functionCall()).toThrow('Expected error message');
|
||||
|
||||
// Test async operations
|
||||
await expect(asyncFunction()).resolves.toEqual(expectedResult);
|
||||
await expect(asyncFunction()).rejects.toThrow(Error);
|
||||
```
|
||||
|
||||
### 4. Performance and Reliability
|
||||
- **Mock external dependencies** to ensure test reliability
|
||||
- **Use deterministic test data** for consistent results
|
||||
- **Test timeout scenarios** for long-running operations
|
||||
- **Validate memory usage** for large data processing
|
||||
|
||||
### 5. Documentation and Maintenance
|
||||
- **Document complex test scenarios** with inline comments
|
||||
- **Use descriptive test names** that explain the test purpose
|
||||
- **Group related tests** logically in describe blocks
|
||||
- **Keep tests independent** - no test should depend on another
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
1. **Don't test implementation details** - focus on behavior
|
||||
2. **Don't use real external APIs** in unit tests
|
||||
3. **Don't skip error handling tests** - they're critical
|
||||
4. **Don't use hardcoded values** - use constants or factories
|
||||
5. **Don't ignore async operations** - always await promises
|
||||
6. **Don't test multiple concerns** in a single test case
|
||||
|
||||
## Example Complete Test Suite
|
||||
|
||||
```typescript
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { TestNode } from '../TestNode';
|
||||
import * as GenericFunctions from '../GenericFunctions';
|
||||
|
||||
describe('TestNode', () => {
|
||||
let node: TestNode;
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
const apiRequestSpy = jest.spyOn(GenericFunctions, 'apiRequest');
|
||||
|
||||
beforeEach(() => {
|
||||
node = new TestNode();
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'test',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful execution', () => {
|
||||
it('should process data correctly', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param) => {
|
||||
const params = { operation: 'create', name: 'Test Item' };
|
||||
return params[param];
|
||||
});
|
||||
|
||||
apiRequestSpy.mockResolvedValue({ id: '123', name: 'Test Item' });
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[
|
||||
{ json: { id: '123', name: 'Test Item' }, pairedItem: { item: 0 } }
|
||||
]]);
|
||||
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/items', { name: 'Test Item' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for missing required parameter', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue(undefined);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions))
|
||||
.rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle API errors with continueOnFail', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue('create');
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
apiRequestSpy.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('error', 'API Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
562
packages/nodes-base/TESTING_PROMPT_WORKFLOW.md
Normal file
562
packages/nodes-base/TESTING_PROMPT_WORKFLOW.md
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
# AI Agent Prompt: Writing Reliable Workflow Unit Tests for n8n Nodes
|
||||
|
||||
You are an expert AI agent specialized in writing comprehensive, reliable workflow unit tests for n8n nodes in the `@packages/nodes-base` folder. Your task is to create thorough test suites that use `.workflow.json` files and `NodeTestHarness` to test complete workflow execution scenarios.
|
||||
|
||||
## Core Guidelines
|
||||
- **Don't add useless comments** such as "Arrange, Assert, Act" or "Mock something"
|
||||
- **Always work from within the package directory** when running tests
|
||||
- **Use `pnpm test`** for running tests. Example: `cd packages/nodes-base/ && pnpm test TestFileName
|
||||
|
||||
## Essential Test Structure
|
||||
|
||||
### Basic Test Setup
|
||||
```typescript
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('NodeName', () => {
|
||||
describe('Run Test Workflow', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://api.example.com');
|
||||
mock.post('/endpoint').reply(200, mockResponse);
|
||||
mock.get('/data').reply(200, mockData);
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Test with Credentials
|
||||
```typescript
|
||||
describe('NodeName', () => {
|
||||
const credentials = {
|
||||
nodeApi: {
|
||||
accessToken: 'test-token',
|
||||
baseUrl: 'https://api.example.com',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Run Test Workflow', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock(credentials.nodeApi.baseUrl);
|
||||
mock.post('/users').reply(200, userCreateResponse);
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['workflow.json'],
|
||||
assertBinaryData: true
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Workflow JSON Structure
|
||||
|
||||
### Basic Workflow Template
|
||||
```json
|
||||
{
|
||||
"name": "NodeName Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "trigger-id",
|
||||
"name": "When clicking 'Execute Workflow'"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "create",
|
||||
"resource": "user",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com"
|
||||
},
|
||||
"type": "n8n-nodes-base.nodeName",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"id": "node-id",
|
||||
"name": "Node Operation",
|
||||
"credentials": {
|
||||
"nodeApi": {
|
||||
"id": "credential-id",
|
||||
"name": "Test Credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Node Operation": [
|
||||
{
|
||||
"json": {
|
||||
"id": "123",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking 'Execute Workflow'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node Operation",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Node Parameter Types
|
||||
|
||||
### Basic Parameters
|
||||
```json
|
||||
{
|
||||
"displayName": "Parameter Name",
|
||||
"name": "parameterName",
|
||||
"type": "string|number|boolean|options",
|
||||
"default": "defaultValue",
|
||||
"required": true
|
||||
}
|
||||
```
|
||||
|
||||
### Collection Parameters
|
||||
```json
|
||||
{
|
||||
"displayName": "Additional Fields",
|
||||
"name": "additionalFields",
|
||||
"type": "collection",
|
||||
"default": {},
|
||||
"options": [
|
||||
{
|
||||
"displayName": "Custom Field",
|
||||
"name": "customField",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fixed Collection Parameters
|
||||
```json
|
||||
{
|
||||
"displayName": "Fields to Set",
|
||||
"name": "fields",
|
||||
"type": "fixedCollection",
|
||||
"typeOptions": {
|
||||
"multipleValues": true
|
||||
},
|
||||
"options": [
|
||||
{
|
||||
"name": "values",
|
||||
"displayName": "Values",
|
||||
"values": [
|
||||
{
|
||||
"displayName": "Name",
|
||||
"name": "name",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Mocking with Nock
|
||||
|
||||
### Basic API Mocking
|
||||
```typescript
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://api.example.com');
|
||||
|
||||
// Mock GET request
|
||||
mock.get('/users')
|
||||
.reply(200, {
|
||||
users: [
|
||||
{ id: 1, name: 'User 1' },
|
||||
{ id: 2, name: 'User 2' }
|
||||
]
|
||||
});
|
||||
|
||||
// Mock POST request
|
||||
mock.post('/users', {
|
||||
name: 'Test User',
|
||||
email: 'test@example.com'
|
||||
})
|
||||
.reply(201, {
|
||||
id: 123,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Mock error responses
|
||||
mock.get('/error-endpoint')
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Mocking
|
||||
```typescript
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://api.example.com');
|
||||
|
||||
// Mock with headers
|
||||
mock.get('/protected-endpoint')
|
||||
.matchHeader('Authorization', 'Bearer test-token')
|
||||
.reply(200, { data: 'protected' });
|
||||
|
||||
// Mock with query parameters
|
||||
mock.get('/search')
|
||||
.query({ q: 'test', limit: 10 })
|
||||
.reply(200, { results: [] });
|
||||
|
||||
// Mock with request body validation
|
||||
mock.post('/validate', (body) => {
|
||||
return body.name && body.email;
|
||||
})
|
||||
.reply(200, { valid: true });
|
||||
});
|
||||
```
|
||||
|
||||
### Credentials Mocking
|
||||
Some workflows require credentials for NodeHarness. If the execution result of a test is null it means that workflow has invalid inputs. Very often it's misconfigured credentials.
|
||||
|
||||
```typescript
|
||||
const credentials = {
|
||||
googleAnalyticsOAuth2: {
|
||||
scope: '',
|
||||
oauthTokenData: {
|
||||
access_token: 'ACCESSTOKEN',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
const credentials = {
|
||||
aws: {
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
wordpressApi: {
|
||||
url: 'https://myblog.com',
|
||||
allowUnauthorizedCerts: false,
|
||||
username: 'nodeqa',
|
||||
password: 'fake-password',
|
||||
},
|
||||
```
|
||||
|
||||
```typescript
|
||||
const credentials = {
|
||||
telegramApi: {
|
||||
accessToken: 'testToken',
|
||||
baseUrl: 'https://api.telegram.org',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## Binary Data Testing
|
||||
|
||||
### Binary Data Workflow
|
||||
```json
|
||||
{
|
||||
"pinData": {
|
||||
"Upload Node": [
|
||||
{
|
||||
"json": {
|
||||
"fileId": "123",
|
||||
"fileName": "test.txt",
|
||||
"fileSize": 1024,
|
||||
"mimeType": "text/plain"
|
||||
},
|
||||
"binary": {
|
||||
"data": {
|
||||
"data": "base64data",
|
||||
"mimeType": "text/plain",
|
||||
"fileName": "test.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Binary Data Test Setup
|
||||
```typescript
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['binary.workflow.json'],
|
||||
assertBinaryData: true
|
||||
});
|
||||
```
|
||||
|
||||
## Error Scenario Testing
|
||||
|
||||
### Error Workflow
|
||||
```json
|
||||
{
|
||||
"pinData": {
|
||||
"Error Node": [
|
||||
{
|
||||
"json": {
|
||||
"error": "User not found",
|
||||
"message": "Invalid request",
|
||||
"code": 404
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Mock Setup
|
||||
```typescript
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://api.example.com');
|
||||
mock.get('/users/nonexistent')
|
||||
.reply(404, { error: 'User not found' });
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Workflow Patterns
|
||||
|
||||
### Switch Node Testing
|
||||
```json
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"conditions": [
|
||||
{
|
||||
"leftValue": "={{ $json.status }}",
|
||||
"rightValue": "active",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"outputKey": "Active Users"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Set Node Testing
|
||||
```json
|
||||
{
|
||||
"parameters": {
|
||||
"fields": {
|
||||
"values": [
|
||||
{
|
||||
"name": "processed",
|
||||
"stringValue": "true"
|
||||
},
|
||||
{
|
||||
"name": "timestamp",
|
||||
"stringValue": "={{ new Date().toISOString() }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Code Node Testing
|
||||
```json
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "return [\n { id: 1, name: 'Item 1' },\n { id: 2, name: 'Item 2' }\n]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Credential Types
|
||||
|
||||
### API Key Credentials
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openai-cred-id",
|
||||
"name": "OpenAI API Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth2 Credentials
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"slackOAuth2Api": {
|
||||
"id": "slack-oauth-id",
|
||||
"name": "Slack OAuth2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Database Credentials
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "postgres-cred-id",
|
||||
"name": "PostgreSQL Database"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Essential Test Categories
|
||||
|
||||
Always include tests for:
|
||||
- **Complete Workflow Execution**: End-to-end workflow scenarios
|
||||
- **API Integration**: External API calls with proper mocking
|
||||
- **Data Flow**: Input data transformation through multiple nodes
|
||||
- **Error Scenarios**: Workflow execution with API failures
|
||||
- **Binary Data Handling**: File uploads, downloads, and processing
|
||||
- **Authentication**: Credential handling across workflow execution
|
||||
- **Node Interactions**: Multiple nodes working together
|
||||
- **Conditional Logic**: Switch nodes, conditional execution paths
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Workflow JSON Design
|
||||
- **Trigger Node**: Always start with `n8n-nodes-base.manualTrigger`
|
||||
- **Node Parameters**: Include all required parameters with realistic values
|
||||
- **Node Connections**: Define clear data flow between nodes
|
||||
- **Pin Data**: Provide expected outputs for validation
|
||||
- **Credentials**: Reference appropriate credential types
|
||||
|
||||
### Mock Setup
|
||||
- **Mock all external API calls** to ensure test reliability
|
||||
- **Use realistic response data** that matches expected outputs
|
||||
- **Test both success and error scenarios**
|
||||
- **Include proper HTTP status codes**
|
||||
- **Clean up mocks** between test runs
|
||||
|
||||
### Test Organization
|
||||
- **Group related workflows** in the same test file
|
||||
- **Use descriptive test names** that explain the scenario
|
||||
- **Keep workflow JSON files** in the same directory as test files
|
||||
- **Use consistent naming conventions** for workflow files
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
1. **Don't use real external APIs** in workflow tests
|
||||
2. **Don't skip pinData** - it's essential for output validation
|
||||
3. **Don't forget to mock all API calls** - missing mocks cause test failures
|
||||
4. **Don't use hardcoded credentials** - use test credentials
|
||||
5. **Don't ignore error scenarios** - test both success and failure cases
|
||||
6. **Don't create overly complex workflows** - keep them focused and testable
|
||||
7. **Don't forget to clean up nock mocks** between tests
|
||||
8. **Don't use production data** in test workflows
|
||||
9. **Don't skip credential testing** - test authentication flows
|
||||
10. **Don't ignore node version differences** - test multiple node versions
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('NodeName', () => {
|
||||
const credentials = {
|
||||
nodeApi: {
|
||||
accessToken: 'test-token',
|
||||
baseUrl: 'https://api.example.com',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Basic Operations', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock(credentials.nodeApi.baseUrl);
|
||||
|
||||
mock.get('/users')
|
||||
.reply(200, {
|
||||
users: [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' }
|
||||
]
|
||||
});
|
||||
|
||||
mock.post('/users', {
|
||||
name: 'Test User',
|
||||
email: 'test@example.com'
|
||||
})
|
||||
.reply(201, {
|
||||
id: 123,
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['basic.workflow.json']
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock(credentials.nodeApi.baseUrl);
|
||||
mock.get('/users')
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['error.workflow.json']
|
||||
});
|
||||
});
|
||||
|
||||
describe('Binary Data Operations', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock(credentials.nodeApi.baseUrl);
|
||||
mock.post('/upload')
|
||||
.reply(200, {
|
||||
fileId: '123',
|
||||
fileName: 'test.txt',
|
||||
fileSize: 1024,
|
||||
mimeType: 'text/plain'
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['binary.workflow.json'],
|
||||
assertBinaryData: true
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('GoogleAnalyticsV2', () => {
|
||||
const credentials = {
|
||||
googleAnalyticsOAuth2: {
|
||||
scope: '',
|
||||
oauthTokenData: {
|
||||
access_token: 'ACCESSTOKEN',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Report Resource - GA4 Get Operation', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://analyticsdata.googleapis.com');
|
||||
|
||||
mock.post('/v1beta/properties/123456789:runReport').reply(200, {
|
||||
dimensionHeaders: [{ name: 'date' }],
|
||||
metricHeaders: [{ name: 'totalUsers', type: 'TYPE_INTEGER' }],
|
||||
rows: [
|
||||
{
|
||||
dimensionValues: [{ value: '20240101' }],
|
||||
metricValues: [{ value: '100' }],
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['report-ga4-get.workflow.json'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('Report Resource - Universal Analytics Get Operation', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://analyticsreporting.googleapis.com');
|
||||
|
||||
mock.post('/v4/reports:batchGet').reply(200, {
|
||||
reports: [
|
||||
{
|
||||
columnHeader: {
|
||||
dimensions: ['ga:date'],
|
||||
metricHeader: {
|
||||
metricHeaderEntries: [
|
||||
{ name: 'ga:users', type: 'INTEGER' },
|
||||
{ name: 'ga:sessions', type: 'INTEGER' },
|
||||
],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
dimensions: ['20240101'],
|
||||
metrics: [{ values: ['100', '50'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['report-universal-get.workflow.json'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserActivity Resource - Search Operation', () => {
|
||||
beforeAll(() => {
|
||||
const mock = nock('https://analyticsreporting.googleapis.com');
|
||||
|
||||
mock.post('/v4/userActivity:search').reply(200, {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session123',
|
||||
deviceCategory: 'desktop',
|
||||
platform: 'web',
|
||||
dataSource: 'web',
|
||||
activities: [
|
||||
{
|
||||
activityTime: '2024-01-01T10:00:00Z',
|
||||
source: 'web',
|
||||
medium: 'organic',
|
||||
channelGrouping: 'Organic Search',
|
||||
campaign: 'spring_sale',
|
||||
keyword: 'analytics',
|
||||
hostname: 'example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['useractivity-search.workflow.json'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"name": "Google Analytics GA4 Report Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "trigger-id",
|
||||
"name": "When clicking 'Execute Workflow'"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "report",
|
||||
"operation": "get",
|
||||
"propertyType": "ga4",
|
||||
"propertyId": {
|
||||
"mode": "id",
|
||||
"value": "123456789"
|
||||
},
|
||||
"dateRange": "last7days",
|
||||
"metricsGA4": {
|
||||
"metricValues": [
|
||||
{
|
||||
"listName": "totalUsers"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dimensionsGA4": {
|
||||
"dimensionValues": [
|
||||
{
|
||||
"listName": "date"
|
||||
}
|
||||
]
|
||||
},
|
||||
"returnAll": false,
|
||||
"limit": 10,
|
||||
"simple": true
|
||||
},
|
||||
"type": "n8n-nodes-base.googleAnalytics",
|
||||
"typeVersion": 2,
|
||||
"position": [200, 0],
|
||||
"id": "ga4-report-node",
|
||||
"name": "GA4 Report",
|
||||
"credentials": {
|
||||
"googleAnalyticsOAuth2": {
|
||||
"id": "ga-oauth-cred-id",
|
||||
"name": "Google Analytics OAuth2"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"GA4 Report": [
|
||||
{
|
||||
"json": {
|
||||
"date": "20240101",
|
||||
"totalUsers": "100"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking 'Execute Workflow'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "GA4 Report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "Google Analytics Universal Report Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "trigger-id",
|
||||
"name": "When clicking 'Execute Workflow'"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "report",
|
||||
"operation": "get",
|
||||
"propertyType": "universal",
|
||||
"viewId": {
|
||||
"mode": "id",
|
||||
"value": "123456789"
|
||||
},
|
||||
"dateRange": "last7days",
|
||||
"metricsUA": {
|
||||
"metricValues": [
|
||||
{
|
||||
"listName": "ga:users"
|
||||
},
|
||||
{
|
||||
"listName": "ga:sessions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dimensionsUA": {
|
||||
"dimensionValues": [
|
||||
{
|
||||
"listName": "ga:date"
|
||||
}
|
||||
]
|
||||
},
|
||||
"returnAll": false,
|
||||
"limit": 10
|
||||
},
|
||||
"type": "n8n-nodes-base.googleAnalytics",
|
||||
"typeVersion": 2,
|
||||
"position": [200, 0],
|
||||
"id": "universal-report-node",
|
||||
"name": "Universal Report",
|
||||
"credentials": {
|
||||
"googleAnalyticsOAuth2": {
|
||||
"id": "ga-oauth-cred-id",
|
||||
"name": "Google Analytics OAuth2"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Universal Report": [
|
||||
{
|
||||
"json": {
|
||||
"ga:date": "20240101",
|
||||
"ga:users": "100",
|
||||
"ga:sessions": "50"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking 'Execute Workflow'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Universal Report",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"name": "Google Analytics UserActivity Search Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "trigger-id",
|
||||
"name": "When clicking 'Execute Workflow'"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"resource": "userActivity",
|
||||
"operation": "search",
|
||||
"viewId": "123456789",
|
||||
"userId": "user123",
|
||||
"returnAll": false,
|
||||
"limit": 100,
|
||||
"additionalFields": {
|
||||
"activityTypes": ["PAGEVIEW", "EVENT"]
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.googleAnalytics",
|
||||
"typeVersion": 2,
|
||||
"position": [200, 0],
|
||||
"id": "useractivity-search-node",
|
||||
"name": "UserActivity Search",
|
||||
"credentials": {
|
||||
"googleAnalyticsOAuth2": {
|
||||
"id": "ga-oauth-cred-id",
|
||||
"name": "Google Analytics OAuth2"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"UserActivity Search": [
|
||||
{
|
||||
"json": {
|
||||
"sessionId": "session123",
|
||||
"deviceCategory": "desktop",
|
||||
"platform": "web",
|
||||
"dataSource": "web",
|
||||
"activities": [
|
||||
{
|
||||
"activityTime": "2024-01-01T10:00:00Z",
|
||||
"source": "web",
|
||||
"medium": "organic",
|
||||
"channelGrouping": "Organic Search",
|
||||
"campaign": "spring_sale",
|
||||
"keyword": "analytics",
|
||||
"hostname": "example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking 'Execute Workflow'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "UserActivity Search",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../../v2/actions/sheet/append.operation';
|
||||
import type { GoogleSheet } from '../../../../v2/helpers/GoogleSheet';
|
||||
import * as GoogleSheetsUtils from '../../../../v2/helpers/GoogleSheets.utils';
|
||||
|
||||
jest.mock('../../../../v2/helpers/GoogleSheets.utils', () => ({
|
||||
autoMapInputData: jest.fn(),
|
||||
mapFields: jest.fn(),
|
||||
checkForSchemaChanges: jest.fn(),
|
||||
cellFormatDefault: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Google Sheets Append Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockSheet: jest.Mocked<GoogleSheet>;
|
||||
let mockNode: jest.Mocked<INode>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'Google Sheets Append',
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
typeVersion: 3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockSheet = mock<GoogleSheet>();
|
||||
mockSheet.getData = jest.fn();
|
||||
mockSheet.appendSheetData = jest.fn();
|
||||
mockSheet.appendEmptyRowsOrColumns = jest.fn();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{ json: { name: 'John', email: 'john@example.com' } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' } },
|
||||
]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const mockParams: Record<string, any> = {
|
||||
dataMode: 'defineBelow',
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
options: {},
|
||||
'columns.schema': [],
|
||||
};
|
||||
return mockParams[paramName];
|
||||
});
|
||||
|
||||
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
]);
|
||||
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
]);
|
||||
(GoogleSheetsUtils.cellFormatDefault as jest.Mock).mockReturnValue('USER_ENTERED');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute successfully with valid parameters', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
json: { name: 'John', email: 'john@example.com' },
|
||||
pairedItem: { item: 0 },
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
json: { name: 'Jane', email: 'jane@example.com' },
|
||||
pairedItem: { item: 1 },
|
||||
});
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no input data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when dataMode is nothing', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
|
||||
return 'nothing';
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Mode Handling', () => {
|
||||
it('should use autoMapInputData mode when sheet is empty', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
|
||||
'Sheet1!A1:B2',
|
||||
mockSheet,
|
||||
[
|
||||
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use defineBelow mode when sheet has data', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should handle autoMapInputData mode explicitly', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
|
||||
return 'autoMapInputData';
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Version Handling', () => {
|
||||
it('should use dataMode parameter for node version < 4', async () => {
|
||||
mockNode.typeVersion = 3;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use columns.mappingMode parameter for node version >= 4', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Options and Configuration', () => {
|
||||
it('should handle custom header row', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return {
|
||||
locationDefine: {
|
||||
values: { headerRow: 2 },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Header1', 'Header2'],
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!A1:B2', 'FORMATTED_VALUE');
|
||||
});
|
||||
|
||||
it('should handle useAppend option', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return { useAppend: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
useAppend: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cell format option', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return { cellFormat: 'RAW' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
valueInputMode: 'RAW',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when column names cannot be retrieved (node version >= 4.4)', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
// Mock checkForSchemaChanges to throw error
|
||||
(GoogleSheetsUtils.checkForSchemaChanges as jest.Mock).mockImplementation(() => {
|
||||
throw new NodeOperationError(mockNode, 'Column names were updated');
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw error when header row is out of bounds', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
if (paramName === 'options') {
|
||||
return {
|
||||
locationDefine: {
|
||||
values: { headerRow: 5 },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).rejects.toThrow('Could not retrieve the column names from row 5');
|
||||
});
|
||||
|
||||
it('should handle empty input data gracefully', async () => {
|
||||
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([]);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Return Data Formatting', () => {
|
||||
it('should return original items for node version < 4', async () => {
|
||||
mockNode.typeVersion = 3;
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
|
||||
it('should return mapped data for node version >= 4 with defineBelow mode', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
|
||||
it('should return original items for autoMapInputData mode regardless of node version', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sheet Data Processing', () => {
|
||||
it('should calculate lastRow correctly when sheet has data', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
['Jane', 'jane@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty sheet data for lastRow calculation', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with GoogleSheets Utils', () => {
|
||||
it('should call autoMapInputData with correct parameters', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
|
||||
'Sheet1!A1:B2',
|
||||
mockSheet,
|
||||
[
|
||||
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call mapFields with correct input size', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
|
||||
mockNode,
|
||||
['Name', 'Email'],
|
||||
[{ id: 'name' }, { id: 'email' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single item input', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{ json: { name: 'John', email: 'john@example.com' } },
|
||||
]);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
});
|
||||
|
||||
it('should handle large input data', async () => {
|
||||
const largeInputData = Array.from({ length: 100 }, (_, i) => ({
|
||||
json: { name: `User${i}`, email: `user${i}@example.com` },
|
||||
}));
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(largeInputData);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(100);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined options gracefully', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') return {};
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +1,33 @@
|
|||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import type { INodeTypeBaseDescription } from 'n8n-workflow';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { SwitchV3 } from '../SwitchV3.node';
|
||||
|
||||
describe('Execute Switch Node', () => {
|
||||
new NodeTestHarness().setupTests();
|
||||
describe('SwitchV3 Node', () => {
|
||||
let switchNode: SwitchV3;
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Switch',
|
||||
name: 'n8n-nodes-base.switch',
|
||||
group: ['transform'],
|
||||
description: 'Route items to different outputs',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
switchNode = new SwitchV3(baseDescription);
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Version-specific behavior', () => {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Switch',
|
||||
name: 'n8n-nodes-base.switch',
|
||||
group: ['transform'],
|
||||
description: 'Route items to different outputs',
|
||||
};
|
||||
|
||||
it('should have two numberOutputs parameters with different version conditions', () => {
|
||||
const switchNode = new SwitchV3(baseDescription);
|
||||
const numberOutputsParams = switchNode.description.properties.filter(
|
||||
|
|
@ -54,4 +68,503 @@ describe('Execute Switch Node', () => {
|
|||
expect(switchNode.description.version).toContain(3.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expression Mode Execution', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'switch-node',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route items to correct output in expression mode', async () => {
|
||||
const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, itemIndex: number) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 3,
|
||||
output: itemIndex % 3,
|
||||
};
|
||||
return params[paramName];
|
||||
},
|
||||
);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toEqual({ value: 1 });
|
||||
expect(result[1]).toHaveLength(1);
|
||||
expect(result[1][0].json).toEqual({ value: 2 });
|
||||
expect(result[2]).toHaveLength(1);
|
||||
expect(result[2][0].json).toEqual({ value: 3 });
|
||||
});
|
||||
|
||||
it('should handle multiple items routed to same output', async () => {
|
||||
const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 2,
|
||||
output: 0,
|
||||
};
|
||||
return params[paramName];
|
||||
});
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveLength(3);
|
||||
expect(result[1]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw error for invalid output index', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 2,
|
||||
output: 5,
|
||||
};
|
||||
return params[paramName];
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for negative output index', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 2,
|
||||
output: -1,
|
||||
};
|
||||
return params[paramName];
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty input data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([]);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue('expression');
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rules Mode Execution', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'switch-node',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route items based on matching rules', async () => {
|
||||
const inputData = [
|
||||
{ json: { status: 'active' } },
|
||||
{ json: { status: 'inactive' } },
|
||||
{ json: { status: 'pending' } },
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') {
|
||||
return [
|
||||
{
|
||||
conditions: {
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{$json.status}}',
|
||||
rightValue: 'active',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
{
|
||||
conditions: {
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{$json.status}}',
|
||||
rightValue: 'inactive',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (paramName === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, itemIndex: number, defaultValue: any, options?: any) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') {
|
||||
return [
|
||||
{ conditions: { conditions: [], combinator: 'and' } },
|
||||
{ conditions: { conditions: [], combinator: 'and' } },
|
||||
];
|
||||
}
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName.includes('conditions') && options?.extractValue) {
|
||||
if (itemIndex === 0) return true; // active matches first rule
|
||||
if (itemIndex === 1) return false; // inactive doesn't match first rule
|
||||
return false;
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toEqual({ status: 'active' });
|
||||
expect(result[1]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle fallback output when no rules match', async () => {
|
||||
const inputData = [{ json: { status: 'unknown' } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') {
|
||||
return [{ conditions: { conditions: [], combinator: 'and' } }];
|
||||
}
|
||||
if (paramName === 'options') return { fallbackOutput: 'extra' };
|
||||
if (paramName.includes('conditions') && options?.extractValue) {
|
||||
return false; // No rule matches
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(2); // One rule output + one fallback output
|
||||
expect(result[0]).toHaveLength(0); // No matches for rule
|
||||
expect(result[1]).toHaveLength(1); // Item goes to fallback
|
||||
expect(result[1][0].json).toEqual({ status: 'unknown' });
|
||||
});
|
||||
|
||||
it('should handle allMatchingOutputs option', async () => {
|
||||
const inputData = [{ json: { value: 10 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') {
|
||||
return [
|
||||
{ conditions: { conditions: [], combinator: 'and' } },
|
||||
{ conditions: { conditions: [], combinator: 'and' } },
|
||||
];
|
||||
}
|
||||
if (paramName === 'options') return { allMatchingOutputs: true };
|
||||
if (paramName.includes('conditions') && options?.extractValue) {
|
||||
return true; // Both rules match
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[1]).toHaveLength(1);
|
||||
expect(result[0][0].json).toEqual({ value: 10 });
|
||||
expect(result[1][0].json).toEqual({ value: 10 });
|
||||
});
|
||||
|
||||
it('should skip items when no rules are defined', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') return [];
|
||||
if (paramName === 'options') return {};
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'switch-node',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors with continueOnFail', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'mode') return 'expression';
|
||||
if (paramName === 'numberOutputs') return 1;
|
||||
if (paramName === 'output') {
|
||||
throw new Error('Parameter error');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].json).toHaveProperty('error', 'Parameter error');
|
||||
});
|
||||
|
||||
it('should rethrow NodeOperationError', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
|
||||
throw new NodeOperationError(mockExecuteFunctions.getNode(), 'Invalid parameter');
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ApplicationError with context', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
|
||||
const error = new ApplicationError('Application error');
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
|
||||
it('should wrap generic errors in NodeOperationError', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'mode') return 'expression';
|
||||
if (paramName === 'numberOutputs') return 1;
|
||||
if (paramName === 'output') {
|
||||
throw new Error('Generic error');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load Options', () => {
|
||||
it('should load fallback output options with no rules', async () => {
|
||||
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue([]);
|
||||
|
||||
const result =
|
||||
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
|
||||
mockLoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'None (default)',
|
||||
value: 'none',
|
||||
description: 'Items will be ignored',
|
||||
},
|
||||
{
|
||||
name: 'Extra Output',
|
||||
value: 'extra',
|
||||
description: 'Items will be sent to the extra, separate, output',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should load fallback output options with rules', async () => {
|
||||
const rules = [
|
||||
{ outputKey: 'Rule 1' },
|
||||
{ outputKey: 'Rule 2' },
|
||||
{}, // Rule without outputKey
|
||||
];
|
||||
|
||||
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(rules);
|
||||
|
||||
const result =
|
||||
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
|
||||
mockLoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'None (default)',
|
||||
value: 'none',
|
||||
description: 'Items will be ignored',
|
||||
},
|
||||
{
|
||||
name: 'Extra Output',
|
||||
value: 'extra',
|
||||
description: 'Items will be sent to the extra, separate, output',
|
||||
},
|
||||
{
|
||||
name: 'Output Rule 1',
|
||||
value: 0,
|
||||
description: 'Items will be sent to the same output as when matched rule 1',
|
||||
},
|
||||
{
|
||||
name: 'Output Rule 2',
|
||||
value: 1,
|
||||
description: 'Items will be sent to the same output as when matched rule 2',
|
||||
},
|
||||
{
|
||||
name: 'Output 2',
|
||||
value: 2,
|
||||
description: 'Items will be sent to the same output as when matched rule 3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle null rules parameter', async () => {
|
||||
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(null);
|
||||
|
||||
const result =
|
||||
await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
|
||||
mockLoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'None (default)',
|
||||
value: 'none',
|
||||
description: 'Items will be ignored',
|
||||
},
|
||||
{
|
||||
name: 'Extra Output',
|
||||
value: 'extra',
|
||||
description: 'Items will be sent to the extra, separate, output',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
id: 'switch-node',
|
||||
name: 'Switch',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle items with pairedItem already set', async () => {
|
||||
const inputData = [{ json: { value: 1 }, pairedItem: { item: 5 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 1,
|
||||
output: 0,
|
||||
};
|
||||
return params[paramName];
|
||||
});
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
|
||||
it('should handle output index equal to returnData length', async () => {
|
||||
const inputData = [{ json: { value: 1 } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
mode: 'expression',
|
||||
numberOutputs: 2,
|
||||
output: 2, // Equal to returnData length
|
||||
};
|
||||
return params[paramName];
|
||||
});
|
||||
|
||||
await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle fallback output to existing rule output', async () => {
|
||||
const inputData = [{ json: { status: 'unknown' } }];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
|
||||
if (paramName === 'mode') return 'rules';
|
||||
if (paramName === 'rules.values') {
|
||||
return [{ conditions: { conditions: [], combinator: 'and' } }];
|
||||
}
|
||||
if (paramName === 'options') return { fallbackOutput: 0 };
|
||||
if (paramName.includes('conditions') && options?.extractValue) {
|
||||
return false; // No rule matches
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await switchNode.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toEqual({ status: 'unknown' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue