first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { checkDomainRestrictions } from '../checkDomainRestrictions';
|
||||
|
||||
describe('checkDomainRestrictions', () => {
|
||||
let mockNode: INode;
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
let mockSupplyDataFunctions: ISupplyDataFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
|
||||
mockSupplyDataFunctions = mockExecuteFunctions as unknown as ISupplyDataFunctions;
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is "domains"', () => {
|
||||
it('should throw error when allowedDomains is empty', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: '',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when allowedDomains is whitespace only', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: ' ',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when allowedDomains is undefined', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when URL is not in allowed domains', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com,test.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://notallowed.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://notallowed.com');
|
||||
}).toThrow(
|
||||
'Domain not allowed: This credential is restricted from accessing https://notallowed.com. Only the following domains are allowed: example.com,test.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw error when URL is in allowed domains (exact match)', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when URL is in allowed domains (comma-separated list)', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com,test.com,another.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://test.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when URL matches wildcard domain', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: '*.example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://sub.example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with IExecuteFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with ISupplyDataFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockSupplyDataFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is "none"', () => {
|
||||
it('should not throw error when URL matches credentials URL', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when URL does not match credentials URL', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://different.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://different.com');
|
||||
}).toThrow(
|
||||
'Domain not allowed: This credential is restricted from accessing https://different.com. ',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw error when credentials URL key does not exist', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should use custom credentialsUrlKey parameter', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://custom.example.com',
|
||||
'baseUrl',
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://different.com',
|
||||
'baseUrl',
|
||||
);
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should work with IExecuteFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with ISupplyDataFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockSupplyDataFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is undefined or other value', () => {
|
||||
it('should not throw error when allowedDomainsType is undefined', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when allowedDomainsType is empty string', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: '',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when allowedDomainsType is other value', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'other' as any,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle URLs with paths and query parameters', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://example.com/api/v1/endpoint?param=value',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com:8080');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle case-insensitive domain matching', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'EXAMPLE.COM',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle domains with trailing dots', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com.',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple domains with spaces in allowedDomains', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com, test.com , another.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://test.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle exact URL match for "none" type with different protocols', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
// Should throw because protocol is different
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'http://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle exact URL match for "none" type with different paths', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
// Should throw because path is different
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com/path');
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
import { DynamicTool, type Tool } from '@langchain/core/tools';
|
||||
import { StructuredToolkit } from 'n8n-core';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
escapeSingleCurlyBrackets,
|
||||
getConnectedTools,
|
||||
mergeCustomHeaders,
|
||||
unwrapNestedOutput,
|
||||
getSessionId,
|
||||
} from '../helpers';
|
||||
import { N8nTool } from '../N8nTool';
|
||||
|
||||
describe('escapeSingleCurlyBrackets', () => {
|
||||
it('should return undefined when input is undefined', () => {
|
||||
expect(escapeSingleCurlyBrackets(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should escape single curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {world}')).toBe('Hello {{world}}');
|
||||
expect(escapeSingleCurlyBrackets('Test {value} here')).toBe('Test {{value}} here');
|
||||
});
|
||||
|
||||
it('should not escape already double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{world}}')).toBe('Hello {{world}}');
|
||||
expect(escapeSingleCurlyBrackets('Test {{value}} here')).toBe('Test {{value}} here');
|
||||
});
|
||||
|
||||
it('should handle mixed single and double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{world}} and {earth}')).toBe(
|
||||
'Hello {{world}} and {{earth}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(escapeSingleCurlyBrackets('')).toBe('');
|
||||
});
|
||||
it('should handle string with no curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should handle string with only opening curly bracket', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello { world')).toBe('Hello {{ world');
|
||||
});
|
||||
|
||||
it('should handle string with only closing curly bracket', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world }')).toBe('Hello world }}');
|
||||
});
|
||||
|
||||
it('should handle string with multiple single curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('{Hello} {world}')).toBe('{{Hello}} {{world}}');
|
||||
});
|
||||
|
||||
it('should handle string with alternating single and double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('{a} {{b}} {c} {{d}}')).toBe('{{a}} {{b}} {{c}} {{d}}');
|
||||
});
|
||||
|
||||
it('should handle string with curly brackets at the start and end', () => {
|
||||
expect(escapeSingleCurlyBrackets('{start} middle {end}')).toBe('{{start}} middle {{end}}');
|
||||
});
|
||||
|
||||
it('should handle string with special characters', () => {
|
||||
expect(escapeSingleCurlyBrackets('Special {!@#$%^&*} chars')).toBe(
|
||||
'Special {{!@#$%^&*}} chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string with numbers in curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Numbers {123} here')).toBe('Numbers {{123}} here');
|
||||
});
|
||||
|
||||
it('should handle string with whitespace in curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Whitespace { } here')).toBe('Whitespace {{ }} here');
|
||||
});
|
||||
it('should handle multi-line input with single curly brackets', () => {
|
||||
const input = `
|
||||
Line 1 {test}
|
||||
Line 2 {another test}
|
||||
Line 3
|
||||
`;
|
||||
const expected = `
|
||||
Line 1 {{test}}
|
||||
Line 2 {{another test}}
|
||||
Line 3
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with mixed single and double curly brackets', () => {
|
||||
const input = `
|
||||
{Line 1}
|
||||
{{Line 2}}
|
||||
Line {3} {{4}}
|
||||
`;
|
||||
const expected = `
|
||||
{{Line 1}}
|
||||
{{Line 2}}
|
||||
Line {{3}} {{4}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with curly brackets at line starts and ends', () => {
|
||||
const input = `
|
||||
{Start of line 1
|
||||
End of line 2}
|
||||
{3} Line 3 {3}
|
||||
`;
|
||||
const expected = `
|
||||
{{Start of line 1
|
||||
End of line 2}}
|
||||
{{3}} Line 3 {{3}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with nested curly brackets', () => {
|
||||
const input = `
|
||||
Outer {
|
||||
Inner {nested}
|
||||
}
|
||||
`;
|
||||
const expected = `
|
||||
Outer {{
|
||||
Inner {{nested}}
|
||||
}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
it('should handle string with triple uneven curly brackets - opening', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{{world}')).toBe('Hello {{{{world}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - closing', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world}}}')).toBe('Hello world}}}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - mixed opening and closing', () => {
|
||||
expect(escapeSingleCurlyBrackets('{{{Hello}}} {world}}}')).toBe('{{{{Hello}}}} {{world}}}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - multiple occurrences', () => {
|
||||
expect(escapeSingleCurlyBrackets('{{{a}}} {{b}}} {{{c}')).toBe('{{{{a}}}} {{b}}}} {{{{c}}');
|
||||
});
|
||||
|
||||
it('should handle multi-line input with triple uneven curly brackets', () => {
|
||||
const input = `
|
||||
{{{Line 1}
|
||||
Line 2}}}
|
||||
{{{3}}} Line 3 {{{4
|
||||
`;
|
||||
const expected = `
|
||||
{{{{Line 1}}
|
||||
Line 2}}}}
|
||||
{{{{3}}}} Line 3 {{{{4
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectedTools', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
let mockNode: INode;
|
||||
let mockN8nTool: N8nTool;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
|
||||
// Add getParentNodes mock for metadata functionality
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue([]);
|
||||
|
||||
mockN8nTool = new N8nTool(mockExecuteFunctions as unknown as ISupplyDataFunctions, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func: jest.fn(),
|
||||
schema: z.object({
|
||||
foo: z.string(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no tools are connected', async () => {
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true);
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return tools without modification when enforceUniqueNames is false', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
{ name: 'tool1', description: 'desc2' }, // Duplicate name
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
expect(tools).toEqual(mockTools);
|
||||
});
|
||||
|
||||
it('should throw error when duplicate tool names exist and enforceUniqueNames is true', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
{ name: 'tool1', description: 'desc2' },
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
await expect(getConnectedTools(mockExecuteFunctions, true)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should escape curly brackets in tool descriptions when escapeCurlyBrackets is true', async () => {
|
||||
const mockTools = [{ name: 'tool1', description: 'Test {value}' }] as Tool[];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, false, true);
|
||||
expect(tools[0].description).toBe('Test {{value}}');
|
||||
});
|
||||
|
||||
it('should convert N8nTool to dynamic tool when convertStructuredTool is true', async () => {
|
||||
const mockDynamicTool = new DynamicTool({
|
||||
name: 'dynamicTool',
|
||||
description: 'desc',
|
||||
func: jest.fn(),
|
||||
});
|
||||
const asDynamicToolSpy = jest.fn().mockReturnValue(mockDynamicTool);
|
||||
mockN8nTool.asDynamicTool = asDynamicToolSpy;
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, true);
|
||||
expect(asDynamicToolSpy).toHaveBeenCalled();
|
||||
expect(tools[0]).toEqual(mockDynamicTool);
|
||||
});
|
||||
|
||||
it('should not convert N8nTool when convertStructuredTool is false', async () => {
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
|
||||
expect(tools[0]).toBe(mockN8nTool);
|
||||
});
|
||||
|
||||
it('should flatten tools from a toolkit', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
|
||||
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
metadata: { isFromToolkit: false, sourceNodeName: undefined },
|
||||
},
|
||||
{
|
||||
name: 'toolkitTool1',
|
||||
description: 'toolkitToolDesc1',
|
||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
||||
},
|
||||
{
|
||||
name: 'toolkitTool2',
|
||||
description: 'toolkitToolDesc2',
|
||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add metadata to all tools with source node information', async () => {
|
||||
const mockParentNodes = [{ name: 'RegularTool' }, { name: 'MCP Client Tool' }];
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
|
||||
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue(mockParentNodes);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
|
||||
expect(tools).toHaveLength(3);
|
||||
|
||||
// Regular tool should have metadata with isFromToolkit: false
|
||||
expect(tools[0].name).toBe('tool1');
|
||||
expect(tools[0].metadata).toEqual({
|
||||
isFromToolkit: false,
|
||||
sourceNodeName: 'RegularTool',
|
||||
});
|
||||
|
||||
// Toolkit tools should have metadata with isFromToolkit: true
|
||||
expect(tools[1].name).toBe('toolkitTool1');
|
||||
expect(tools[1].metadata).toEqual({
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
|
||||
expect(tools[2].name).toBe('toolkitTool2');
|
||||
expect(tools[2].metadata).toEqual({
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing metadata when adding toolkit metadata', async () => {
|
||||
const mockParentNodes = [{ name: 'MCP Client Tool' }];
|
||||
const mockTools = [
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'desc1', metadata: { customField: 'value' } },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue(mockParentNodes);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
|
||||
expect(tools[0].metadata).toEqual({
|
||||
customField: 'value',
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapNestedOutput', () => {
|
||||
it('should unwrap doubly nested output', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should not modify regular output object', () => {
|
||||
const input = {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify object without output property', () => {
|
||||
const input = {
|
||||
result: 'success',
|
||||
data: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when output is not an object', () => {
|
||||
const input = {
|
||||
output: 'Hello world',
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when object has multiple properties', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
timestamp: 123456789,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when inner output has multiple properties', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
meta: {
|
||||
timestamp: 123456789,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle null values properly', () => {
|
||||
const input = {
|
||||
output: null,
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle empty object values properly', () => {
|
||||
const input = {
|
||||
output: {},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId', () => {
|
||||
let mockCtx: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCtx = {
|
||||
getNodeParameter: jest.fn(),
|
||||
evaluateExpression: jest.fn(),
|
||||
getChatTrigger: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should retrieve sessionId from bodyData', () => {
|
||||
mockCtx.getBodyData = jest.fn();
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.getBodyData.mockReturnValue({ sessionId: '12345' });
|
||||
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('12345');
|
||||
});
|
||||
|
||||
it('should retrieve sessionId from chat trigger', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.evaluateExpression.mockReturnValueOnce(undefined);
|
||||
mockCtx.getChatTrigger.mockReturnValue({ name: 'chatTrigger' });
|
||||
mockCtx.evaluateExpression.mockReturnValueOnce('67890');
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('67890');
|
||||
});
|
||||
|
||||
it('should throw error if sessionId is not found', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.evaluateExpression.mockReturnValue(undefined);
|
||||
mockCtx.getChatTrigger.mockReturnValue(undefined);
|
||||
|
||||
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should use custom sessionId if provided', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValueOnce('custom').mockReturnValueOnce('customSessionId');
|
||||
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('customSessionId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCustomHeaders', () => {
|
||||
it('should merge custom header when credential has header enabled', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original headers when header option is disabled', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: false,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerName is empty', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: '',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerName is not a string', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 123,
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerValue is not a string', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 123,
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when credential has no header properties', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should handle empty defaultHeaders', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Api-Key',
|
||||
headerValue: 'my-api-key',
|
||||
};
|
||||
|
||||
const result = mergeCustomHeaders(credentials, {});
|
||||
|
||||
expect(result).toEqual({ 'X-Api-Key': 'my-api-key' });
|
||||
});
|
||||
|
||||
it('should override existing header with same name', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'Authorization',
|
||||
headerValue: 'Bearer new-token',
|
||||
};
|
||||
const defaultHeaders = { Authorization: 'Bearer old-token' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer new-token' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { INode, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
generateSchemaFromExample,
|
||||
convertJsonSchemaToZod,
|
||||
throwIfToolSchema,
|
||||
} from './../schemaParsing';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [60, 760],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('generateSchemaFromExample', () => {
|
||||
it('should generate schema from simple object', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
active: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from nested object', () => {
|
||||
const example = JSON.stringify({
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string' },
|
||||
notifications: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from array', () => {
|
||||
const example = JSON.stringify({
|
||||
items: ['apple', 'banana', 'cherry'],
|
||||
numbers: [1, 2, 3],
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
numbers: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from complex nested structure', () => {
|
||||
const example = JSON.stringify({
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
tags: ['production', 'api'],
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Item 1',
|
||||
properties: {
|
||||
color: 'red',
|
||||
size: 'large',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties).toHaveProperty('metadata');
|
||||
expect(schema.properties).toHaveProperty('data');
|
||||
expect((schema.properties?.data as JSONSchema7).type).toBe('array');
|
||||
expect(((schema.properties?.data as JSONSchema7).items as JSONSchema7).type).toBe('object');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
middleName: null,
|
||||
age: 30,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
middleName: { type: 'null' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not require fields by default', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should make all fields required when allFieldsRequired is true', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema.required).toEqual(['name', 'age', 'active']);
|
||||
});
|
||||
|
||||
it('should make all nested fields required when allFieldsRequired is true', () => {
|
||||
const example = JSON.stringify({
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema.required).toEqual(['user']);
|
||||
|
||||
const userSchema = schema.properties?.user as JSONSchema7;
|
||||
|
||||
expect(userSchema.required).toEqual(['profile', 'preferences']);
|
||||
expect((userSchema.properties?.profile as JSONSchema7).required).toEqual(['name', 'email']);
|
||||
expect((userSchema.properties?.preferences as JSONSchema7).required).toEqual([
|
||||
'theme',
|
||||
'notifications',
|
||||
]);
|
||||
|
||||
// Check the full structure
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
preferences: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string' },
|
||||
notifications: { type: 'boolean' },
|
||||
},
|
||||
required: ['theme', 'notifications'],
|
||||
},
|
||||
},
|
||||
required: ['profile', 'preferences'],
|
||||
},
|
||||
},
|
||||
required: ['user'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const example = JSON.stringify({});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object with allFieldsRequired true', () => {
|
||||
const example = JSON.stringify({});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', () => {
|
||||
const invalidJson = '{ name: "John", age: 30 }'; // Missing quotes around property names
|
||||
|
||||
expect(() => generateSchemaFromExample(invalidJson)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle array of objects', () => {
|
||||
const example = JSON.stringify([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
]);
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array of objects with allFieldsRequired true', () => {
|
||||
const example = JSON.stringify([
|
||||
{ id: 1, name: 'Item 1', metadata: { tag: 'prod' } },
|
||||
{ id: 2, name: 'Item 2', metadata: { tag: 'dev' } },
|
||||
]);
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: { type: 'string' },
|
||||
},
|
||||
required: ['tag'],
|
||||
},
|
||||
},
|
||||
required: ['id', 'name', 'metadata'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertJsonSchemaToZod', () => {
|
||||
it('should convert simple object schema to zod', () => {
|
||||
const schema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
expect(zodSchema).toBeDefined();
|
||||
expect(typeof zodSchema.parse).toBe('function');
|
||||
});
|
||||
|
||||
it('should convert and validate with zod schema', () => {
|
||||
const schema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
// Valid data should pass
|
||||
expect(() => zodSchema.parse({ name: 'John', age: 30 })).not.toThrow();
|
||||
expect(() => zodSchema.parse({ name: 'John' })).not.toThrow();
|
||||
|
||||
// Invalid data should throw
|
||||
expect(() => zodSchema.parse({ age: 30 })).toThrow(); // Missing required name
|
||||
expect(() => zodSchema.parse({ name: 'John', age: 'thirty' })).toThrow(); // Wrong type for age
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwIfToolSchema', () => {
|
||||
it('should throw NodeOperationError for tool schema error', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error('tool input did not match expected schema');
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(/tool input did not match expected schema/);
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(
|
||||
/This is most likely because some of your tools are configured to require a specific schema/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw for non-tool schema errors', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error('Some other error');
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for errors without message', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error();
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle errors that are not Error instances', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = { message: 'tool input did not match expected schema' } as Error;
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user