test: Add tests for nodes (no-changelog) (#20732)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Shireen Missi
2025-11-03 15:44:04 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent 941a54e723
commit 1d1655685f
35 changed files with 12448 additions and 22 deletions
@@ -0,0 +1,295 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('AWS ELB', () => {
const credentials = {
aws: {
region: 'us-east-1',
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
};
describe('Load Balancer Operations', () => {
describe('Create Load Balancer', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=CreateLoadBalancer&Version=2015-12-01&IpAddressType=ipv4&Name=test-lb&Scheme=internet-facing&Type=application&Subnets.member.1=subnet-12345&Subnets.member.2=subnet-67890&SecurityGroups.member.1=sg-12345&Tags.member.1.Key=Environment&Tags.member.1.Value=test',
)
.reply(
200,
`
<CreateLoadBalancerResponse>
<CreateLoadBalancerResult>
<LoadBalancers>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456</LoadBalancerArn>
<LoadBalancerName>test-lb</LoadBalancerName>
<Scheme>internet-facing</Scheme>
<Type>application</Type>
<State>
<Code>provisioning</Code>
</State>
</member>
</LoadBalancers>
</CreateLoadBalancerResult>
</CreateLoadBalancerResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['create-load-balancer.workflow.json'],
});
});
describe('Get Load Balancer', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=DescribeLoadBalancers&Version=2015-12-01&LoadBalancerArns.member.1=arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456',
)
.reply(
200,
`
<DescribeLoadBalancersResponse>
<DescribeLoadBalancersResult>
<LoadBalancers>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456</LoadBalancerArn>
<LoadBalancerName>test-lb</LoadBalancerName>
<Scheme>internet-facing</Scheme>
<Type>application</Type>
<State>
<Code>active</Code>
</State>
</member>
</LoadBalancers>
</DescribeLoadBalancersResult>
</DescribeLoadBalancersResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['get-load-balancer.workflow.json'],
});
});
describe('Get Many Load Balancers', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock.get('/?Action=DescribeLoadBalancers&Version=2015-12-01&PageSize=10').reply(
200,
`
<DescribeLoadBalancersResponse>
<DescribeLoadBalancersResult>
<LoadBalancers>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb-1/1234567890123456</LoadBalancerArn>
<LoadBalancerName>test-lb-1</LoadBalancerName>
<Scheme>internet-facing</Scheme>
<Type>application</Type>
</member>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-lb-2/2345678901234567</LoadBalancerArn>
<LoadBalancerName>test-lb-2</LoadBalancerName>
<Scheme>internal</Scheme>
<Type>network</Type>
</member>
</LoadBalancers>
</DescribeLoadBalancersResult>
</DescribeLoadBalancersResponse>
`,
);
mock
.get(
'/?Action=DescribeLoadBalancers&Version=2015-12-01&Names.member.1=test-lb-1&Names.member.2=test-lb-2',
)
.reply(
200,
`
<DescribeLoadBalancersResponse>
<DescribeLoadBalancersResult>
<LoadBalancers>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb-1/1234567890123456</LoadBalancerArn>
<LoadBalancerName>test-lb-1</LoadBalancerName>
<Scheme>internet-facing</Scheme>
<Type>application</Type>
</member>
<member>
<LoadBalancerArn>arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-lb-2/2345678901234567</LoadBalancerArn>
<LoadBalancerName>test-lb-2</LoadBalancerName>
<Scheme>internal</Scheme>
<Type>network</Type>
</member>
</LoadBalancers>
</DescribeLoadBalancersResult>
</DescribeLoadBalancersResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: [
'get-many-load-balancers.workflow.json',
'get-many-load-balancers-with-names.workflow.json',
],
});
});
describe('Delete Load Balancer', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=DeleteLoadBalancer&Version=2015-12-01&LoadBalancerArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456',
)
.reply(
200,
`
<DeleteLoadBalancerResponse>
<DeleteLoadBalancerResult/>
</DeleteLoadBalancerResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['delete-load-balancer.workflow.json'],
});
});
});
describe('Listener Certificate Operations', () => {
describe('Add Listener Certificate', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=AddListenerCertificates&Version=2015-12-01&Certificates.member.1.CertificateArn=arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012&ListenerArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2',
)
.reply(
200,
`
<AddListenerCertificatesResponse>
<AddListenerCertificatesResult>
<Certificates>
<member>
<CertificateArn>arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012</CertificateArn>
<IsDefault>false</IsDefault>
</member>
</Certificates>
</AddListenerCertificatesResult>
</AddListenerCertificatesResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['add-listener-certificate.workflow.json'],
});
});
describe('Get Many Listener Certificates', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=DescribeListenerCertificates&Version=2015-12-01&ListenerArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2&PageSize=10',
)
.reply(
200,
`
<DescribeListenerCertificatesResponse>
<DescribeListenerCertificatesResult>
<Certificates>
<member>
<CertificateArn>arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012</CertificateArn>
<IsDefault>false</IsDefault>
</member>
<member>
<CertificateArn>arn:aws:acm:us-east-1:123456789012:certificate/87654321-4321-4321-4321-210987654321</CertificateArn>
<IsDefault>true</IsDefault>
</member>
</Certificates>
</DescribeListenerCertificatesResult>
</DescribeListenerCertificatesResponse>
`,
);
mock
.get(
'/?Action=DescribeListenerCertificates&Version=2015-12-01&ListenerArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2',
)
.reply(
200,
`
<DescribeListenerCertificatesResponse>
<DescribeListenerCertificatesResult>
<Certificates>
<member>
<CertificateArn>arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012</CertificateArn>
<IsDefault>false</IsDefault>
</member>
<member>
<CertificateArn>arn:aws:acm:us-east-1:123456789012:certificate/87654321-4321-4321-4321-210987654321</CertificateArn>
<IsDefault>true</IsDefault>
</member>
</Certificates>
</DescribeListenerCertificatesResult>
</DescribeListenerCertificatesResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: [
'get-many-listener-certificates.workflow.json',
'get-many-listener-certificates-all.workflow.json',
],
});
});
describe('Remove Listener Certificate', () => {
beforeAll(() => {
const mock = nock('https://elasticloadbalancing.us-east-1.amazonaws.com');
mock
.get(
'/?Action=RemoveListenerCertificates&Version=2015-12-01&Certificates.member.1.CertificateArn=arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012&ListenerArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2',
)
.reply(
200,
`
<RemoveListenerCertificatesResponse>
<RemoveListenerCertificatesResult/>
</RemoveListenerCertificatesResponse>
`,
);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['remove-listener-certificate.workflow.json'],
});
});
});
});
@@ -0,0 +1,289 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import {
awsApiRequest,
awsApiRequestREST,
awsApiRequestSOAP,
awsApiRequestSOAPAllItems,
} from '../GenericFunctions';
jest.mock('xml2js', () => ({
parseString: jest.fn(),
}));
import { parseString as parseXml } from 'xml2js';
describe('ELB GenericFunctions', () => {
describe('awsApiRequest', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test ELB Node',
type: 'n8n-nodes-base.awsElb',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
});
it('should make successful API request with basic parameters', async () => {
const mockResponse = { success: true };
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(mockResponse);
const result = await awsApiRequest.call(
mockExecuteFunctions,
'elasticloadbalancing',
'GET',
'/test-path',
);
expect(result).toEqual(mockResponse);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
qs: expect.objectContaining({
service: 'elasticloadbalancing',
path: '/test-path',
}),
method: 'GET',
}),
);
});
it('should handle API errors', async () => {
const apiError = new Error('API Error');
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockRejectedValue(apiError);
await expect(
awsApiRequest.call(mockExecuteFunctions, 'elasticloadbalancing', 'GET', '/test-path'),
).rejects.toThrow(NodeApiError);
});
});
describe('awsApiRequestREST', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should parse valid JSON response', async () => {
const jsonResponse = '{"result": "success", "data": [1,2,3]}';
const expectedResult = { result: 'success', data: [1, 2, 3] };
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(jsonResponse);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'elasticloadbalancing',
'GET',
'/test-path',
);
expect(result).toEqual(expectedResult);
});
it('should return raw response when JSON parsing fails', async () => {
const rawResponse = 'not json data';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(rawResponse);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'elasticloadbalancing',
'GET',
'/test-path',
);
expect(result).toBe(rawResponse);
});
});
describe('awsApiRequestSOAP', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
const mockParseXml = parseXml as jest.MockedFunction<typeof parseXml>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should parse XML response correctly', async () => {
const xmlResponse =
'<ListBucketsResult><Buckets><Bucket><Name>test-bucket</Name></Bucket></Buckets></ListBucketsResult>';
const expectedParsedData = {
ListBucketsResult: {
Buckets: {
Bucket: {
Name: 'test-bucket',
},
},
},
};
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
xmlResponse,
);
mockParseXml.mockImplementation((_xml, _options, callback) => {
callback(null, expectedParsedData);
});
const result = await awsApiRequestSOAP.call(
mockExecuteFunctions,
'elasticloadbalancing',
'GET',
'/test-path',
);
expect(result).toEqual(expectedParsedData);
});
it('should handle XML parsing errors', async () => {
const xmlResponse = 'invalid xml';
const xmlError = new Error('Invalid XML');
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
xmlResponse,
);
mockParseXml.mockImplementation((_xml, _options, callback) => {
callback(xmlError, null);
});
const result = await awsApiRequestSOAP.call(
mockExecuteFunctions,
'elasticloadbalancing',
'GET',
'/test-path',
);
expect(result).toBeInstanceOf(Error);
});
});
describe('awsApiRequestSOAPAllItems', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
const mockParseXml = parseXml as jest.MockedFunction<typeof parseXml>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
describe('pagination patterns', () => {
it('should handle DescribeLoadBalancers pagination', async () => {
const mockCredentials = { region: 'us-east-1' };
const xmlResponse = {
DescribeLoadBalancersResponse: {
DescribeLoadBalancersResult: {
LoadBalancerDescriptions: [
{ LoadBalancerName: 'elb-1' },
{ LoadBalancerName: 'elb-2' },
],
},
},
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
'<xml>response</xml>',
);
mockParseXml.mockImplementation((_xml, _options, callback) => {
callback(null, xmlResponse);
});
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancerDescriptions',
'elasticloadbalancing',
'POST',
'/describe-load-balancers',
);
expect(result).toEqual([{ LoadBalancerName: 'elb-1' }, { LoadBalancerName: 'elb-2' }]);
});
it('should handle DescribeTargetGroups with nested properties', async () => {
const mockCredentials = { region: 'us-east-1' };
const firstResponse = {
DescribeTargetGroupsResponse: {
DescribeTargetGroupsResult: {
TargetGroups: [
{ TargetGroupName: 'tg-1', Protocol: 'HTTP' },
{ TargetGroupName: 'tg-2', Protocol: 'HTTPS' },
],
NextMarker: 'marker123',
},
},
};
const secondResponse = {
DescribeTargetGroupsResponse: {
DescribeTargetGroupsResult: {
TargetGroups: [{ TargetGroupName: 'tg-3', Protocol: 'TCP' }],
},
},
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock)
.mockResolvedValueOnce('<xml>first-response</xml>')
.mockResolvedValueOnce('<xml>second-response</xml>');
mockParseXml
.mockImplementationOnce((_xml, _options, callback) => callback(null, firstResponse))
.mockImplementationOnce((_xml, _options, callback) => callback(null, secondResponse));
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'DescribeTargetGroupsResponse.DescribeTargetGroupsResult.TargetGroups',
'elasticloadbalancing',
'POST',
'/describe-target-groups',
);
expect(result).toEqual([
{ TargetGroupName: 'tg-1', Protocol: 'HTTP' },
{ TargetGroupName: 'tg-2', Protocol: 'HTTPS' },
{ TargetGroupName: 'tg-3', Protocol: 'TCP' },
]);
});
});
describe('error handling', () => {
it('should handle service error responses', async () => {
const mockCredentials = { region: 'us-east-1' };
const elbError = new Error(
'LoadBalancerNotFound: The specified load balancer does not exist',
);
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockRejectedValue(
elbError,
);
await expect(
awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'DescribeLoadBalancersResponse.DescribeLoadBalancersResult.LoadBalancerDescriptions',
'elasticloadbalancing',
'POST',
'/describe-load-balancers',
),
).rejects.toThrow('LoadBalancerNotFound: The specified load balancer does not exist');
});
});
});
});
@@ -0,0 +1,29 @@
# AWS ELB Test Implementation Notes
This document explains intentional test data adjustments made to match the current implementation behavior.
## Known Implementation Issues
### 1. Typo in Remove Listener Certificate Operation
**File**: `remove-listener-certificate.workflow.json`
**Issue**: The response contains `"sucess": true` instead of `"success": true`
**Root Cause**: Spelling error in the node implementation
**Test Adjustment**: The pinData uses the misspelled version to match actual output
**TODO**: Fix the typo in the AWS ELB node implementation and update test accordingly
### 2. Boolean Values Returned as Strings
**File**: `get-many-listener-certificates-all.workflow.json`
**Issue**: Boolean values like `IsDefault` are returned as strings ("true"/"false") instead of actual booleans
**Root Cause**: AWS API response parsing returns string values rather than converting to boolean types
**Test Adjustment**: The pinData uses string values ("true"/"false") instead of boolean values (true/false)
**Note**: This reflects how the AWS API response is actually parsed and returned by the node
## Test Data Maintenance
When updating these test files:
1. Verify the actual node implementation output before changing expected results
2. If implementation bugs are fixed, update both the node code and corresponding test data
3. Consider backward compatibility when making changes that affect output format
@@ -0,0 +1,60 @@
{
"name": "AWS ELB Add Listener Certificate Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "listenerCertificate",
"operation": "add",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"listenerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2",
"certificateId": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Add Listener Certificate",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Add Listener Certificate": [
{
"json": {
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012",
"IsDefault": "false"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Add Listener Certificate",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,78 @@
{
"name": "AWS ELB Create Load Balancer Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "loadBalancer",
"operation": "create",
"ipAddressType": "ipv4",
"name": "test-lb",
"schema": "internet-facing",
"type": "application",
"subnets": ["subnet-12345", "subnet-67890"],
"additionalFields": {
"securityGroups": ["sg-12345"],
"tagsUi": {
"tagValues": [
{
"key": "Environment",
"value": "test"
}
]
}
}
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Create Load Balancer",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Create Load Balancer": [
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"LoadBalancerName": "test-lb",
"Scheme": "internet-facing",
"Type": "application",
"State": {
"Code": "provisioning"
}
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Create Load Balancer",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,57 @@
{
"name": "AWS ELB Delete Load Balancer Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "loadBalancer",
"operation": "delete",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456"
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Delete Load Balancer",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Delete Load Balancer": [
{
"json": {
"success": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Delete Load Balancer",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,63 @@
{
"name": "AWS ELB Get Load Balancer Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "loadBalancer",
"operation": "get",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456"
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Get Load Balancer",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Get Load Balancer": [
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"LoadBalancerName": "test-lb",
"Scheme": "internet-facing",
"Type": "application",
"State": {
"Code": "active"
}
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Load Balancer",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,66 @@
{
"name": "AWS ELB Get Many Listener Certificates All Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "listenerCertificate",
"operation": "getMany",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"listenerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2",
"returnAll": true
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Get Many Listener Certificates All",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Get Many Listener Certificates All": [
{
"json": {
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012",
"IsDefault": "false"
}
},
{
"json": {
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/87654321-4321-4321-4321-210987654321",
"IsDefault": "true"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Many Listener Certificates All",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,67 @@
{
"name": "AWS ELB Get Many Listener Certificates Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "listenerCertificate",
"operation": "getMany",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"listenerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2",
"returnAll": false,
"limit": 10
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Get Many Listener Certificates",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Get Many Listener Certificates": [
{
"json": {
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012",
"IsDefault": "false"
}
},
{
"json": {
"CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/87654321-4321-4321-4321-210987654321",
"IsDefault": "true"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Many Listener Certificates",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,71 @@
{
"name": "AWS ELB Get Many Load Balancers with Names Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "loadBalancer",
"operation": "getMany",
"returnAll": true,
"filters": {
"names": "test-lb-1,test-lb-2"
}
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Get Many Load Balancers with Names",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Get Many Load Balancers with Names": [
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb-1/1234567890123456",
"LoadBalancerName": "test-lb-1",
"Scheme": "internet-facing",
"Type": "application"
}
},
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-lb-2/2345678901234567",
"LoadBalancerName": "test-lb-2",
"Scheme": "internal",
"Type": "network"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Many Load Balancers with Names",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,69 @@
{
"name": "AWS ELB Get Many Load Balancers Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "loadBalancer",
"operation": "getMany",
"returnAll": false,
"limit": 10
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Get Many Load Balancers",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Get Many Load Balancers": [
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb-1/1234567890123456",
"LoadBalancerName": "test-lb-1",
"Scheme": "internet-facing",
"Type": "application"
}
},
{
"json": {
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-lb-2/2345678901234567",
"LoadBalancerName": "test-lb-2",
"Scheme": "internal",
"Type": "network"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Many Load Balancers",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,59 @@
{
"name": "AWS ELB Remove Listener Certificate Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "listenerCertificate",
"operation": "remove",
"loadBalancerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-lb/1234567890123456",
"listenerId": "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-lb/1234567890123456/f2f7dc8efc522ab2",
"certificateId": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"
},
"type": "n8n-nodes-base.awsElb",
"typeVersion": 1,
"position": [200, 0],
"id": "node-id",
"name": "Remove Listener Certificate",
"credentials": {
"aws": {
"id": "credential-id",
"name": "Test AWS Credentials"
}
}
}
],
"pinData": {
"Remove Listener Certificate": [
{
"json": {
"sucess": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Remove Listener Certificate",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,339 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
import {
awsApiRequest,
awsApiRequestREST,
awsApiRequestSOAP,
awsApiRequestSOAPAllItems,
} from '../../V1/GenericFunctions';
describe('AWS S3 V1 GenericFunctions', () => {
describe('awsApiRequest', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should make AWS API request with basic parameters', async () => {
const mockResponse = { success: true };
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(mockResponse);
const result = await awsApiRequest.call(
mockExecuteFunctions,
's3',
'GET',
'/bucket',
'',
{},
{},
{},
'us-east-1',
);
expect(result).toEqual(mockResponse);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
qs: expect.objectContaining({
service: 's3',
path: '/bucket',
}),
method: 'GET',
body: '',
url: '',
}),
);
});
it('should handle query parameters correctly', async () => {
const mockResponse = { data: 'test' };
const queryParams = { 'list-type': '2', 'max-keys': '10' };
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(mockResponse);
await awsApiRequest.call(
mockExecuteFunctions,
's3',
'GET',
'/bucket',
'',
queryParams,
{},
{},
);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
qs: expect.objectContaining({
...queryParams,
service: 's3',
path: '/bucket',
query: queryParams,
}),
}),
);
});
});
describe('awsApiRequestREST', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should parse valid JSON response', async () => {
const jsonString = JSON.stringify({ id: '123', name: 'test' });
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(jsonString);
const result = await awsApiRequestREST.call(mockExecuteFunctions, 's3', 'GET', '/bucket');
expect(result).toEqual({ id: '123', name: 'test' });
});
it('should return raw response when JSON parsing fails', async () => {
const rawResponse = 'not valid json';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(rawResponse);
const result = await awsApiRequestREST.call(mockExecuteFunctions, 's3', 'GET', '/bucket');
expect(result).toBe(rawResponse);
});
});
describe('awsApiRequestSOAP', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should parse valid XML response', async () => {
const xmlResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>test-bucket</Name></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(xmlResponse);
const result = await awsApiRequestSOAP.call(mockExecuteFunctions, 's3', 'GET', '/bucket');
expect(result).toHaveProperty('ListBucketResult.Name', 'test-bucket');
});
it('should return error when XML parsing fails', async () => {
const invalidXml = 'not valid xml';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(invalidXml);
const result = await awsApiRequestSOAP.call(mockExecuteFunctions, 's3', 'GET', '/bucket');
expect(result).toBeInstanceOf(Error);
});
});
describe('awsApiRequestSOAPAllItems', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should collect all items from single page response', async () => {
const xmlResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file1.txt</Key><Size>1024</Size></Contents><Contents><Key>file2.txt</Key><Size>2048</Size></Contents><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(xmlResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
);
expect(result).toHaveLength(2);
expect(result).toEqual([
{ Key: 'file1.txt', Size: '1024' },
{ Key: 'file2.txt', Size: '2048' },
]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
it('should handle empty response', async () => {
const xmlResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(xmlResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
);
expect(result).toEqual([]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
it('should handle pagination with NextContinuationToken', async () => {
const firstPageResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file1.txt</Key><Size>1024</Size></Contents><IsTruncated>true</IsTruncated><NextContinuationToken>token123</NextContinuationToken></ListBucketResult>';
const secondPageResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file2.txt</Key><Size>2048</Size></Contents><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
'',
{},
);
expect(result).toHaveLength(2);
expect(result).toEqual([
{ Key: 'file1.txt', Size: '1024' },
{ Key: 'file2.txt', Size: '2048' },
]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(2);
// Verify that continuation token was passed in the second call
expect(mockRequestWithAuth).toHaveBeenNthCalledWith(
2,
'aws',
expect.objectContaining({
qs: expect.objectContaining({
'continuation-token': 'token123',
}),
}),
);
});
it('should respect limit parameter and stop early', async () => {
const firstPageResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file1.txt</Key><Size>1024</Size></Contents><Contents><Key>file2.txt</Key><Size>2048</Size></Contents><IsTruncated>true</IsTruncated><NextContinuationToken>token123</NextContinuationToken></ListBucketResult>';
const secondPageResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file3.txt</Key><Size>3072</Size></Contents><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
'',
{ limit: 2 },
);
// Should stop after collecting 2 items, even though there are more pages
expect(result).toHaveLength(2);
expect(result).toEqual([
{ Key: 'file1.txt', Size: '1024' },
{ Key: 'file2.txt', Size: '2048' },
]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
it('should handle single item response (not an array)', async () => {
const xmlResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>single-file.txt</Key><Size>512</Size></Contents><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(xmlResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
);
expect(result).toHaveLength(1);
expect(result).toEqual([{ Key: 'single-file.txt', Size: '512' }]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
it('should handle error responses from SOAP parsing', async () => {
const invalidXml = 'invalid xml response';
const mockRequestWithAuth = mockExecuteFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(invalidXml);
const result = await awsApiRequestSOAPAllItems.call(
mockExecuteFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
);
// When XML parsing fails, awsApiRequestSOAP returns an Error object
// and awsApiRequestSOAPAllItems should handle this gracefully
expect(result).toEqual([]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
it('should work with different function contexts', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
const xmlResponse =
'<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Contents><Key>file1.txt</Key></Contents><IsTruncated>false</IsTruncated></ListBucketResult>';
const mockRequestWithAuth = mockLoadOptionsFunctions.helpers
.requestWithAuthentication as jest.Mock;
mockRequestWithAuth.mockResolvedValue(xmlResponse);
const result = await awsApiRequestSOAPAllItems.call(
mockLoadOptionsFunctions,
'ListBucketResult.Contents',
's3',
'GET',
'/bucket',
);
expect(result).toEqual([{ Key: 'file1.txt' }]);
expect(mockRequestWithAuth).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,629 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { AwsLambda } from '../AwsLambda.node';
import * as GenericFunctions from '../GenericFunctions';
describe('AwsLambda', () => {
let node: AwsLambda;
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
const awsApiRequestRESTSpy = jest.spyOn(GenericFunctions, 'awsApiRequestREST');
beforeEach(() => {
node = new AwsLambda();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
jest.clearAllMocks();
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'AWS Lambda',
type: 'n8n-nodes-base.awsLambda',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
(mockExecuteFunctions.helpers.constructExecutionMetaData as jest.Mock).mockImplementation(
(data: any, meta: any) => {
return [
{
...data[0],
pairedItem: meta?.itemData?.item ?? 0,
},
];
},
);
(mockExecuteFunctions.helpers.returnJsonArray as jest.Mock).mockImplementation((data: any) => [
{ json: data },
]);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Load Options Methods', () => {
describe('getFunctions', () => {
it('should load functions without pagination', async () => {
const mockFunctions = {
Functions: [
{
FunctionName: 'test-function-1',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-1',
},
{
FunctionName: 'test-function-2',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-2',
},
],
};
awsApiRequestRESTSpy.mockResolvedValue(mockFunctions);
const result = await node.methods.loadOptions.getFunctions.call(mockLoadOptionsFunctions);
expect(result).toEqual([
{
name: 'test-function-1',
value: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-1',
},
{
name: 'test-function-2',
value: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-2',
},
]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'GET',
'/2015-03-31/functions/',
);
});
it('should handle paginated function list', async () => {
const mockFirstPage = {
Functions: [
{
FunctionName: 'test-function-1',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-1',
},
],
NextMarker: 'marker123',
};
const mockSecondPage = {
Functions: [
{
FunctionName: 'test-function-2',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-2',
},
],
};
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockFirstPage)
.mockResolvedValueOnce(mockSecondPage);
const result = await node.methods.loadOptions.getFunctions.call(mockLoadOptionsFunctions);
expect(result).toEqual([
{
name: 'test-function-1',
value: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-1',
},
{
name: 'test-function-2',
value: 'arn:aws:lambda:us-east-1:123456789012:function:test-function-2',
},
]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledTimes(2);
expect(awsApiRequestRESTSpy).toHaveBeenNthCalledWith(
2,
'lambda',
'GET',
'/2015-03-31/functions/?MaxItems=50&Marker=marker123',
);
});
it('should handle multiple pages with NextMarker', async () => {
const mockFirstPage = {
Functions: [
{
FunctionName: 'function-1',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:function-1',
},
],
NextMarker: 'marker1',
};
const mockSecondPage = {
Functions: [
{
FunctionName: 'function-2',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:function-2',
},
],
NextMarker: 'marker2',
};
const mockThirdPage = {
Functions: [
{
FunctionName: 'function-3',
FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:function-3',
},
],
};
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockFirstPage)
.mockResolvedValueOnce(mockSecondPage)
.mockResolvedValueOnce(mockThirdPage);
const result = await node.methods.loadOptions.getFunctions.call(mockLoadOptionsFunctions);
expect(result).toHaveLength(3);
expect(awsApiRequestRESTSpy).toHaveBeenCalledTimes(3);
expect(awsApiRequestRESTSpy).toHaveBeenNthCalledWith(
3,
'lambda',
'GET',
'/2015-03-31/functions/?MaxItems=50&Marker=marker2',
);
});
it('should handle empty function list', async () => {
const mockEmptyResponse = {
Functions: [],
};
awsApiRequestRESTSpy.mockResolvedValue(mockEmptyResponse);
const result = await node.methods.loadOptions.getFunctions.call(mockLoadOptionsFunctions);
expect(result).toEqual([]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'GET',
'/2015-03-31/functions/',
);
});
});
});
describe('Execute Method', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'arn:aws:lambda:us-east-1:123456789012:function:test-function',
qualifier: '$LATEST',
invocationType: 'RequestResponse',
payload: JSON.stringify({ test: 'data' }),
};
return mockParams[paramName as keyof typeof mockParams];
});
});
describe('Successful Lambda Invocation', () => {
it('should invoke lambda function with RequestResponse type', async () => {
const mockResponse = {
StatusCode: 200,
result: { success: true, data: 'processed' },
};
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { result: mockResponse },
pairedItem: 0,
},
],
},
],
]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:test-function/invocations?Qualifier=$LATEST',
JSON.stringify({ test: 'data' }),
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should invoke lambda function with Event type', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: 'PROD',
invocationType: 'Event',
payload: JSON.stringify({ async: true }),
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = {
StatusCode: 202,
};
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { result: mockResponse },
pairedItem: 0,
},
],
},
],
]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=PROD',
JSON.stringify({ async: true }),
{
'X-Amz-Invocation-Type': 'Event',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should handle empty payload', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: '$LATEST',
invocationType: 'RequestResponse',
payload: '',
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = { result: 'success' };
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { result: mockResponse },
pairedItem: 0,
},
],
},
],
]);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=$LATEST',
'',
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should process multiple input items', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { item: 1 } },
{ json: { item: 2 } },
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, itemIndex) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: '$LATEST',
invocationType: 'RequestResponse',
payload: JSON.stringify({ item: itemIndex + 1 }),
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse1 = { result: 'item1-processed' };
const mockResponse2 = { result: 'item2-processed' };
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toHaveLength(2);
expect(result[0][0].json[0]).toEqual({ json: { result: mockResponse1 }, pairedItem: 0 });
expect(result[0][0].json[1]).toEqual({ json: { result: mockResponse2 }, pairedItem: 1 });
expect(awsApiRequestRESTSpy).toHaveBeenCalledTimes(2);
});
});
describe('Lambda Function Errors', () => {
it('should handle lambda function execution errors', async () => {
const mockErrorResponse = {
errorMessage: 'Function execution failed',
errorType: 'Runtime.HandlerNotFound',
stackTrace: ['at /var/task/index.js:10:5'],
};
awsApiRequestRESTSpy.mockResolvedValue(mockErrorResponse);
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(NodeApiError);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:test-function/invocations?Qualifier=$LATEST',
JSON.stringify({ test: 'data' }),
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should handle API errors when continueOnFail is true', async () => {
const apiError = new Error('Function not found');
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
awsApiRequestRESTSpy.mockRejectedValue(apiError);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { error: 'Function not found' },
pairedItem: 0,
},
],
},
],
]);
});
it('should throw API errors when continueOnFail is false', async () => {
const apiError = new Error('Invalid function name');
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
awsApiRequestRESTSpy.mockRejectedValue(apiError);
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Invalid function name',
);
});
it('should handle mixed success and error scenarios with continueOnFail', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([
{ json: { item: 1 } },
{ json: { item: 2 } },
{ json: { item: 3 } },
]);
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
const mockSuccessResponse = { result: 'success' };
const apiError = new Error('Function error');
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockSuccessResponse)
.mockRejectedValueOnce(apiError)
.mockResolvedValueOnce(mockSuccessResponse);
const result = await node.execute.call(mockExecuteFunctions);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toHaveLength(3);
expect(result[0][0].json[0]).toEqual({
json: { result: mockSuccessResponse },
pairedItem: 0,
});
expect(result[0][0].json[1]).toEqual({ json: { error: 'Function error' }, pairedItem: 1 });
expect(result[0][0].json[2]).toEqual({
json: { result: mockSuccessResponse },
pairedItem: 2,
});
});
});
describe('Different Qualifier Values', () => {
it('should handle custom version qualifier', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: '1',
invocationType: 'RequestResponse',
payload: JSON.stringify({ version: 1 }),
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = { result: 'version-1-response' };
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
await node.execute.call(mockExecuteFunctions);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=1',
JSON.stringify({ version: 1 }),
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should handle alias qualifier', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: 'PROD',
invocationType: 'RequestResponse',
payload: JSON.stringify({ environment: 'production' }),
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = { result: 'prod-response' };
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
await node.execute.call(mockExecuteFunctions);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=PROD',
JSON.stringify({ environment: 'production' }),
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
});
describe('Payload Handling', () => {
it('should handle complex JSON payload', async () => {
const complexPayload = {
user: { id: 123, name: 'John Doe' },
data: [1, 2, 3],
metadata: { timestamp: '2023-01-01T00:00:00Z' },
};
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: '$LATEST',
invocationType: 'RequestResponse',
payload: JSON.stringify(complexPayload),
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = { result: 'complex-processed' };
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
await node.execute.call(mockExecuteFunctions);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=$LATEST',
JSON.stringify(complexPayload),
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
it('should handle string payload', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
const mockParams = {
operation: 'invoke',
function: 'test-function',
qualifier: '$LATEST',
invocationType: 'RequestResponse',
payload: 'plain string payload',
};
return mockParams[paramName as keyof typeof mockParams];
});
const mockResponse = { result: 'string-processed' };
awsApiRequestRESTSpy.mockResolvedValue(mockResponse);
await node.execute.call(mockExecuteFunctions);
expect(awsApiRequestRESTSpy).toHaveBeenCalledWith(
'lambda',
'POST',
'/2015-03-31/functions/test-function/invocations?Qualifier=$LATEST',
'plain string payload',
{
'X-Amz-Invocation-Type': 'RequestResponse',
'Content-Type': 'application/x-amz-json-1.0',
},
);
});
});
describe('Edge Cases', () => {
it('should handle null response from Lambda', async () => {
awsApiRequestRESTSpy.mockResolvedValue(null);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { result: null },
pairedItem: 0,
},
],
},
],
]);
});
it('should handle undefined response from Lambda', async () => {
awsApiRequestRESTSpy.mockResolvedValue(undefined);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [
{
json: { result: undefined },
pairedItem: 0,
},
],
},
],
]);
});
it('should handle empty input data', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([]);
const result = await node.execute.call(mockExecuteFunctions);
expect(result).toEqual([
[
{
json: [],
},
],
]);
expect(awsApiRequestRESTSpy).not.toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,643 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions, IWebhookFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { awsApiRequest, awsApiRequestREST, awsApiRequestSOAP } from '../GenericFunctions';
describe('AWS GenericFunctions', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
let mockWebhookFunctions: jest.Mocked<IWebhookFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
mockWebhookFunctions = mockDeep<IWebhookFunctions>();
jest.clearAllMocks();
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.aws',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
});
afterEach(() => {
jest.resetAllMocks();
});
describe('awsApiRequest', () => {
describe('Successful Requests', () => {
it('should make successful API request with basic parameters', async () => {
const mockCredentials = {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
region: 'us-east-1',
};
const mockResponse = { success: true, data: 'test response' };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
const result = await awsApiRequest.call(
mockExecuteFunctions,
's3',
'GET',
'/test-path',
undefined,
{ 'Content-Type': 'application/json' },
);
expect(result).toEqual(mockResponse);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('aws');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith('aws', {
qs: {
service: 's3',
path: '/test-path',
},
method: 'GET',
body: undefined,
url: '',
headers: { 'Content-Type': 'application/json' },
region: 'us-east-1',
});
});
it('should handle lambda service with raw body', async () => {
const mockCredentials = {
region: 'us-west-2',
};
const testBody = '{"test": "data"}';
const mockResponse = { result: 'lambda response' };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
const result = await awsApiRequest.call(
mockExecuteFunctions,
'lambda',
'POST',
'/2015-03-31/functions/test/invocations',
testBody,
);
expect(result).toEqual(mockResponse);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith('aws', {
qs: {
service: 'lambda',
path: '/2015-03-31/functions/test/invocations',
},
method: 'POST',
body: testBody,
url: '',
headers: undefined,
region: 'us-west-2',
});
});
it('should stringify body for non-lambda services', async () => {
const mockCredentials = { region: 'eu-central-1' };
const testBody = { name: 'Test', value: 123 };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
await awsApiRequest.call(
mockExecuteFunctions,
's3',
'PUT',
'/bucket/object',
testBody as any,
);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
body: JSON.stringify(testBody),
}),
);
});
it('should work with ILoadOptionsFunctions context', async () => {
const mockCredentials = { region: 'ca-central-1' };
const mockResponse = ['option1', 'option2'];
mockLoadOptionsFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
mockLoadOptionsFunctions.getNode.mockReturnValue({
id: 'load-node',
name: 'Load Node',
type: 'n8n-nodes-base.aws',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const result = await awsApiRequest.call(
mockLoadOptionsFunctions,
'ec2',
'GET',
'/instances',
);
expect(result).toEqual(mockResponse);
});
it('should work with IWebhookFunctions context', async () => {
const mockCredentials = { region: 'sa-east-1' };
const mockResponse = { webhook: 'processed' };
mockWebhookFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockWebhookFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
mockWebhookFunctions.getNode.mockReturnValue({
id: 'webhook-node',
name: 'Webhook Node',
type: 'n8n-nodes-base.aws',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const result = await awsApiRequest.call(
mockWebhookFunctions,
'apigateway',
'GET',
'/webhooks',
);
expect(result).toEqual(mockResponse);
});
it('should handle undefined body parameter', async () => {
const mockCredentials = { region: 'us-east-1' };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
await awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/list-buckets');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
body: undefined,
}),
);
});
it('should handle undefined headers parameter', async () => {
const mockCredentials = { region: 'us-east-1' };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
await awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/list-buckets');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
headers: undefined,
}),
);
});
});
describe('Error Handling', () => {
it('should handle credential retrieval errors', async () => {
const credentialError = new Error('Failed to get AWS credentials');
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
await expect(
awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/test-path'),
).rejects.toThrow('Failed to get AWS credentials');
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('aws');
});
it('should wrap API errors in NodeApiError', async () => {
const mockCredentials = { region: 'us-east-1' };
const apiError = new Error('AWS API Error');
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockRejectedValue(
apiError,
);
await expect(
awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/test-path'),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalled();
});
it('should handle authentication errors', async () => {
const mockCredentials = { region: 'us-east-1' };
const authError = {
message: 'Invalid AWS credentials',
statusCode: 403,
response: { body: 'Forbidden' },
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockRejectedValue(
authError,
);
await expect(
awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/test-path'),
).rejects.toThrow(NodeApiError);
});
});
describe('Edge Cases', () => {
it('should handle empty service name', async () => {
const mockCredentials = { region: 'us-east-1' };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
await awsApiRequest.call(mockExecuteFunctions, '', 'GET', '/test');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
qs: {
service: '',
path: '/test',
},
}),
);
});
it('should handle null credentials region', async () => {
const mockCredentials = { region: null };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
await awsApiRequest.call(mockExecuteFunctions, 's3', 'GET', '/test');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
region: null,
}),
);
});
it('should handle various HTTP methods', async () => {
const mockCredentials = { region: 'us-east-1' };
const mockResponse = { success: true };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
mockResponse,
);
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
for (const method of methods) {
await awsApiRequest.call(mockExecuteFunctions, 's3', method, '/test');
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'aws',
expect.objectContaining({
method,
}),
);
}
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(5);
});
});
});
describe('awsApiRequestREST', () => {
describe('JSON Response Handling', () => {
it('should parse valid JSON response', async () => {
const mockCredentials = { region: 'us-east-1' };
const jsonResponse = '{"result": "success", "data": [1, 2, 3]}';
const expectedParsed = { result: 'success', data: [1, 2, 3] };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
jsonResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'lambda',
'POST',
'/2015-03-31/functions/test/invocations',
'{"test": "data"}',
);
expect(result).toEqual(expectedParsed);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalled();
});
it('should return raw response when JSON parsing fails', async () => {
const mockCredentials = { region: 'us-east-1' };
const invalidJsonResponse = 'Not valid JSON content';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
invalidJsonResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
's3',
'GET',
'/bucket/object.txt',
);
expect(result).toBe(invalidJsonResponse);
});
it('should handle empty string response', async () => {
const mockCredentials = { region: 'us-east-1' };
const emptyResponse = '';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
emptyResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'lambda',
'POST',
'/test',
);
expect(result).toBe(emptyResponse);
});
it('should handle null response', async () => {
const mockCredentials = { region: 'us-east-1' };
const nullResponse = null;
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
nullResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
's3',
'DELETE',
'/bucket',
);
expect(result).toBeNull();
});
it('should handle number response', async () => {
const mockCredentials = { region: 'us-east-1' };
const numberResponse = 42;
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
numberResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'cloudwatch',
'GET',
'/metrics',
);
expect(result).toBe(numberResponse);
});
it('should handle boolean response', async () => {
const mockCredentials = { region: 'us-east-1' };
const booleanResponse = true;
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
booleanResponse,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
's3',
'HEAD',
'/bucket/exists',
);
expect(result).toBe(booleanResponse);
});
it('should work with ILoadOptionsFunctions context', async () => {
const mockCredentials = { region: 'us-east-1' };
const jsonResponse = '["option1", "option2", "option3"]';
mockLoadOptionsFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
jsonResponse,
);
mockLoadOptionsFunctions.getNode.mockReturnValue({
id: 'load-node',
name: 'Load Node',
type: 'n8n-nodes-base.aws',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const result = await awsApiRequestREST.call(
mockLoadOptionsFunctions,
'ec2',
'GET',
'/instances',
);
expect(result).toEqual(['option1', 'option2', 'option3']);
});
});
describe('Error Propagation', () => {
it('should propagate errors from awsApiRequest', async () => {
const apiError = new Error('API request failed');
mockExecuteFunctions.getCredentials.mockRejectedValue(apiError);
await expect(
awsApiRequestREST.call(mockExecuteFunctions, 's3', 'GET', '/test'),
).rejects.toThrow('API request failed');
});
it('should handle malformed JSON gracefully', async () => {
const mockCredentials = { region: 'us-east-1' };
const malformedJson = '{"incomplete": json';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
malformedJson,
);
const result = await awsApiRequestREST.call(
mockExecuteFunctions,
'lambda',
'POST',
'/test',
);
expect(result).toBe(malformedJson);
});
});
});
describe('awsApiRequestSOAP', () => {
describe('XML Response Handling', () => {
it('should parse valid XML response', async () => {
const mockCredentials = { region: 'us-east-1' };
const xmlResponse = '<response><status>success</status><data>test</data></response>';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
xmlResponse,
);
const result = await awsApiRequestSOAP.call(
mockExecuteFunctions,
'ses',
'POST',
'/send-email',
'<email>test</email>',
);
expect(result).toEqual({
response: {
status: 'success',
data: 'test',
},
});
});
it('should return raw response when XML parsing fails', async () => {
const mockCredentials = { region: 'us-east-1' };
const invalidXmlResponse = 'Not valid XML content';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
invalidXmlResponse,
);
const result = await awsApiRequestSOAP.call(
mockExecuteFunctions,
'ses',
'POST',
'/send-email',
);
expect(result).toBe(invalidXmlResponse);
});
it('should handle empty XML response', async () => {
const mockCredentials = { region: 'us-east-1' };
const emptyResponse = '';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
emptyResponse,
);
const result = await awsApiRequestSOAP.call(mockExecuteFunctions, 'ses', 'GET', '/status');
expect(result).toBeNull();
});
it('should handle complex nested XML with actual parsing', async () => {
const mockCredentials = { region: 'us-east-1' };
const complexXml =
'<ListQueuesResult><QueueUrl>url1</QueueUrl><QueueUrl>url2</QueueUrl></ListQueuesResult>';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockExecuteFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
complexXml,
);
const result = await awsApiRequestSOAP.call(
mockExecuteFunctions,
'sqs',
'GET',
'/list-queues',
);
expect(result).toEqual({
ListQueuesResult: {
QueueUrl: ['url1', 'url2'],
},
});
});
it('should work with different function contexts', async () => {
const mockCredentials = { region: 'us-east-1' };
const xmlResponse = '<options><item>opt1</item><item>opt2</item></options>';
mockLoadOptionsFunctions.getCredentials.mockResolvedValue(mockCredentials);
(mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue(
xmlResponse,
);
mockLoadOptionsFunctions.getNode.mockReturnValue({
id: 'load-node',
name: 'Load Node',
type: 'n8n-nodes-base.aws',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const result = await awsApiRequestSOAP.call(
mockLoadOptionsFunctions,
'ses',
'GET',
'/options',
);
expect(result).toEqual({
options: {
item: ['opt1', 'opt2'],
},
});
});
it('should propagate errors from awsApiRequest', async () => {
const apiError = new Error('SOAP API request failed');
mockExecuteFunctions.getCredentials.mockRejectedValue(apiError);
await expect(
awsApiRequestSOAP.call(mockExecuteFunctions, 'ses', 'POST', '/test'),
).rejects.toThrow('SOAP API request failed');
});
});
});
});
@@ -0,0 +1,610 @@
import { mockDeep } from 'jest-mock-extended';
import moment from 'moment-timezone';
import type { IPollFunctions, INode, ILoadOptionsFunctions, IDataObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { GoogleDriveTrigger } from '../GoogleDriveTrigger.node';
import * as GenericFunctions from '../v1/GenericFunctions';
import * as listSearch from '../v2/methods/listSearch';
jest.mock('../v1/GenericFunctions', () => ({
extractId: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(),
}));
jest.mock('../v2/methods/listSearch', () => ({
fileSearch: jest.fn(),
folderSearch: jest.fn(),
}));
describe('GoogleDriveTrigger', () => {
let trigger: GoogleDriveTrigger;
let mockPollFunctions: jest.Mocked<IPollFunctions>;
let mockNode: INode;
const extractIdSpy = jest.spyOn(GenericFunctions, 'extractId');
const googleApiRequestSpy = jest.spyOn(GenericFunctions, 'googleApiRequest');
const googleApiRequestAllItemsSpy = jest.spyOn(GenericFunctions, 'googleApiRequestAllItems');
beforeEach(() => {
trigger = new GoogleDriveTrigger();
mockPollFunctions = mockDeep<IPollFunctions>();
mockNode = {
id: 'test-node-id',
name: 'Google Drive Trigger Test',
type: 'n8n-nodes-base.googleDriveTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
jest.clearAllMocks();
mockPollFunctions.getNode.mockReturnValue(mockNode);
mockPollFunctions.getWorkflowStaticData.mockReturnValue({});
mockPollFunctions.getMode.mockReturnValue('trigger');
(mockPollFunctions.helpers.returnJsonArray as jest.Mock).mockImplementation((data: unknown[]) =>
data.map((item: unknown, index: number) => ({ json: item, pairedItem: { item: index } })),
);
extractIdSpy.mockImplementation((id) => id);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Methods', () => {
it('should have correct list search methods', () => {
expect(trigger.methods?.listSearch?.fileSearch).toBe(listSearch.fileSearch);
expect(trigger.methods?.listSearch?.folderSearch).toBe(listSearch.folderSearch);
});
it('should have correct load options methods', () => {
expect(trigger.methods?.loadOptions?.getDrives).toBeDefined();
});
describe('getDrives', () => {
it('should return drives with root option', async () => {
const mockDrives = [
{ id: 'drive1', name: 'My Drive 1' },
{ id: 'drive2', name: 'My Drive 2' },
];
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
googleApiRequestAllItemsSpy.mockResolvedValue(mockDrives);
const result = await trigger.methods.loadOptions.getDrives.call(mockLoadOptionsFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'drives',
'GET',
'/drive/v3/drives',
);
expect(result).toEqual([
{ name: 'Root', value: 'root' },
{ name: 'My Drive 1', value: 'drive1' },
{ name: 'My Drive 2', value: 'drive2' },
]);
});
it('should handle empty drives list', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
googleApiRequestAllItemsSpy.mockResolvedValue([]);
const result = await trigger.methods.loadOptions.getDrives.call(mockLoadOptionsFunctions);
expect(result).toEqual([{ name: 'Root', value: 'root' }]);
});
});
});
describe('Poll Function - Parameter Setup', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
});
it('should handle specific file trigger', async () => {
const now = moment().utc();
const webhookData = { lastTimeChecked: now.clone().subtract(1, 'hour').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
const mockFiles = [
{
id: 'test-file-id',
name: 'Test File',
modifiedTime: now.format(),
},
];
extractIdSpy.mockReturnValue('test-file-id');
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
includeItemsFromAllDrives: true,
supportsAllDrives: true,
spaces: 'appDataFolder, drive',
corpora: 'allDrives',
q: expect.stringContaining('trashed = false'),
fields: 'nextPageToken, files(*)',
}),
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('test-file-id');
});
it('should handle specific folder trigger for file created', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'fileCreated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
const now = moment().utc();
const webhookData = { lastTimeChecked: now.clone().subtract(1, 'hour').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
const mockFiles = [
{
id: 'new-file-id',
name: 'New File',
createdTime: now.format(),
},
];
extractIdSpy.mockReturnValue('test-folder-id');
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.stringContaining("'test-folder-id' in parents"),
}),
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
});
it('should filter by file type when specified', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'fileCreated',
folderToWatch: 'test-folder-id',
options: { fileType: 'application/vnd.google-apps.document' },
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.stringContaining("mimeType = 'application/vnd.google-apps.document'"),
}),
);
});
it('should handle folder events with folder mime type filter', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'folderCreated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.stringContaining("mimeType = 'application/vnd.google-apps.folder'"),
}),
);
});
it('should handle watch folder updated event', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'watchFolderUpdated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
const mockFiles = [
{
id: 'test-folder-id',
name: 'Test Folder',
modifiedTime: moment().utc().format(),
},
];
extractIdSpy.mockReturnValue('test-folder-id');
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.not.stringContaining('in parents'),
}),
);
expect(result).toBeDefined();
});
it('should use createdTime for Created events and modifiedTime for Updated events', async () => {
const now = moment().utc();
const webhookData: IDataObject = {
lastTimeChecked: now.clone().subtract(1, 'hour').format(),
};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
// Test fileCreated event uses createdTime
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, unknown> = {
triggerOn: 'specificFolder',
event: 'fileCreated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.stringContaining('createdTime >'),
}),
);
// Reset mock
googleApiRequestAllItemsSpy.mockClear();
// Test fileUpdated event uses modifiedTime
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, unknown> = {
triggerOn: 'specificFolder',
event: 'fileUpdated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestAllItemsSpy).toHaveBeenCalledWith(
'files',
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.stringContaining('modifiedTime >'),
}),
);
});
});
describe('Poll Function - Manual Mode', () => {
beforeEach(() => {
mockPollFunctions.getMode.mockReturnValue('manual');
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
options: {},
};
return params[paramName] ?? '';
});
});
it('should fetch single file in manual mode', async () => {
const mockResponse = {
files: [
{
id: 'test-file-id',
name: 'Test File',
},
],
};
googleApiRequestSpy.mockResolvedValue(mockResponse);
const result = await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestSpy).toHaveBeenCalledWith(
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
pageSize: 1,
}),
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('test-file-id');
});
it('should throw NodeApiError when no data found in manual mode', async () => {
googleApiRequestSpy.mockResolvedValue({ files: [] });
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(NodeApiError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(
'No data with the current filter could be found',
);
});
});
describe('Poll Function - State Management', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
options: {},
};
return params[paramName] ?? '';
});
});
it('should update lastTimeChecked in webhook data', async () => {
const mockWebhookData = { lastTimeChecked: moment().subtract(1, 'day').format() };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWebhookData);
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(mockWebhookData.lastTimeChecked).toBeDefined();
expect(moment(mockWebhookData.lastTimeChecked).isValid()).toBe(true);
});
it('should use current time as startDate when no lastTimeChecked exists', async () => {
const mockWebhookData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWebhookData);
googleApiRequestAllItemsSpy.mockResolvedValue([]);
await trigger.poll.call(mockPollFunctions);
expect(mockWebhookData.lastTimeChecked).toBeDefined();
expect(moment(mockWebhookData.lastTimeChecked as string).isValid()).toBe(true);
});
});
describe('Poll Function - Error Handling', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
options: {},
};
return params[paramName] ?? '';
});
});
it('should handle API request errors', async () => {
const apiError = new Error('API Error');
googleApiRequestAllItemsSpy.mockRejectedValue(apiError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('API Error');
});
it('should handle invalid extractId results', async () => {
extractIdSpy.mockImplementation(() => {
throw new Error('Invalid ID');
});
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('Invalid ID');
});
});
describe('Poll Function - Edge Cases', () => {
it('should return null when no files found in trigger mode', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
options: {},
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
it('should handle empty files array', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'fileCreated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
googleApiRequestAllItemsSpy.mockResolvedValue([]);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
it('should handle files without required fields gracefully', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'test-file-id',
options: {},
};
return params[paramName] ?? '';
});
const mockFiles = [
{
id: 'test-file-id',
// Missing name and other fields
},
];
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('test-file-id');
});
it('should skip time filtering in manual mode', async () => {
mockPollFunctions.getMode.mockReturnValue('manual');
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'fileCreated',
folderToWatch: 'test-folder-id',
options: {},
};
return params[paramName] ?? '';
});
const mockResponse = { files: [{ id: 'test-file', name: 'Test' }] };
googleApiRequestSpy.mockResolvedValue(mockResponse);
await trigger.poll.call(mockPollFunctions);
expect(googleApiRequestSpy).toHaveBeenCalledWith(
'GET',
'/drive/v3/files',
{},
expect.objectContaining({
q: expect.not.stringMatching(/createdTime|modifiedTime/),
}),
);
});
});
describe('Poll Function - File Filtering', () => {
it('should filter specific file results correctly', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFile',
event: 'fileUpdated',
fileToWatch: 'target-file-id',
options: {},
};
return params[paramName] ?? '';
});
const mockFiles = [
{ id: 'target-file-id', name: 'Target File' },
{ id: 'other-file-id', name: 'Other File' },
];
extractIdSpy.mockReturnValue('target-file-id');
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('target-file-id');
});
it('should filter specific folder results correctly for watchFolderUpdated', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, any> = {
triggerOn: 'specificFolder',
event: 'watchFolderUpdated',
folderToWatch: 'target-folder-id',
options: {},
};
return params[paramName] ?? '';
});
const mockFiles = [
{ id: 'target-folder-id', name: 'Target Folder' },
{ id: 'other-folder-id', name: 'Other Folder' },
];
extractIdSpy.mockReturnValue('target-folder-id');
googleApiRequestAllItemsSpy.mockResolvedValue(mockFiles);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
expect(result![0][0].json.id).toBe('target-folder-id');
});
});
});
@@ -0,0 +1,565 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { getGoogleAccessToken } from '../../../GenericFunctions';
import {
googleApiRequest,
jsonToDocument,
documentToJson,
fullDocumentToJson,
} from '../GenericFunctions';
// Mock the getGoogleAccessToken function
jest.mock('../../../GenericFunctions', () => ({
getGoogleAccessToken: jest.fn(),
}));
const mockGetGoogleAccessToken = getGoogleAccessToken as jest.MockedFunction<
typeof getGoogleAccessToken
>;
describe('GoogleFirebaseCloudFirestore > GenericFunctions', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
let mockNode: INode;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'Test CloudFirestore Node',
type: 'n8n-nodes-base.googleFirebaseCloudFirestore',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
jest.clearAllMocks();
});
describe('googleApiRequest', () => {
const mockRequestWithAuth = jest.fn();
beforeEach(() => {
mockExecuteFunctions.helpers.requestWithAuthentication = mockRequestWithAuth;
mockLoadOptionsFunctions.helpers.requestWithAuthentication = mockRequestWithAuth;
});
describe('OAuth2 authentication', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('oAuth2Api');
mockLoadOptionsFunctions.getNodeParameter.mockReturnValue('oAuth2Api');
});
it('should make successful API request with OAuth2', async () => {
const mockResponse = { success: true, data: 'test' };
mockRequestWithAuth.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(
mockExecuteFunctions,
'GET',
'/databases/test/documents/users',
);
expect(result).toEqual(mockResponse);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleFirebaseCloudFirestoreOAuth2Api',
expect.objectContaining({
method: 'GET',
uri: 'https://firestore.googleapis.com/v1/projects/databases/test/documents/users',
headers: {
'Content-Type': 'application/json',
},
qs: {},
qsStringifyOptions: {
arrayFormat: 'repeat',
},
json: true,
}),
);
});
it('should remove empty body for GET requests', async () => {
const mockResponse = { success: true };
mockRequestWithAuth.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'GET', '/test', {});
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleFirebaseCloudFirestoreOAuth2Api',
expect.not.objectContaining({ body: expect.anything() }),
);
});
it('should include body for POST requests', async () => {
const mockResponse = { success: true };
const requestBody = { name: 'Test', value: 'data' };
mockRequestWithAuth.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'POST', '/test', requestBody);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleFirebaseCloudFirestoreOAuth2Api',
expect.objectContaining({
method: 'POST',
body: requestBody,
}),
);
});
it('should include query parameters', async () => {
const mockResponse = { success: true };
const queryParams = { pageSize: 100, orderBy: 'name' };
mockRequestWithAuth.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'GET', '/test', {}, queryParams);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleFirebaseCloudFirestoreOAuth2Api',
expect.objectContaining({
qs: queryParams,
}),
);
});
it('should use custom URI when provided', async () => {
const mockResponse = { success: true };
const customUri = 'https://custom.googleapis.com/v1/test';
mockRequestWithAuth.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'GET', '/test', {}, {}, customUri);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleFirebaseCloudFirestoreOAuth2Api',
expect.objectContaining({
uri: customUri,
}),
);
});
});
describe('Service Account authentication', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('serviceAccount');
mockLoadOptionsFunctions.getNodeParameter.mockReturnValue('serviceAccount');
mockExecuteFunctions.getCredentials.mockResolvedValue({
email: 'test@project.iam.gserviceaccount.com',
privateKey: 'test-key',
});
mockLoadOptionsFunctions.getCredentials.mockResolvedValue({
email: 'test@project.iam.gserviceaccount.com',
privateKey: 'test-key',
});
});
it('should make API request with service account', async () => {
const mockResponse = { success: true };
const mockAccessToken = 'mock-access-token';
mockGetGoogleAccessToken.mockResolvedValue({ access_token: mockAccessToken });
mockRequestWithAuth.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockExecuteFunctions, 'GET', '/test');
expect(result).toEqual(mockResponse);
expect(mockGetGoogleAccessToken).toHaveBeenCalledWith(
expect.objectContaining({
email: 'test@project.iam.gserviceaccount.com',
privateKey: 'test-key',
}),
'firestore',
);
expect(mockRequestWithAuth).toHaveBeenCalledWith(
'googleApi',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${mockAccessToken}`,
},
}),
);
});
it('should handle service account credential errors', async () => {
const credentialError = new Error('Invalid service account');
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
await expect(googleApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeApiError,
);
});
it('should handle access token errors', async () => {
const tokenError = new Error('Failed to get access token');
mockGetGoogleAccessToken.mockRejectedValue(tokenError);
await expect(googleApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeApiError,
);
});
});
describe('error handling', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('oAuth2Api');
});
it('should throw NodeAPIError on API failure', async () => {
const apiError = new Error('API request failed');
mockRequestWithAuth.mockRejectedValue(apiError);
await expect(googleApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeApiError,
);
expect(mockExecuteFunctions.getNode).toHaveBeenCalled();
});
it('should work with ILoadOptionsFunctions', async () => {
const mockResponse = { options: ['option1', 'option2'] };
mockRequestWithAuth.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockLoadOptionsFunctions, 'GET', '/options');
expect(result).toEqual(mockResponse);
});
});
});
describe('jsonToDocument', () => {
it('should convert boolean values', () => {
expect(jsonToDocument(true as any)).toEqual({ booleanValue: true });
expect(jsonToDocument(false as any)).toEqual({ booleanValue: false });
// Note: The current implementation preserves string 'true'/'false' as strings in booleanValue
// While Firestore API spec expects actual boolean values for booleanValue fields,
// this behavior is maintained for backward compatibility and to avoid breaking existing workflows
// that may depend on this string preservation behavior in certain edge cases
expect(jsonToDocument('true')).toEqual({ booleanValue: 'true' });
expect(jsonToDocument('false')).toEqual({ booleanValue: 'false' });
});
it('should convert null values', () => {
expect(jsonToDocument(null as any)).toEqual({ nullValue: null });
});
it('should convert numeric values', () => {
expect(jsonToDocument(42)).toEqual({ integerValue: 42 });
expect(jsonToDocument(3.14)).toEqual({ doubleValue: 3.14 });
// Note: The current implementation preserves numeric strings as strings in Firestore document values
// While Firestore API spec typically expects numeric types for doubleValue/integerValue,
// this behavior is maintained for backward compatibility and to avoid breaking existing workflows
// that may depend on string preservation in certain edge cases
expect(jsonToDocument('123')).toEqual({ integerValue: '123' });
expect(jsonToDocument('123.45')).toEqual({ doubleValue: '123.45' });
});
it('should convert string values', () => {
expect(jsonToDocument('hello world')).toEqual({ stringValue: 'hello world' });
expect(jsonToDocument('')).toEqual({ stringValue: '' });
});
it('should convert date strings to timestamps', () => {
const dateString = '2023-01-01T10:00:00.000Z';
const result = jsonToDocument(dateString);
expect(result).toHaveProperty('timestampValue');
expect(result.timestampValue).toBe('2023-01-01T10:00:00.000Z');
});
it('should convert arrays', () => {
const array = [1, 'test', true, null] as any;
const result = jsonToDocument(array);
expect(result).toEqual({
arrayValue: {
values: [
{ integerValue: 1 },
{ stringValue: 'test' },
{ booleanValue: true },
{ nullValue: null },
],
},
});
});
it('should convert objects', () => {
const obj = {
name: 'John',
age: 30,
active: true,
metadata: null,
};
const result = jsonToDocument(obj);
expect(result).toEqual({
mapValue: {
fields: {
name: { stringValue: 'John' },
age: { integerValue: 30 },
active: { booleanValue: true },
metadata: { nullValue: null },
},
},
});
});
it('should convert nested objects', () => {
const nestedObj = {
user: {
profile: {
name: 'John',
settings: {
notifications: true,
},
},
},
};
const result = jsonToDocument(nestedObj);
expect(result).toEqual({
mapValue: {
fields: {
user: {
mapValue: {
fields: {
profile: {
mapValue: {
fields: {
name: { stringValue: 'John' },
settings: {
mapValue: {
fields: {
notifications: { booleanValue: true },
},
},
},
},
},
},
},
},
},
},
},
});
});
it('should handle edge cases', () => {
expect(jsonToDocument(0)).toEqual({ integerValue: 0 });
expect(jsonToDocument(NaN as any)).toEqual({});
expect(jsonToDocument(undefined as any)).toEqual({});
});
it('should skip unsafe object properties', () => {
const obj = { safeProperty: 'safe' } as any;
Object.defineProperty(obj, '__proto__', {
value: 'unsafe',
enumerable: true,
});
const result = jsonToDocument(obj);
expect(result).toEqual({
mapValue: {
fields: {
safeProperty: { stringValue: 'safe' },
},
},
});
});
});
describe('documentToJson', () => {
it('should handle undefined input', () => {
expect(documentToJson(undefined as any)).toEqual({});
});
it('should convert primitive document values', () => {
expect(documentToJson({ stringValue: 'hello' })).toBe('hello');
expect(documentToJson({ integerValue: 42 })).toBe(42);
expect(documentToJson({ doubleValue: 3.14 })).toBe(3.14);
expect(documentToJson({ booleanValue: true })).toBe(true);
expect(documentToJson({ nullValue: null })).toBe(null);
expect(documentToJson({ timestampValue: '2023-01-01T00:00:00.000Z' })).toBe(
'2023-01-01T00:00:00.000Z',
);
});
it('should convert map values', () => {
const mapValue = {
mapValue: {
fields: {
name: { stringValue: 'John' },
age: { integerValue: 30 },
},
},
};
expect(documentToJson(mapValue)).toEqual({
name: 'John',
age: 30,
});
});
it('should convert array values', () => {
const arrayValue = {
arrayValue: {
values: [{ stringValue: 'item1' }, { integerValue: 42 }, { booleanValue: true }],
},
};
expect(documentToJson(arrayValue)).toEqual(['item1', 42, true]);
});
it('should convert nested structures', () => {
const nested = {
user: {
mapValue: {
fields: {
name: { stringValue: 'John' },
tags: {
arrayValue: {
values: [{ stringValue: 'developer' }, { stringValue: 'javascript' }],
},
},
metadata: {
mapValue: {
fields: {
created: { timestampValue: '2023-01-01T00:00:00.000Z' },
active: { booleanValue: true },
},
},
},
},
},
},
};
const result = documentToJson(nested);
expect(result).toEqual({
user: {
name: 'John',
tags: ['developer', 'javascript'],
metadata: {
created: '2023-01-01T00:00:00.000Z',
active: true,
},
},
});
});
it('should handle empty arrays', () => {
const emptyArray = { arrayValue: { values: [] } };
expect(documentToJson(emptyArray)).toEqual([]);
});
it('should handle arrays with undefined values', () => {
const arrayWithUndefined = { arrayValue: {} };
expect(documentToJson(arrayWithUndefined)).toEqual([]);
});
it('should handle empty map values', () => {
const emptyMap = { mapValue: { fields: {} } };
expect(documentToJson(emptyMap)).toEqual({});
});
it('should handle map values with undefined fields', () => {
const mapWithUndefinedFields = { mapValue: {} };
expect(documentToJson(mapWithUndefinedFields)).toEqual({});
});
});
describe('fullDocumentToJson', () => {
it('should handle undefined input', () => {
expect(fullDocumentToJson(undefined as any)).toBeUndefined();
});
it('should extract metadata and fields', () => {
const document = {
name: 'projects/test/databases/(default)/documents/users/123',
id: 'user123',
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T11:00:00.000Z',
fields: {
name: { stringValue: 'John Doe' },
email: { stringValue: 'john@example.com' },
age: { integerValue: 30 },
active: { booleanValue: true },
},
};
const result = fullDocumentToJson(document);
expect(result).toEqual({
_name: 'projects/test/databases/(default)/documents/users/123',
_id: 'user123',
_createTime: '2023-01-01T10:00:00.000Z',
_updateTime: '2023-01-01T11:00:00.000Z',
name: 'John Doe',
email: 'john@example.com',
age: 30,
active: true,
});
});
it('should handle document without fields', () => {
const document = {
name: 'projects/test/databases/(default)/documents/empty/123',
id: 'empty123',
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T11:00:00.000Z',
};
const result = fullDocumentToJson(document);
expect(result).toEqual({
_name: 'projects/test/databases/(default)/documents/empty/123',
_id: 'empty123',
_createTime: '2023-01-01T10:00:00.000Z',
_updateTime: '2023-01-01T11:00:00.000Z',
});
});
it('should handle complex nested document', () => {
const document = {
name: 'projects/test/databases/(default)/documents/complex/123',
id: 'complex123',
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T11:00:00.000Z',
fields: {
profile: {
mapValue: {
fields: {
name: { stringValue: 'John' },
preferences: {
mapValue: {
fields: {
theme: { stringValue: 'dark' },
notifications: { booleanValue: true },
},
},
},
},
},
},
tags: {
arrayValue: {
values: [{ stringValue: 'developer' }, { stringValue: 'admin' }],
},
},
},
};
const result = fullDocumentToJson(document);
expect(result).toEqual({
_name: 'projects/test/databases/(default)/documents/complex/123',
_id: 'complex123',
_createTime: '2023-01-01T10:00:00.000Z',
_updateTime: '2023-01-01T11:00:00.000Z',
profile: {
name: 'John',
preferences: {
theme: 'dark',
notifications: true,
},
},
tags: ['developer', 'admin'],
});
});
});
});
@@ -0,0 +1,75 @@
{
"name": "CloudFirestore Collection Get All Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"authentication": "googleFirebaseCloudFirestoreOAuth2Api",
"resource": "document",
"operation": "getAll",
"projectId": "test-project",
"database": "(default)",
"collection": "users"
},
"type": "n8n-nodes-base.googleFirebaseCloudFirestore",
"typeVersion": 1.1,
"position": [200, 0],
"id": "cloudFirestore-getAll",
"name": "Get All Documents",
"credentials": {
"googleFirebaseCloudFirestoreOAuth2Api": {
"id": "cloudFirestore-oauth-id",
"name": "CloudFirestore OAuth2"
}
}
}
],
"pinData": {
"Get All Documents": [
{
"json": {
"_name": "projects/test-project/databases/(default)/documents/users/user1",
"_id": "user1",
"_createTime": "2023-01-01T10:00:00.000Z",
"_updateTime": "2023-01-01T10:00:00.000Z",
"name": "John Doe",
"email": "john@example.com"
}
},
{
"json": {
"_name": "projects/test-project/databases/(default)/documents/users/user2",
"_id": "user2",
"_createTime": "2023-01-01T10:00:00.000Z",
"_updateTime": "2023-01-01T10:00:00.000Z",
"name": "Jane Smith",
"email": "jane@example.com"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get All Documents",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,49 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleFirebaseCloudFirestore', () => {
const credentials = {
googleFirebaseCloudFirestoreOAuth2Api: {
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
beforeAll(() => {
const mock = nock('https://firestore.googleapis.com');
mock
.get('/v1/projects/test-project/databases/(default)/documents/users')
.query({ pageSize: 100 })
.reply(200, {
documents: [
{
name: 'projects/test-project/databases/(default)/documents/users/user1',
id: 'user1',
fields: {
name: { stringValue: 'John Doe' },
email: { stringValue: 'john@example.com' },
},
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T10:00:00.000Z',
},
{
name: 'projects/test-project/databases/(default)/documents/users/user2',
id: 'user2',
fields: {
name: { stringValue: 'Jane Smith' },
email: { stringValue: 'jane@example.com' },
},
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T10:00:00.000Z',
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['collection-getAll.workflow.json'],
});
});
@@ -0,0 +1,61 @@
{
"name": "CloudFirestore Document Delete Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"authentication": "googleFirebaseCloudFirestoreOAuth2Api",
"resource": "document",
"operation": "delete",
"projectId": "test-project",
"database": "(default)",
"collection": "users",
"documentId": "test-doc-id"
},
"type": "n8n-nodes-base.googleFirebaseCloudFirestore",
"typeVersion": 1.1,
"position": [200, 0],
"id": "cloudFirestore-delete",
"name": "Delete Document",
"credentials": {
"googleFirebaseCloudFirestoreOAuth2Api": {
"id": "cloudFirestore-oauth-id",
"name": "CloudFirestore OAuth2"
}
}
}
],
"pinData": {
"Delete Document": [
{
"json": {
"success": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Delete Document",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,68 @@
{
"name": "CloudFirestore Document Get Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"authentication": "googleFirebaseCloudFirestoreOAuth2Api",
"resource": "document",
"operation": "get",
"projectId": "test-project",
"database": "(default)",
"collection": "users",
"documentId": "test-doc-id"
},
"type": "n8n-nodes-base.googleFirebaseCloudFirestore",
"typeVersion": 1.1,
"position": [200, 0],
"id": "cloudFirestore-get",
"name": "Get Document",
"credentials": {
"googleFirebaseCloudFirestoreOAuth2Api": {
"id": "cloudFirestore-oauth-id",
"name": "CloudFirestore OAuth2"
}
}
}
],
"pinData": {
"Get Document": [
{
"json": {
"_name": "projects/test-project/databases/(default)/documents/users/test-doc-id",
"_id": "test-doc-id",
"_createTime": "2023-01-01T10:00:00.000Z",
"_updateTime": "2023-01-01T10:00:00.000Z",
"name": "John Doe",
"email": "john@example.com",
"age": "30",
"active": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Get Document",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,77 @@
{
"name": "CloudFirestore Document Query Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"authentication": "googleFirebaseCloudFirestoreOAuth2Api",
"resource": "document",
"operation": "query",
"projectId": "test-project",
"database": "(default)",
"query": "{\"structuredQuery\": {\"where\": {\"fieldFilter\": {\"field\": {\"fieldPath\": \"active\"}, \"op\": \"EQUAL\", \"value\": {\"booleanValue\": true}}}, \"from\": [{\"collectionId\": \"users\"}]}}"
},
"type": "n8n-nodes-base.googleFirebaseCloudFirestore",
"typeVersion": 1.1,
"position": [200, 0],
"id": "cloudFirestore-query",
"name": "Query Documents",
"credentials": {
"googleFirebaseCloudFirestoreOAuth2Api": {
"id": "cloudFirestore-oauth-id",
"name": "CloudFirestore OAuth2"
}
}
}
],
"pinData": {
"Query Documents": [
{
"json": {
"_name": "projects/test-project/databases/(default)/documents/users/user1",
"_id": "user1",
"_createTime": "2023-01-01T10:00:00.000Z",
"_updateTime": "2023-01-01T10:00:00.000Z",
"name": "John Doe",
"email": "john@example.com",
"active": true
}
},
{
"json": {
"_name": "projects/test-project/databases/(default)/documents/users/user2",
"_id": "user2",
"_createTime": "2023-01-01T10:00:00.000Z",
"_updateTime": "2023-01-01T10:00:00.000Z",
"name": "Jane Smith",
"email": "jane@example.com",
"active": true
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Query Documents",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,65 @@
{
"name": "CloudFirestore Document Update Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"authentication": "googleFirebaseCloudFirestoreOAuth2Api",
"resource": "document",
"operation": "upsert",
"projectId": "test-project",
"database": "(default)",
"collection": "users",
"updateKey": "documentId",
"columns": "name,email,age,active"
},
"type": "n8n-nodes-base.googleFirebaseCloudFirestore",
"typeVersion": 1.1,
"position": [200, 0],
"id": "cloudFirestore-update",
"name": "Update Document",
"credentials": {
"googleFirebaseCloudFirestoreOAuth2Api": {
"id": "cloudFirestore-oauth-id",
"name": "CloudFirestore OAuth2"
}
}
}
],
"pinData": {
"Update Document": [
{
"json": {
"status": {
"code": 0
},
"updateTime": "2023-01-01T11:00:00.000Z"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Update Document",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,25 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleFirebaseCloudFirestore', () => {
const credentials = {
googleFirebaseCloudFirestoreOAuth2Api: {
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
beforeAll(() => {
const mock = nock('https://firestore.googleapis.com');
mock
.delete('/v1/projects/test-project/databases/(default)/documents/users/test-doc-id')
.reply(200, {});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['document-delete.workflow.json'],
});
});
@@ -0,0 +1,37 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleFirebaseCloudFirestore', () => {
const credentials = {
googleFirebaseCloudFirestoreOAuth2Api: {
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
beforeAll(() => {
const mock = nock('https://firestore.googleapis.com');
mock.post('/v1/projects/test-project/databases/(default)/documents:batchGet').reply(200, [
{
found: {
name: 'projects/test-project/databases/(default)/documents/users/test-doc-id',
fields: {
name: { stringValue: 'John Doe' },
email: { stringValue: 'john@example.com' },
age: { integerValue: '30' },
active: { booleanValue: true },
},
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T10:00:00.000Z',
},
},
]);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['document-get.workflow.json'],
});
});
@@ -0,0 +1,50 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleFirebaseCloudFirestore', () => {
const credentials = {
googleFirebaseCloudFirestoreOAuth2Api: {
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
beforeAll(() => {
const mock = nock('https://firestore.googleapis.com');
mock.post('/v1/projects/test-project/databases/(default)/documents:runQuery').reply(200, [
{
document: {
name: 'projects/test-project/databases/(default)/documents/users/user1',
id: 'user1',
fields: {
name: { stringValue: 'John Doe' },
email: { stringValue: 'john@example.com' },
active: { booleanValue: true },
},
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T10:00:00.000Z',
},
},
{
document: {
name: 'projects/test-project/databases/(default)/documents/users/user2',
id: 'user2',
fields: {
name: { stringValue: 'Jane Smith' },
email: { stringValue: 'jane@example.com' },
active: { booleanValue: true },
},
createTime: '2023-01-01T10:00:00.000Z',
updateTime: '2023-01-01T10:00:00.000Z',
},
},
]);
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['document-query.workflow.json'],
});
});
@@ -0,0 +1,34 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleFirebaseCloudFirestore', () => {
const credentials = {
googleFirebaseCloudFirestoreOAuth2Api: {
oauthTokenData: {
access_token: 'test-access-token',
},
},
};
beforeAll(() => {
const mock = nock('https://firestore.googleapis.com');
mock.post('/v1/projects/test-project/databases/(default)/documents:batchWrite').reply(200, {
writeResults: [
{
updateTime: '2023-01-01T11:00:00.000Z',
},
],
status: [
{
code: 0,
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['document-update.workflow.json'],
});
});
@@ -0,0 +1,242 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { googleApiRequest } from '../GenericFunctions';
describe('GoogleFirebaseRealtimeDatabase > GenericFunctions', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;
let mockNode: INode;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'Test RealtimeDatabase Node',
type: 'n8n-nodes-base.googleFirebaseRealtimeDatabase',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
jest.clearAllMocks();
});
describe('googleApiRequest', () => {
const mockRequestOAuth2 = jest.fn();
beforeEach(() => {
mockExecuteFunctions.helpers.requestOAuth2 = mockRequestOAuth2;
mockLoadOptionsFunctions.helpers.requestOAuth2 = mockRequestOAuth2;
});
describe('successful requests', () => {
beforeEach(() => {
mockExecuteFunctions.getCredentials.mockResolvedValue({
region: 'firebaseio.com',
});
mockLoadOptionsFunctions.getCredentials.mockResolvedValue({
region: 'firebaseio.com',
});
});
it('should make successful API request with default options', async () => {
const mockResponse = { data: { test: 'value' } };
mockRequestOAuth2.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(
mockExecuteFunctions,
'test-project',
'GET',
'/users',
);
expect(result).toEqual(mockResponse);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
method: 'GET',
url: 'https://test-project.firebaseio.com//users.json',
headers: {
'Content-Type': 'application/json',
},
qs: {},
json: true,
}),
);
});
it('should include body for POST requests', async () => {
const mockResponse = { success: true };
const requestBody = { name: 'John Doe', email: 'john@example.com' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(
mockExecuteFunctions,
'test-project',
'POST',
'/users',
requestBody,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
method: 'POST',
body: requestBody,
}),
);
});
it('should remove empty body for GET requests', async () => {
const mockResponse = { data: [] };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'test-project', 'GET', '/users', {});
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.not.objectContaining({ body: expect.anything() }),
);
});
it('should include query parameters', async () => {
const mockResponse = { data: [] };
const queryParams = { orderBy: '"$key"', limitToFirst: 10 };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(
mockExecuteFunctions,
'test-project',
'GET',
'/users',
{},
queryParams,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
qs: queryParams,
}),
);
});
it('should include custom headers', async () => {
const mockResponse = { data: [] };
const customHeaders = { 'X-Custom-Header': 'custom-value' };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(
mockExecuteFunctions,
'test-project',
'GET',
'/users',
{},
{},
customHeaders,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
},
}),
);
});
it('should use custom URI when provided', async () => {
const mockResponse = { data: [] };
const customUri = 'https://custom-project.firebaseio.com/custom/path.json';
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(
mockExecuteFunctions,
'test-project',
'GET',
'/users',
{},
{},
{},
customUri,
);
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
url: customUri,
}),
);
});
it('should use different regions correctly', async () => {
mockExecuteFunctions.getCredentials.mockResolvedValue({
region: 'europe-west1-firebase.cloudfunctions.net',
});
const mockResponse = { data: [] };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'test-project', 'GET', '/users');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
url: 'https://test-project.europe-west1-firebase.cloudfunctions.net//users.json',
}),
);
});
});
describe('error handling', () => {
beforeEach(() => {
mockExecuteFunctions.getCredentials.mockResolvedValue({
region: 'firebaseio.com',
});
});
it('should throw NodeApiError on API failure', async () => {
const apiError = new Error('API request failed');
mockRequestOAuth2.mockRejectedValue(apiError);
await expect(
googleApiRequest.call(mockExecuteFunctions, 'test-project', 'GET', '/users'),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.getNode).toHaveBeenCalled();
});
it('should handle credential errors', async () => {
const credentialError = new Error('Invalid credentials');
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
await expect(
googleApiRequest.call(mockExecuteFunctions, 'test-project', 'GET', '/users'),
).rejects.toThrow('Invalid credentials');
});
it('should handle missing region in credentials', async () => {
mockExecuteFunctions.getCredentials.mockResolvedValue({});
const mockResponse = { data: [] };
mockRequestOAuth2.mockResolvedValue(mockResponse);
await googleApiRequest.call(mockExecuteFunctions, 'test-project', 'GET', '/users');
expect(mockRequestOAuth2).toHaveBeenCalledWith(
'googleFirebaseRealtimeDatabaseOAuth2Api',
expect.objectContaining({
url: 'https://test-project.undefined//users.json',
}),
);
});
});
});
});
@@ -1,6 +1,7 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../v2/actions/sheet/appendOrUpdate.operation';
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
@@ -95,6 +96,618 @@ describe('Google Sheet - Append or Update', () => {
valueInputMode: 'USER_ENTERED',
});
});
it('should throw error when no column names can be retrieved and sheet has data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
// Mock sheet with data but no header row at keyRowIndex
mockGoogleSheet.getData.mockResolvedValueOnce([
undefined as any, // No header row at keyRowIndex 0
['some', 'data', 'here'], // Has data but not at header position
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [],
});
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle custom header row and first data row positions', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({
locationDefine: {
values: {
headerRow: '3',
firstDataRow: '5',
},
},
}) // options with custom positions
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
mockGoogleSheet.getData.mockResolvedValueOnce([
[],
[],
['id', 'name'], // Header at row 3 (index 2)
[],
['1', 'Old Name'], // Data starts at row 5 (index 4)
]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: '1', name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
dataStartRowIndex: 4, // firstDataRow - 1
keyIndex: 0,
range: 'Sheet1!A:Z',
sheetData: expect.any(Array),
valueRenderMode: 'UNFORMATTED_VALUE',
});
});
it('should handle data mode "nothing" by skipping processing', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0, input: undefined } },
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1, input: undefined } },
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('nothing') // dataMode
.mockReturnValueOnce('id'); // columnToMatchOn
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [],
});
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should return original items since dataMode is 'nothing'
expect(result).toEqual([
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0 } },
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1 } },
]);
// Should not call update or append operations
expect(mockGoogleSheet.batchUpdate).not.toHaveBeenCalled();
expect(mockGoogleSheet.appendSheetData).not.toHaveBeenCalled();
});
it('should handle autoMapInputData with ignoreIt option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', extraField: 'should be ignored' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'ignoreIt' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
}),
);
});
it('should handle autoMapInputData with error option and throw on unexpected fields', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', unexpectedField: 'error' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'error' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle autoMapInputData with insertInNewColumn option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test', newField: 'new column value' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({ handlingExtraData: 'insertInNewColumn' }) // options
.mockReturnValueOnce('autoMapInputData'); // dataMode
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test', newField: 'new column value' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should update header row with new column
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['id', 'name', 'newField']],
'RAW',
1,
);
});
it('should throw error when valueToMatchOn is empty in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce(''); // empty valueToMatchOn
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should throw error when no values are provided in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([]); // empty fieldsUi.values
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle newColumn type in defineBelow mode (v3)', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
.mockReturnValueOnce({}) // options
.mockReturnValueOnce('defineBelow') // dataMode
.mockReturnValueOnce('id') // columnToMatchOn
.mockReturnValueOnce('1') // valueToMatchOn
.mockReturnValueOnce([
{ column: 'name', fieldValue: 'Updated Name' },
{ column: 'newColumn', columnName: 'description', fieldValue: 'New Description' },
]); // fieldsUi.values with new column
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: '1', name: 'Updated Name', description: 'New Description' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
// Should update header with new column
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
'Sheet1',
[['id', 'name', 'description']],
'RAW',
1,
);
});
it('should handle both update and append data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Updated Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
appendData: [{ id: 2, name: 'New Item' }],
});
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
[{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
'USER_ENTERED',
);
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalled();
});
it('should use useAppend option when appending data', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: { useAppend: true },
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
columnNamesList: [['id', 'name']],
inputData: [{ id: 1, name: 'Test Name' }],
keyRowIndex: 1,
lastRow: 2,
range: 'Sheet1!A:Z',
useAppend: true,
valueInputMode: 'USER_ENTERED',
});
// Should NOT call appendEmptyRowsOrColumns when useAppend is true
expect(mockGoogleSheet.appendEmptyRowsOrColumns).not.toHaveBeenCalled();
});
it('should handle v4 with empty columns.value and throw error', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': {},
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
await expect(
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
).rejects.toThrow(NodeOperationError);
});
it('should handle v4 with null/undefined values by converting to empty string', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: null, description: undefined },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name', 'description']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: '', description: '' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
inputData: [{ id: 1, name: '', description: '' }],
}),
);
});
it('should return mapped values for v4+ in defineBelow mode', async () => {
const mappedValues = [
{ id: 1, name: 'Test Name' },
{ id: 2, name: 'Another Test' },
];
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{ json: { id: 1 }, pairedItem: { item: 0, input: undefined } },
{ json: { id: 2 }, pairedItem: { item: 1, input: undefined } },
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value[id]': itemIndex === 0 ? 1 : 2,
};
// Handle per-item columns.value calls
if (paramName === 'columns.value' && typeof itemIndex === 'number') {
return mappedValues[itemIndex];
}
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
// Mock prepareDataForUpdateOrUpsert to be called for each item
mockGoogleSheet.prepareDataForUpdateOrUpsert
.mockResolvedValueOnce({
updateData: [],
appendData: [mappedValues[0]],
})
.mockResolvedValueOnce({
updateData: [],
appendData: [mappedValues[1]],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(result).toEqual([
{ json: mappedValues[0], pairedItem: { item: 0 } },
{ json: mappedValues[1], pairedItem: { item: 1 } },
]);
});
it('should handle custom valueRenderMode option', async () => {
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: { valueRenderMode: 'FORMATTED_VALUE' },
'columns.mappingMode': 'defineBelow',
'columns.schema': [],
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith(
expect.objectContaining({
valueRenderMode: 'FORMATTED_VALUE',
}),
);
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
expect.objectContaining({
valueRenderMode: 'FORMATTED_VALUE',
}),
);
});
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
const mockSchema = [
{ id: 'id', displayName: 'ID' },
{ id: 'name', displayName: 'Name' },
];
const mockNode = mock<INode>({ typeVersion: 4.5 });
mockExecuteFunctions.getInputData.mockReturnValueOnce([
{
json: { id: 1, name: 'Test' },
pairedItem: { item: 0, input: undefined },
},
]);
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
'columns.schema': mockSchema,
'columns.matchingColumns': ['id'],
'columns.value': { id: 1, name: 'Test Name' },
'columns.value[id]': 1,
};
return params[paramName] ?? fallback;
});
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
updateData: [],
appendData: [{ id: 1, name: 'Test Name' }],
});
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
// Mock the checkForSchemaChanges import to verify it's called
const GoogleSheetsUtils = await import('../../../v2/helpers/GoogleSheets.utils');
jest.spyOn(GoogleSheetsUtils, 'checkForSchemaChanges').mockImplementation(() => {});
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
mockNode, // node
['id', 'name'], // columnNames
mockSchema, // schema
);
});
});
describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => {
@@ -121,7 +734,7 @@ describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => {
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
@@ -191,7 +804,7 @@ describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => {
]);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
const params: Record<string, any> = {
'options.cellFormat': 'USER_ENTERED',
options: {},
'columns.mappingMode': 'defineBelow',
@@ -324,21 +324,6 @@ describe('Microsoft GraphSecurity GenericFunctions', () => {
);
});
it('should handle network timeout errors', async () => {
const networkError = {
error: {
error: {
message: 'ETIMEDOUT - Network timeout',
},
},
};
mockRequest.mockRejectedValue(networkError);
await expect(
msGraphSecurityApiRequest.call(mockExecuteFunctions, 'GET', '/alerts'),
).rejects.toThrow(NodeApiError);
});
it('should handle errors without nested structure', async () => {
const simpleError = {
error: {
@@ -1,4 +1,6 @@
import type { IDataObject, INodePropertyOptions } from 'n8n-workflow';
import { mockDeep } from 'jest-mock-extended';
import type { IDataObject, INodePropertyOptions, IExecuteFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import {
getValue,
@@ -7,8 +9,44 @@ import {
getDefaultFields,
getQuery,
filterAndManageProcessedItems,
salesforceApiRequest,
} from '../GenericFunctions';
// Mock external dependencies
jest.mock('jsonwebtoken');
jest.mock('moment-timezone', () => {
const mockMoment = (value?: any) => ({
unix: jest.fn(() => 1640995200), // Mock timestamp: 2022-01-01T00:00:00Z
isValid: jest.fn(() => {
// Mock moment validation logic to match real moment behavior
// Real moment considers many values "valid" even if they're not proper dates
if (typeof value === 'string') {
// ISO date formats
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
return true;
}
// Only consider obviously non-date strings as invalid
// Real moment would consider numeric strings like '123' as valid (with deprecation warning)
if (/^[a-zA-Z\s]+$/.test(value)) {
return false; // Pure text strings like 'foo bar baz' are invalid
}
// Everything else (including numeric strings) is considered "valid" by moment
return true;
}
return false;
}),
});
return {
__esModule: true,
default: mockMoment,
};
});
import * as jwt from 'jsonwebtoken';
const mockJwt = jwt as jest.Mocked<typeof jwt>;
describe('Salesforce -> GenericFunctions', () => {
describe('getValue', () => {
it('should return date string as is', () => {
@@ -113,7 +151,7 @@ describe('Salesforce -> GenericFunctions', () => {
);
});
it('should return undefined when constitions is not an array', () => {
it('should return undefined when conditions is not an array', () => {
const options: IDataObject = {
conditionsUi: {
conditionValues: 'not an array',
@@ -125,7 +163,7 @@ describe('Salesforce -> GenericFunctions', () => {
expect(result).toBeUndefined();
});
it('should return undefined when constitions is an empty array', () => {
it('should return undefined when conditions is an empty array', () => {
const options: IDataObject = {
conditionsUi: {
conditionValues: [],
@@ -417,4 +455,510 @@ describe('Salesforce -> GenericFunctions', () => {
expect(result.updatedProcessedIds).toEqual(['100', '200', '003', '001', '004']);
});
});
describe('salesforceApiRequest - JWT Authentication', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockRequest: jest.Mock;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockRequest = jest.fn();
mockExecuteFunctions.helpers.request = mockRequest;
jest.clearAllMocks();
// Setup default mocks
(mockJwt.sign as jest.Mock).mockReturnValue('mock-jwt-signature');
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'authentication') return 'jwt';
return undefined;
});
});
afterEach(() => {
jest.resetAllMocks();
});
describe('JWT Authentication Flow', () => {
it('should authenticate using JWT with production environment', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
// Verify JWT signature generation
expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith(
{
iss: 'test-client-id',
sub: 'test@example.com',
aud: 'https://login.salesforce.com',
exp: 1640995200 + 3 * 60, // Current timestamp + 3 minutes
},
'mock-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
// Verify token exchange request
expect(mockRequest).toHaveBeenCalledWith({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'mock-jwt-signature',
},
uri: 'https://login.salesforce.com/services/oauth2/token',
json: true,
});
// Verify API request with bearer token
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
expect(mockExecuteFunctions.logger.debug).toHaveBeenCalledWith(
'Authentication for "Salesforce" node is using "jwt". Invoking URI https://test.salesforce.com/services/data/v59.0/test-endpoint',
);
});
it('should authenticate using JWT with sandbox environment', async () => {
const mockCredentials = {
clientId: 'sandbox-client-id',
username: 'sandbox@example.com',
privateKey: 'sandbox-private-key',
environment: 'sandbox',
};
const mockResponse = {
access_token: 'sandbox-access-token',
instance_url: 'https://test.my.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(
mockExecuteFunctions,
'POST',
'/sandbox-endpoint',
{ data: 'test' },
{ param: 'value' },
);
// Verify JWT uses sandbox URL
expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith(
{
iss: 'sandbox-client-id',
sub: 'sandbox@example.com',
aud: 'https://test.salesforce.com',
exp: 1640995200 + 3 * 60,
},
'sandbox-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
// Verify token exchange request uses sandbox URL
expect(mockRequest).toHaveBeenCalledWith({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'mock-jwt-signature',
},
uri: 'https://test.salesforce.com/services/oauth2/token',
json: true,
});
});
it('should handle JWT token exchange with body and query parameters', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
const testBody = { name: 'Test Account', type: 'Customer' };
const testQs = { fields: 'Id,Name', limit: '10' };
await salesforceApiRequest.call(
mockExecuteFunctions,
'POST',
'/test-endpoint',
testBody,
testQs,
);
// Verify API request includes body and query parameters
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'POST',
body: testBody,
qs: testQs,
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
});
it('should handle custom URI parameter', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(
mockExecuteFunctions,
'GET',
'/default-endpoint',
{},
{},
'/custom-uri',
);
// Verify custom URI is used instead of endpoint
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/custom-uri',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
});
it('should merge additional options', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
const additionalOptions = {
timeout: 30000,
resolveWithFullResponse: true,
};
await salesforceApiRequest.call(
mockExecuteFunctions,
'GET',
'/test-endpoint',
{},
{},
undefined,
additionalOptions,
);
// Verify additional options are merged
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
json: true,
timeout: 30000,
resolveWithFullResponse: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
});
});
describe('JWT Authentication Error Handling', () => {
it('should handle credential retrieval errors', async () => {
const credentialError = new Error('Failed to get credentials');
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('salesforceJwtApi');
});
it('should handle JWT token exchange errors', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const tokenError = new Error('Invalid JWT signature');
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockRejectedValue(tokenError);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
});
it('should handle API request errors after successful authentication', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
const apiError = new Error('API rate limit exceeded');
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest
.mockResolvedValueOnce(mockResponse) // First call succeeds (token exchange)
.mockRejectedValueOnce(apiError); // Second call fails (API request)
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
// Verify that token exchange succeeded but API request failed
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it('should handle missing instance_url in token response', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
// Missing instance_url
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
// Should not throw error but handle gracefully
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
// Verify API call was made with undefined instance_url
expect(mockRequest).toHaveBeenCalledTimes(2);
});
});
describe('JWT Signature Generation', () => {
it('should generate correct JWT payload for production environment', async () => {
const mockCredentials = {
clientId: 'prod-client-id',
username: 'prod@example.com',
privateKey: 'prod-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'prod-access-token',
instance_url: 'https://prod.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith(
{
iss: 'prod-client-id',
sub: 'prod@example.com',
aud: 'https://login.salesforce.com',
exp: 1640995200 + 180, // 3 minutes = 180 seconds
},
'prod-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
});
it('should generate correct JWT payload for sandbox environment', async () => {
const mockCredentials = {
clientId: 'sandbox-client-id',
username: 'sandbox@example.com',
privateKey: 'sandbox-private-key',
environment: 'sandbox',
};
const mockResponse = {
access_token: 'sandbox-access-token',
instance_url: 'https://sandbox.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith(
{
iss: 'sandbox-client-id',
sub: 'sandbox@example.com',
aud: 'https://test.salesforce.com', // Sandbox uses test.salesforce.com
exp: 1640995200 + 180,
},
'sandbox-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
});
it('should use RS256 algorithm and proper header configuration', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'test-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'test-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: jest.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
const jwtOptions = (mockJwt.sign as jest.Mock).mock.calls[0][2];
expect(jwtOptions.algorithm).toBe('RS256');
expect(jwtOptions.header?.alg).toBe('RS256');
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,586 @@
import { mockDeep } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import type { IPollFunctions, INode, ILoadOptionsFunctions, IDataObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import * as GenericFunctions from '../GenericFunctions';
import { SalesforceTrigger } from '../SalesforceTrigger.node';
jest.mock('../GenericFunctions', () => ({
getQuery: jest.fn(),
salesforceApiRequest: jest.fn(),
salesforceApiRequestAllItems: jest.fn(),
sortOptions: jest.fn(),
getPollStartDate: jest.fn(),
filterAndManageProcessedItems: jest.fn(),
}));
describe('SalesforceTrigger', () => {
let trigger: SalesforceTrigger;
let mockPollFunctions: jest.Mocked<IPollFunctions>;
let mockNode: INode;
const getQuerySpy = jest.spyOn(GenericFunctions, 'getQuery');
const salesforceApiRequestSpy = jest.spyOn(GenericFunctions, 'salesforceApiRequest');
const salesforceApiRequestAllItemsSpy = jest.spyOn(
GenericFunctions,
'salesforceApiRequestAllItems',
);
const sortOptionsSpy = jest.spyOn(GenericFunctions, 'sortOptions');
const getPollStartDateSpy = jest.spyOn(GenericFunctions, 'getPollStartDate');
const filterAndManageProcessedItemsSpy = jest.spyOn(
GenericFunctions,
'filterAndManageProcessedItems',
);
beforeEach(() => {
trigger = new SalesforceTrigger();
mockPollFunctions = mockDeep<IPollFunctions>();
mockNode = {
id: 'test-node-id',
name: 'Salesforce Trigger Test',
type: 'n8n-nodes-base.salesforceTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
jest.clearAllMocks();
mockPollFunctions.getNode.mockReturnValue(mockNode);
mockPollFunctions.getWorkflowStaticData.mockReturnValue({});
mockPollFunctions.getMode.mockReturnValue('trigger');
mockPollFunctions.getWorkflow.mockReturnValue({ id: 'test-workflow', active: true });
mockPollFunctions.logger.error = jest.fn();
(mockPollFunctions.helpers.returnJsonArray as jest.Mock).mockImplementation((data: unknown[]) =>
data.map((item: unknown, index: number) => ({ json: item, pairedItem: { item: index } })),
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Methods', () => {
it('should have correct load options methods', () => {
expect(trigger.methods?.loadOptions?.getCustomObjects).toBeDefined();
});
describe('getCustomObjects', () => {
it('should return custom objects with sorted options', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
const mockSobjects = {
sobjects: [
{ name: 'Account', label: 'Account', custom: false },
{ name: 'CustomObject1__c', label: 'Custom Object 1', custom: true },
{ name: 'Contact', label: 'Contact', custom: false },
{ name: 'CustomObject2__c', label: 'Custom Object 2', custom: true },
],
};
salesforceApiRequestSpy.mockResolvedValue(mockSobjects);
sortOptionsSpy.mockImplementation((options) => {
return options.sort((a, b) => a.name.localeCompare(b.name));
});
const result =
await trigger.methods!.loadOptions!.getCustomObjects.call(mockLoadOptionsFunctions);
expect(salesforceApiRequestSpy).toHaveBeenCalledWith('GET', '/sobjects');
expect(sortOptionsSpy).toHaveBeenCalled();
expect(result).toEqual([
{ name: 'Custom Object 1', value: 'CustomObject1__c' },
{ name: 'Custom Object 2', value: 'CustomObject2__c' },
]);
});
it('should handle empty sobjects response', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
const mockSobjects = { sobjects: [] };
salesforceApiRequestSpy.mockResolvedValue(mockSobjects);
const result =
await trigger.methods!.loadOptions!.getCustomObjects.call(mockLoadOptionsFunctions);
expect(result).toEqual([]);
});
it('should filter out non-custom objects', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
const mockSobjects = {
sobjects: [
{ name: 'Account', label: 'Account', custom: false },
{ name: 'Contact', label: 'Contact', custom: false },
{ name: 'Lead', label: 'Lead', custom: false },
],
};
salesforceApiRequestSpy.mockResolvedValue(mockSobjects);
const result =
await trigger.methods!.loadOptions!.getCustomObjects.call(mockLoadOptionsFunctions);
expect(result).toEqual([]);
});
});
});
describe('Poll Function - Parameter Setup', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params: Record<string, unknown> = {
triggerOn: 'accountCreated',
customObject: 'CustomObject__c',
};
return params[paramName] ?? '';
});
});
it('should handle standard object creation trigger', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue(
'SELECT id,name,type FROM Account WHERE CreatedDate >= 2023-01-01T00:00:00.000Z',
);
salesforceApiRequestAllItemsSpy.mockResolvedValue([
{ Id: '001000000000001', Name: 'Test Account 1' },
{ Id: '001000000000002', Name: 'Test Account 2' },
]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [
{ Id: '001000000000001', Name: 'Test Account 1' },
{ Id: '001000000000002', Name: 'Test Account 2' },
],
updatedProcessedIds: ['001000000000001', '001000000000002'],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(getPollStartDateSpy).toHaveBeenCalledWith(undefined);
expect(getQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
conditionsUi: {
conditionValues: expect.arrayContaining([
expect.objectContaining({
field: 'CreatedDate',
operation: '>=',
value: '2023-01-01T00:00:00.000Z',
}),
]),
},
}),
'Account',
true,
);
expect(salesforceApiRequestAllItemsSpy).toHaveBeenCalledWith(
'records',
'GET',
'/query',
{},
expect.objectContaining({
q: 'SELECT id,name,type FROM Account WHERE CreatedDate >= 2023-01-01T00:00:00.000Z',
}),
);
expect(filterAndManageProcessedItemsSpy).toHaveBeenCalled();
expect(result).toBeDefined();
expect(result![0]).toHaveLength(2);
expect(result![0][0].json).toEqual({ Id: '001000000000001', Name: 'Test Account 1' });
});
it('should handle standard object update trigger', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'accountUpdated';
return '';
});
const mockWorkflowData: IDataObject = { lastTimeChecked: '2023-01-01T00:00:00.000Z' };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2022-12-31T23:45:00.000Z');
getQuerySpy.mockReturnValue(
'SELECT id,name,type FROM Account WHERE LastModifiedDate >= 2022-12-31T23:45:00.000Z',
);
salesforceApiRequestAllItemsSpy.mockResolvedValue([
{ Id: '001000000000001', Name: 'Updated Account' },
]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [{ Id: '001000000000001', Name: 'Updated Account' }],
updatedProcessedIds: ['001000000000001'],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(getQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
conditionsUi: {
conditionValues: expect.arrayContaining([
expect.objectContaining({
field: 'LastModifiedDate',
operation: '>=',
value: '2022-12-31T23:45:00.000Z',
}),
expect.objectContaining({
field: 'CreatedDate',
operation: '<',
value: '2022-12-31T23:45:00.000Z',
}),
]),
},
}),
'Account',
true,
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
});
it('should handle custom object triggers', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'customObjectCreated';
if (paramName === 'customObject') return 'CustomObject__c';
return '';
});
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue(
'SELECT id FROM CustomObject__c WHERE CreatedDate >= 2023-01-01T00:00:00.000Z',
);
salesforceApiRequestAllItemsSpy.mockResolvedValue([
{ Id: 'a00000000000001', Name: 'Custom Object 1' },
]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [{ Id: 'a00000000000001', Name: 'Custom Object 1' }],
updatedProcessedIds: ['a00000000000001'],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(getQuerySpy).toHaveBeenCalledWith(expect.any(Object), 'CustomObject__c', true);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
});
it('should initialize processedIds when not present', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
expect(mockWorkflowData.processedIds).toEqual([]);
});
it('should update lastTimeChecked after polling', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
expect(mockWorkflowData.lastTimeChecked).toBeDefined();
expect(DateTime.fromISO(mockWorkflowData.lastTimeChecked as string).isValid).toBe(true);
});
});
describe('Poll Function - Manual Mode', () => {
beforeEach(() => {
mockPollFunctions.getMode.mockReturnValue('manual');
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'accountCreated';
return '';
});
});
it('should use limit 1 in manual mode', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account LIMIT 1');
salesforceApiRequestAllItemsSpy.mockResolvedValue([
{ Id: '001000000000001', Name: 'Test Account' },
]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [{ Id: '001000000000001', Name: 'Test Account' }],
updatedProcessedIds: ['001000000000001'],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(getQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
conditionsUi: {
conditionValues: [],
},
}),
'Account',
false,
1,
);
expect(result).toBeDefined();
expect(result![0]).toHaveLength(1);
});
it('should not add date conditions in manual mode', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account LIMIT 1');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
expect(getQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
conditionsUi: {
conditionValues: [],
},
}),
'Account',
false,
1,
);
});
});
describe('Poll Function - Error Handling', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'accountCreated';
return '';
});
});
it('should throw NodeApiError for API request errors', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockRejectedValue(new Error('API Error'));
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow(NodeApiError);
});
it('should throw error in manual mode even with lastTimeChecked', async () => {
mockPollFunctions.getMode.mockReturnValue('manual');
const mockWorkflowData: IDataObject = { lastTimeChecked: '2023-01-01T00:00:00.000Z' };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account LIMIT 1');
salesforceApiRequestAllItemsSpy.mockRejectedValue(new Error('API Error'));
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('API Error');
});
it('should log error and rethrow in trigger mode with lastTimeChecked', async () => {
const mockWorkflowData: IDataObject = { lastTimeChecked: '2023-01-01T00:00:00.000Z' };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
const testError = new Error('API Error');
getPollStartDateSpy.mockReturnValue('2022-12-31T23:45:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockRejectedValue(testError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('API Error');
expect(mockPollFunctions.logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"There was a problem in 'Salesforce Trigger Test' node in workflow 'test-workflow'",
),
expect.objectContaining({
node: 'Salesforce Trigger Test',
workflowId: 'test-workflow',
error: testError,
}),
);
});
it('should throw error immediately when no lastTimeChecked exists', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
const testError = new Error('API Error');
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockRejectedValue(testError);
await expect(trigger.poll.call(mockPollFunctions)).rejects.toThrow('API Error');
expect(mockPollFunctions.logger.error).not.toHaveBeenCalled();
});
});
describe('Poll Function - Edge Cases', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'accountCreated';
return '';
});
});
it('should return null when no data is found', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
expect(mockWorkflowData.lastTimeChecked).toBeDefined();
});
it('should return null when no new items after filtering', async () => {
const mockWorkflowData: IDataObject = { processedIds: ['001000000000001'] };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockResolvedValue([
{ Id: '001000000000001', Name: 'Already Processed Account' },
]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: ['001000000000001'],
});
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
});
it('should handle null/undefined responseData', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id,name,type FROM Account');
salesforceApiRequestAllItemsSpy.mockResolvedValue(null);
const result = await trigger.poll.call(mockPollFunctions);
expect(result).toBeNull();
expect(mockWorkflowData.lastTimeChecked).toBeDefined();
});
it('should handle empty string triggerOn parameter', async () => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return '';
return '';
});
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id FROM LIMIT 1');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
// When triggerOn is empty, it results in 'Updated' operation and empty resource name
expect(getQuerySpy).toHaveBeenCalledWith(
expect.objectContaining({
conditionsUi: expect.objectContaining({
conditionValues: expect.arrayContaining([
expect.objectContaining({
field: 'LastModifiedDate',
}),
]),
}),
}),
'', // Empty resource name
true,
);
});
});
describe('Poll Function - Date Handling', () => {
beforeEach(() => {
mockPollFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'triggerOn') return 'leadUpdated';
return '';
});
});
it('should use getPollStartDate with safety margin', async () => {
const mockWorkflowData: IDataObject = { lastTimeChecked: '2023-01-01T12:00:00.000Z' };
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
getPollStartDateSpy.mockReturnValue('2023-01-01T11:45:00.000Z');
getQuerySpy.mockReturnValue('SELECT id FROM Lead');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
expect(getPollStartDateSpy).toHaveBeenCalledWith('2023-01-01T12:00:00.000Z');
});
it('should set endDate to current time', async () => {
const mockWorkflowData: IDataObject = {};
mockPollFunctions.getWorkflowStaticData.mockReturnValue(mockWorkflowData);
const startTime = DateTime.now();
getPollStartDateSpy.mockReturnValue('2023-01-01T00:00:00.000Z');
getQuerySpy.mockReturnValue('SELECT id FROM Lead');
salesforceApiRequestAllItemsSpy.mockResolvedValue([]);
filterAndManageProcessedItemsSpy.mockReturnValue({
newItems: [],
updatedProcessedIds: [],
});
await trigger.poll.call(mockPollFunctions);
const endTime = DateTime.now();
const lastTimeChecked = DateTime.fromISO(mockWorkflowData.lastTimeChecked as string);
expect(lastTimeChecked >= startTime).toBe(true);
expect(lastTimeChecked <= endTime).toBe(true);
});
});
});
@@ -1,9 +1,11 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
slackApiRequest,
slackApiRequestAllItems,
processThreadOptions,
getMessageContent,
} from '../../V2/GenericFunctions';
jest.mock('n8n-workflow', () => ({
@@ -12,7 +14,7 @@ jest.mock('n8n-workflow', () => ({
}));
describe('Slack V2 > GenericFunctions', () => {
let mockExecuteFunctions: IExecuteFunctions;
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
beforeEach(() => {
jest.clearAllMocks();
@@ -22,7 +24,10 @@ describe('Slack V2 > GenericFunctions', () => {
},
getNode: jest.fn().mockReturnValue({ type: 'n8n-nodes-base.slack', typeVersion: 2 }),
getNodeParameter: jest.fn().mockReturnValue('accessToken'),
} as unknown as IExecuteFunctions;
getWorkflow: jest.fn().mockReturnValue({ id: 'workflow-123', active: true }),
getInstanceBaseUrl: jest.fn().mockReturnValue('https://test.n8n.io/'),
getInstanceId: jest.fn().mockReturnValue('instance-123'),
} as unknown as jest.Mocked<IExecuteFunctions>;
});
describe('slackApiRequest', () => {
@@ -34,6 +39,142 @@ describe('Slack V2 > GenericFunctions', () => {
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow();
});
it('should handle missing_scope error with needed scopes', async () => {
const mockResponse = {
ok: false,
error: 'missing_scope',
needed: 'channels:read,users:read',
};
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
// Test that the function throws the correct error type and message
// Using .rejects ensures the test fails if slackApiRequest doesn't throw
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeOperationError,
);
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
'Your Slack credential is missing required Oauth Scopes',
);
// Test specific error properties by catching the thrown error
await expect(
slackApiRequest.call(mockExecuteFunctions, 'GET', '/test'),
).rejects.toMatchObject({
description: 'Add the following scope(s) to your Slack App: channels:read,users:read',
level: 'warning',
});
});
it('should handle missing_scope error without needed scopes', async () => {
const mockResponse = {
ok: false,
error: 'missing_scope',
};
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeOperationError,
);
// Test detailed error properties
try {
await slackApiRequest.call(mockExecuteFunctions, 'GET', '/test');
throw new Error('Expected slackApiRequest to throw');
} catch (error) {
if (error.message === 'Expected slackApiRequest to throw') {
throw error; // Re-throw our custom error if the function didn't throw as expected
}
expect(error).toBeInstanceOf(NodeOperationError);
expect(error.message).toBe('Your Slack credential is missing required Oauth Scopes');
expect(error.description).toBe('Add the following scope(s) to your Slack App: undefined');
expect(error.level).toBe('warning');
}
});
it('should handle not_admin error', async () => {
const mockResponse = { ok: false, error: 'not_admin' };
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeOperationError,
);
try {
await slackApiRequest.call(mockExecuteFunctions, 'GET', '/test');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
expect(error.message).toBe(
'Need higher Role Level for this Operation (e.g. Owner or Admin Rights)',
);
expect(error.description).toBe(
'Hint: Check the Role of your Slack App Integration. For more information see the Slack Documentation - https://slack.com/help/articles/360018112273-Types-of-roles-in-Slack',
);
expect(error.level).toBe('warning');
}
});
it('should handle generic error responses', async () => {
const mockResponse = { ok: false, error: 'some_other_error' };
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
await expect(slackApiRequest.call(mockExecuteFunctions, 'GET', '/test')).rejects.toThrow(
NodeOperationError,
);
try {
await slackApiRequest.call(mockExecuteFunctions, 'GET', '/test');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
expect(error.message).toBe('Slack error response: "some_other_error"');
}
});
it('should add message_timestamp and remove ts when response contains ts', async () => {
const mockResponse = {
ok: true,
ts: '1234567890.123456',
message: 'Test message',
};
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
const result = await slackApiRequest.call(mockExecuteFunctions, 'GET', '/test');
expect(result).toEqual({
ok: true,
message_timestamp: '1234567890.123456',
message: 'Test message',
});
expect(result.ts).toBeUndefined();
});
it('should not modify response when ts is not present', async () => {
const mockResponse = {
ok: true,
message: 'Test message',
};
mockExecuteFunctions.helpers.requestWithAuthentication = jest
.fn()
.mockResolvedValue(mockResponse);
const result = await slackApiRequest.call(mockExecuteFunctions, 'GET', '/test');
expect(result).toEqual({
ok: true,
message: 'Test message',
});
expect(result.message_timestamp).toBeUndefined();
});
});
describe('slackApiRequestAllItems', () => {
@@ -370,4 +511,338 @@ describe('Slack V2 > GenericFunctions', () => {
});
});
});
describe('getMessageContent', () => {
beforeEach(() => {
mockExecuteFunctions.getWorkflow.mockReturnValue({ id: 'workflow-123', active: true });
mockExecuteFunctions.getInstanceBaseUrl.mockReturnValue('https://test.n8n.io/');
});
describe('block message type', () => {
beforeEach(() => {
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
const params: { [key: string]: any } = {
messageType: 'block',
'otherOptions.includeLinkToWorkflow': true,
text: 'Fallback text',
};
return params[param] || undefined;
},
);
});
it('should handle block message type with includeLinkToWorkflow true', () => {
const mockBlocksUI = {
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Hello World',
},
},
],
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return true;
if (param === 'text') return 'Fallback text';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect((result as any).blocks).toHaveLength(2);
expect((result as any).blocks[0]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Hello World',
},
});
expect((result as any).blocks[1]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: '_Automated with this <https://test.n8n.io/workflow/workflow-123?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.slack_instance-123|n8n workflow>_',
},
});
expect(result.text).toBe('Fallback text');
});
it('should handle block message type with includeLinkToWorkflow false', () => {
const mockBlocksUI = {
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Hello World',
},
},
],
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return false;
if (param === 'text') return 'Fallback text';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect((result as any).blocks).toHaveLength(1);
expect((result as any).blocks[0]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Hello World',
},
});
expect(result.text).toBe('Fallback text');
});
it('should handle block message type without instanceId', () => {
const mockBlocksUI = {
blocks: [
{
type: 'divider',
},
],
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return true;
if (param === 'text') return '';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1);
expect((result as any).blocks).toHaveLength(2);
expect((result as any).blocks[1].text.text).toBe(
'_Automated with this <https://test.n8n.io/workflow/workflow-123?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.slack|n8n workflow>_',
);
});
it('should handle block message type with non-array blocks', () => {
const mockBlocksUI = {
blocks: 'invalid-blocks-format',
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return true;
if (param === 'text') return 'Test text';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect(result).toEqual({
blocks: 'invalid-blocks-format',
text: 'Test text',
});
});
it('should add text property when text parameter is provided', () => {
const mockBlocksUI = {
blocks: [],
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return false;
if (param === 'text') return 'Custom fallback text';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect(result.text).toBe('Custom fallback text');
expect(result.blocks).toEqual([]);
});
it('should not add text property when text parameter is empty', () => {
const mockBlocksUI = {
blocks: [],
};
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'block';
if (param === 'otherOptions.includeLinkToWorkflow') return false;
if (param === 'text') return '';
if (param === 'blocksUi') return mockBlocksUI;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect(result).toEqual({
blocks: [],
});
expect(result.text).toBeUndefined();
});
});
describe('attachment message type', () => {
it('should handle attachment message type with includeLinkToWorkflow true', () => {
const mockAttachments = [
{
color: 'good',
text: 'Attachment text',
fields: {
item: [
{ title: 'Field 1', value: 'Value 1' },
{ title: 'Field 2', value: 'Value 2' },
],
},
},
{
color: 'warning',
title: 'Warning attachment',
},
];
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'attachment';
if (param === 'otherOptions.includeLinkToWorkflow') return true;
if (param === 'text') return '';
if (param === 'attachments') return mockAttachments;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect((result as any).attachments).toHaveLength(3);
expect((result as any).attachments[0]).toEqual({
color: 'good',
text: 'Attachment text',
fields: [
{ title: 'Field 1', value: 'Value 1' },
{ title: 'Field 2', value: 'Value 2' },
],
});
expect((result as any).attachments[1]).toEqual({
color: 'warning',
title: 'Warning attachment',
});
expect((result as any).attachments[2]).toEqual({
text: '_Automated with this <https://test.n8n.io/workflow/workflow-123?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.slack_instance-123|n8n workflow>_',
});
});
it('should handle attachment message type with includeLinkToWorkflow false', () => {
const mockAttachments = [
{
text: 'Simple attachment',
},
];
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'attachment';
if (param === 'otherOptions.includeLinkToWorkflow') return false;
if (param === 'text') return '';
if (param === 'attachments') return mockAttachments;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect((result as any).attachments).toHaveLength(1);
expect((result as any).attachments[0]).toEqual({
text: 'Simple attachment',
});
});
it('should handle attachment with undefined fields', () => {
const mockAttachments = [
{
text: 'No fields attachment',
fields: undefined,
},
];
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'attachment';
if (param === 'otherOptions.includeLinkToWorkflow') return false;
if (param === 'text') return '';
if (param === 'attachments') return mockAttachments;
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
expect((result as any).attachments[0]).toEqual({
text: 'No fields attachment',
fields: undefined,
});
});
it('should handle attachment with non-array attachments', () => {
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'attachment';
if (param === 'otherOptions.includeLinkToWorkflow') return true;
if (param === 'text') return '';
if (param === 'attachments') return 'invalid-format';
return undefined;
},
);
const result = getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
// The function will treat the string as an iterable, so each character becomes an attachment
// Plus the attribution link is added
expect((result as any).attachments).toHaveLength(15);
expect((result as any).attachments[(result as any).attachments.length - 1]).toEqual({
text: '_Automated with this <https://test.n8n.io/workflow/workflow-123?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.slack_instance-123|n8n workflow>_',
});
});
});
it('should throw error for unknown message type', () => {
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation(
(param: string, _index: number) => {
if (param === 'messageType') return 'unknown-type';
return undefined;
},
);
expect(() => {
getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
}).toThrow(NodeOperationError);
expect(() => {
getMessageContent.call(mockExecuteFunctions, 0, 2.1, 'instance-123');
}).toThrow('The message type "unknown-type" is not known!');
});
});
});
File diff suppressed because it is too large Load Diff