diff --git a/packages/nodes-base/TESTING.MD b/packages/nodes-base/TESTING.MD new file mode 100644 index 00000000000..d5b12ae605d --- /dev/null +++ b/packages/nodes-base/TESTING.MD @@ -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', + }, + } +``` \ No newline at end of file diff --git a/packages/nodes-base/TESTING_PROMPT.md b/packages/nodes-base/TESTING_PROMPT.md new file mode 100644 index 00000000000..3a9a38c57ad --- /dev/null +++ b/packages/nodes-base/TESTING_PROMPT.md @@ -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(); + +// Webhook functions mock +const mockWebhookFunctions = mock(); + +// Node mock +const mockNode = mock({ + 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({ + getColumns: jest.fn(), + addColumn: jest.fn(), + updateRow: jest.fn(), +}); + +// Redis client mocking +const mockClient = mock(); +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(); + 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(); + 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; + const apiRequestSpy = jest.spyOn(GenericFunctions, 'apiRequest'); + + beforeEach(() => { + node = new TestNode(); + mockExecuteFunctions = mockDeep(); + 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'); + }); + }); + }); +}); +``` + diff --git a/packages/nodes-base/TESTING_PROMPT_WORKFLOW.md b/packages/nodes-base/TESTING_PROMPT_WORKFLOW.md new file mode 100644 index 00000000000..9dcd4e1c536 --- /dev/null +++ b/packages/nodes-base/TESTING_PROMPT_WORKFLOW.md @@ -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 + }); + }); +}); +``` diff --git a/packages/nodes-base/nodes/Google/Analytics/test/v2/GoogleAnalyticsV2.node.test.ts b/packages/nodes-base/nodes/Google/Analytics/test/v2/GoogleAnalyticsV2.node.test.ts new file mode 100644 index 00000000000..a757f5781ae --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/test/v2/GoogleAnalyticsV2.node.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Analytics/test/v2/report-ga4-get.workflow.json b/packages/nodes-base/nodes/Google/Analytics/test/v2/report-ga4-get.workflow.json new file mode 100644 index 00000000000..85b75ca1404 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/test/v2/report-ga4-get.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" + } +} diff --git a/packages/nodes-base/nodes/Google/Analytics/test/v2/report-universal-get.workflow.json b/packages/nodes-base/nodes/Google/Analytics/test/v2/report-universal-get.workflow.json new file mode 100644 index 00000000000..55f523dec91 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/test/v2/report-universal-get.workflow.json @@ -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" + } +} diff --git a/packages/nodes-base/nodes/Google/Analytics/test/v2/useractivity-search.workflow.json b/packages/nodes-base/nodes/Google/Analytics/test/v2/useractivity-search.workflow.json new file mode 100644 index 00000000000..aae42ce449a --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/test/v2/useractivity-search.workflow.json @@ -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" + } +} diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/sheet/append.operation.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/sheet/append.operation.test.ts new file mode 100644 index 00000000000..6c60dc0e660 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/actions/sheet/append.operation.test.ts @@ -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; + let mockSheet: jest.Mocked; + let mockNode: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockExecuteFunctions = mockDeep(); + + mockNode = mock({ + id: 'test-node', + name: 'Google Sheets Append', + type: 'n8n-nodes-base.googleSheets', + typeVersion: 3, + position: [0, 0], + parameters: {}, + }); + + mockSheet = mock(); + 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 = { + 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(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts b/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts index bbac96233bd..2c489fdac59 100644 --- a/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts +++ b/packages/nodes-base/nodes/Switch/V3/test/switch.node.test.ts @@ -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; + let mockLoadOptionsFunctions: jest.Mocked; + + 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(); + mockLoadOptionsFunctions = mockDeep(); + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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' }); + }); + }); });