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,324 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode, IExecuteFunctions } from 'n8n-workflow';
|
||||
import {
|
||||
CHAT_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
FREE_TEXT_CHAT_RESPONSE_TYPE,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { Chat } from '../Chat.node';
|
||||
|
||||
describe('Test Chat Node', () => {
|
||||
let chat: Chat;
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
chat = new Chat();
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('v1.0', () => {
|
||||
const chatNode = mock<INode>({
|
||||
name: 'Chat',
|
||||
type: CHAT_NODE_TYPE,
|
||||
parameters: {},
|
||||
typeVersion: 1.0,
|
||||
});
|
||||
|
||||
it('should execute and send message', async () => {
|
||||
const items = [{ json: { data: 'test' } }];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(items);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount: 1,
|
||||
resumeUnit: 'minutes',
|
||||
});
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
{
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
disabled: false,
|
||||
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const result = await chat.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
|
||||
});
|
||||
|
||||
it('should execute and handle memory connection', async () => {
|
||||
const items = [{ json: { data: 'test' } }];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(items);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount: 1,
|
||||
resumeUnit: 'minutes',
|
||||
});
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
{
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
disabled: false,
|
||||
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const memory = { chatHistory: { addAIMessage: jest.fn() } };
|
||||
mockExecuteFunctions.getInputConnectionData.mockResolvedValueOnce(memory);
|
||||
|
||||
await chat.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(memory.chatHistory.addAIMessage).toHaveBeenCalledWith('message');
|
||||
});
|
||||
|
||||
it('should execute without memory connection', async () => {
|
||||
const items = [{ json: { data: 'test' } }];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(items);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'afterTimeInterval',
|
||||
resumeAmount: 1,
|
||||
resumeUnit: 'minutes',
|
||||
});
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
{
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
disabled: false,
|
||||
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const result = await chat.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
|
||||
});
|
||||
|
||||
it('should execute with specified time limit', async () => {
|
||||
const items = [{ json: { data: 'test' } }];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(items);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
|
||||
limitType: 'atSpecifiedTime',
|
||||
maxDateAndTime: new Date().toISOString(),
|
||||
});
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
{
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
disabled: false,
|
||||
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const result = await chat.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
|
||||
});
|
||||
|
||||
it('should process onMessage without waiting for reply', async () => {
|
||||
const data = { json: { chatInput: 'user message' } };
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
{
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
disabled: false,
|
||||
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([[data]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1.1', () => {
|
||||
const chatNode = mock<INode>({
|
||||
name: 'Chat',
|
||||
type: CHAT_NODE_TYPE,
|
||||
parameters: {},
|
||||
typeVersion: 1.1,
|
||||
});
|
||||
|
||||
it('should process onMessage without waiting for reply', async () => {
|
||||
const data = { json: { chatInput: 'user message' } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return 'send';
|
||||
case 'options':
|
||||
return { memoryConnection: false };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([[data]]);
|
||||
});
|
||||
|
||||
it('should process onMessage with waiting for reply and free text response type', async () => {
|
||||
const data = { json: { chatInput: 'user message' } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return SEND_AND_WAIT_OPERATION;
|
||||
case 'responseType':
|
||||
return FREE_TEXT_CHAT_RESPONSE_TYPE;
|
||||
case 'options':
|
||||
return { memoryConnection: false };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
...data,
|
||||
json: {
|
||||
data: {
|
||||
...data.json,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process onMessage with waiting for reply and approval response type', async () => {
|
||||
const data = { json: { chatInput: 'user message' } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return SEND_AND_WAIT_OPERATION;
|
||||
case 'responseType':
|
||||
return 'approval';
|
||||
case 'options':
|
||||
return { memoryConnection: false };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
...data,
|
||||
json: {
|
||||
data: {
|
||||
...data.json,
|
||||
approved: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add user message to memory', async () => {
|
||||
const data = { json: { chatInput: 'user message' } };
|
||||
const memory = { chatHistory: { addUserMessage: jest.fn() } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getInputConnectionData.mockResolvedValue(memory);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return SEND_AND_WAIT_OPERATION;
|
||||
case 'responseType':
|
||||
return FREE_TEXT_CHAT_RESPONSE_TYPE;
|
||||
case 'options':
|
||||
return { memoryConnection: true };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
...data,
|
||||
json: {
|
||||
data: {
|
||||
...data.json,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(memory.chatHistory.addUserMessage).toHaveBeenCalledWith('user message');
|
||||
});
|
||||
|
||||
it('v1.2 should return output data directly without nesting into `data` field (except `approved`)', async () => {
|
||||
const chatNode = mock<INode>({
|
||||
name: 'Chat',
|
||||
type: CHAT_NODE_TYPE,
|
||||
parameters: {},
|
||||
typeVersion: 1.2,
|
||||
});
|
||||
const data = { json: { chatInput: 'user message', data: { nested: 'field' } } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([data]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return SEND_AND_WAIT_OPERATION;
|
||||
case 'responseType':
|
||||
return 'approval';
|
||||
case 'options':
|
||||
return { memoryConnection: false };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, data);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
...data,
|
||||
json: {
|
||||
...data.json,
|
||||
data: {
|
||||
...data.json.data,
|
||||
approved: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import type { Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { ChatTrigger } from '../ChatTrigger.node';
|
||||
import type { LoadPreviousSessionChatOption } from '../types';
|
||||
|
||||
jest.mock('../GenericFunctions', () => ({
|
||||
validateAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ChatTrigger Node', () => {
|
||||
const mockContext = mock<IWebhookFunctions>();
|
||||
const mockRequest = mock<Request>();
|
||||
const mockResponse = mock<Response>();
|
||||
let chatTrigger: ChatTrigger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
chatTrigger = new ChatTrigger();
|
||||
|
||||
mockContext.getRequestObject.mockReturnValue(mockRequest);
|
||||
mockContext.getResponseObject.mockReturnValue(mockResponse);
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return {};
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
mockContext.getBodyData.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('webhook method: loadPreviousSession action', () => {
|
||||
beforeEach(() => {
|
||||
mockContext.getBodyData.mockReturnValue({ action: 'loadPreviousSession' });
|
||||
});
|
||||
|
||||
it('should return empty array when loadPreviousSession is undefined', async () => {
|
||||
// Mock options with undefined loadPreviousSession
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { loadPreviousSession: undefined };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify the returned result contains empty data array
|
||||
expect(result).toEqual({
|
||||
webhookResponse: { data: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when loadPreviousSession is "notSupported"', async () => {
|
||||
// Mock options with notSupported loadPreviousSession
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { loadPreviousSession: 'notSupported' };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify the returned result contains empty data array
|
||||
expect(result).toEqual({
|
||||
webhookResponse: { data: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle loadPreviousSession="memory" correctly', async () => {
|
||||
// Mock chat history data
|
||||
const mockMessages = [
|
||||
{ toJSON: () => ({ content: 'Message 1' }) },
|
||||
{ toJSON: () => ({ content: 'Message 2' }) },
|
||||
];
|
||||
|
||||
// Mock memory with chat history
|
||||
const mockMemory = {
|
||||
chatHistory: {
|
||||
getMessages: jest.fn().mockReturnValueOnce(mockMessages),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock options with memory loadPreviousSession
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options')
|
||||
return { loadPreviousSession: 'memory' as LoadPreviousSessionChatOption };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock getInputConnectionData to return memory
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockMemory);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify the returned result contains messages from memory
|
||||
expect(result).toEqual({
|
||||
webhookResponse: {
|
||||
data: [{ content: 'Message 1' }, { content: 'Message 2' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook method: streaming response mode', () => {
|
||||
beforeEach(() => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getMode.mockReturnValue('production' as any);
|
||||
mockContext.getBodyData.mockReturnValue({ message: 'Hello' });
|
||||
(mockContext.helpers.returnJsonArray as any) = jest.fn().mockReturnValue([]);
|
||||
mockResponse.writeHead.mockImplementation(() => mockResponse);
|
||||
mockResponse.flushHeaders.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('should enable streaming when responseMode is "streaming"', async () => {
|
||||
// Mock options with streaming responseMode
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { responseMode: 'streaming' };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify streaming headers are set
|
||||
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
expect(mockResponse.flushHeaders).toHaveBeenCalled();
|
||||
|
||||
// Verify response structure for streaming
|
||||
expect(result).toEqual({
|
||||
workflowData: expect.any(Array),
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not enable streaming when responseMode is not "streaming"', async () => {
|
||||
// Mock options with lastNode responseMode
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { responseMode: 'lastNode' };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify streaming headers are NOT set
|
||||
expect(mockResponse.writeHead).not.toHaveBeenCalled();
|
||||
expect(mockResponse.flushHeaders).not.toHaveBeenCalled();
|
||||
|
||||
// Verify normal response structure
|
||||
expect(result).toEqual({
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable streaming when availableInChat is true and responseMode is not set', async () => {
|
||||
// Mock options with availableInChat true and no responseMode
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return {};
|
||||
if (paramName === 'availableInChat') return true;
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify streaming headers are set
|
||||
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
expect(mockResponse.flushHeaders).toHaveBeenCalled();
|
||||
|
||||
// Verify response structure for streaming
|
||||
expect(result).toEqual({
|
||||
workflowData: expect.any(Array),
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable streaming when availableInChat is true and responseMode is "streaming"', async () => {
|
||||
// Mock options with availableInChat true and streaming responseMode
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { responseMode: 'streaming' };
|
||||
if (paramName === 'availableInChat') return true;
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify streaming headers are set
|
||||
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
expect(mockResponse.flushHeaders).toHaveBeenCalled();
|
||||
|
||||
// Verify response structure for streaming
|
||||
expect(result).toEqual({
|
||||
workflowData: expect.any(Array),
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multipart form data with streaming enabled', async () => {
|
||||
// Mock multipart form data request
|
||||
mockRequest.contentType = 'multipart/form-data';
|
||||
mockRequest.body = {
|
||||
data: { message: 'Hello' },
|
||||
files: {},
|
||||
};
|
||||
|
||||
// Mock options with streaming responseMode
|
||||
mockContext.getNodeParameter.mockImplementation(
|
||||
(
|
||||
paramName: string,
|
||||
defaultValue?: boolean | string | object,
|
||||
): boolean | string | object | undefined => {
|
||||
if (paramName === 'public') return true;
|
||||
if (paramName === 'mode') return 'hostedChat';
|
||||
if (paramName === 'options') return { responseMode: 'streaming' };
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Call the webhook method
|
||||
const result = await chatTrigger.webhook(mockContext);
|
||||
|
||||
// Verify streaming headers are set
|
||||
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
expect(mockResponse.flushHeaders).toHaveBeenCalled();
|
||||
|
||||
// Verify response structure for streaming
|
||||
expect(result).toEqual({
|
||||
workflowData: expect.any(Array),
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { ChatTriggerAuthorizationError } from '../error';
|
||||
import { validateAuth } from '../GenericFunctions';
|
||||
|
||||
describe('validateAuth', () => {
|
||||
const mockContext = mock<IWebhookFunctions>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('authentication = none', () => {
|
||||
it('should pass without error', async () => {
|
||||
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('none');
|
||||
|
||||
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication = basicAuth', () => {
|
||||
beforeEach(() => {
|
||||
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('basicAuth');
|
||||
});
|
||||
|
||||
it('should throw 500 when credentials are not defined', async () => {
|
||||
mockContext.getCredentials.mockRejectedValue(new Error('No credentials'));
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 401 when no auth header is provided', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'secret',
|
||||
} as ICredentialDataDecryptedObject);
|
||||
mockContext.getRequestObject.mockReturnValue({
|
||||
headers: {},
|
||||
} as never);
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 403 when credentials are wrong', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'secret',
|
||||
} as ICredentialDataDecryptedObject);
|
||||
mockContext.getRequestObject.mockReturnValue({
|
||||
headers: {
|
||||
authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64'),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass with correct credentials', async () => {
|
||||
mockContext.getCredentials.mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'secret',
|
||||
} as ICredentialDataDecryptedObject);
|
||||
mockContext.getRequestObject.mockReturnValue({
|
||||
headers: {
|
||||
authorization: 'Basic ' + Buffer.from('admin:secret').toString('base64'),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication = n8nUserAuth', () => {
|
||||
beforeEach(() => {
|
||||
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('n8nUserAuth');
|
||||
});
|
||||
|
||||
it('should skip validation for setup webhook', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('setup');
|
||||
mockContext.getHeaderData.mockReturnValue({});
|
||||
|
||||
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw 401 when no n8n-auth cookie is present', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getHeaderData.mockReturnValue({});
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 401,
|
||||
message: 'User not authenticated!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 401 when cookie has a fake/invalid token', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getHeaderData.mockReturnValue({
|
||||
cookie: 'n8n-auth=anything',
|
||||
});
|
||||
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 401,
|
||||
message: 'Invalid authentication token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 401 when validateCookieAuth rejects (revoked token)', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getHeaderData.mockReturnValue({
|
||||
cookie: 'n8n-auth=some.revoked.token',
|
||||
});
|
||||
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
|
||||
|
||||
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
|
||||
await expect(validateAuth(mockContext)).rejects.toMatchObject({
|
||||
responseCode: 401,
|
||||
message: 'Invalid authentication token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass with a valid token', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getHeaderData.mockReturnValue({
|
||||
cookie: 'n8n-auth=valid.jwt.token',
|
||||
});
|
||||
mockContext.validateCookieAuth.mockResolvedValue(undefined);
|
||||
|
||||
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
|
||||
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
|
||||
});
|
||||
|
||||
it('should pass when cookie has other cookies alongside n8n-auth', async () => {
|
||||
mockContext.getWebhookName.mockReturnValue('default');
|
||||
mockContext.getHeaderData.mockReturnValue({
|
||||
cookie: 'other=value; n8n-auth=valid.jwt.token; another=thing',
|
||||
});
|
||||
mockContext.validateCookieAuth.mockResolvedValue(undefined);
|
||||
|
||||
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
|
||||
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { createPage, getSanitizedInitialMessages, getSanitizedI18nConfig } from '../templates';
|
||||
|
||||
describe('ChatTrigger Templates Security', () => {
|
||||
const defaultParams = {
|
||||
instanceId: 'test-instance',
|
||||
webhookUrl: 'http://test.com/webhook',
|
||||
showWelcomeScreen: false,
|
||||
loadPreviousSession: 'notSupported' as const,
|
||||
i18n: {
|
||||
en: {},
|
||||
},
|
||||
mode: 'test' as const,
|
||||
authentication: 'none' as const,
|
||||
allowFileUploads: false,
|
||||
allowedFilesMimeTypes: '',
|
||||
customCss: '',
|
||||
enableStreaming: false,
|
||||
initialMessages: '',
|
||||
};
|
||||
|
||||
describe('XSS Prevention in initialMessages', () => {
|
||||
it('should prevent script injection through script context breakout', () => {
|
||||
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: maliciousInput,
|
||||
});
|
||||
|
||||
// Should not contain the malicious script
|
||||
expect(result).not.toContain('<script>alert(document.cookie)</script>');
|
||||
expect(result).not.toContain('</script>"%09<script>');
|
||||
expect(result).not.toContain('alert(document.cookie)');
|
||||
|
||||
// Should contain initialMessages (the exact format is less important than security)
|
||||
expect(result).toContain('initialMessages:');
|
||||
// Should contain the tab character but not the dangerous script tags
|
||||
expect(result).toContain('%09');
|
||||
});
|
||||
|
||||
it('should sanitize common XSS payloads', () => {
|
||||
const xssPayloads = [
|
||||
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
|
||||
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
|
||||
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
|
||||
{
|
||||
input: '<iframe src="javascript:alert(1)"></iframe>',
|
||||
dangerous: ['<iframe', 'javascript:'],
|
||||
},
|
||||
];
|
||||
|
||||
xssPayloads.forEach(({ input, dangerous }) => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: input,
|
||||
});
|
||||
|
||||
// Should not contain dangerous HTML elements or protocols
|
||||
dangerous.forEach((dangerousContent) => {
|
||||
expect(result).not.toContain(dangerousContent);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve legitimate messages', () => {
|
||||
const legitimateMessages = [
|
||||
'Hello, how can I help you?',
|
||||
'Welcome to our chat service!',
|
||||
'Please describe your issue.',
|
||||
'Multi-line\nmessage content\nwith breaks',
|
||||
];
|
||||
|
||||
legitimateMessages.forEach((message) => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: message,
|
||||
});
|
||||
|
||||
// Should contain the sanitized legitimate content
|
||||
const expectedLines = message
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => line.trim());
|
||||
|
||||
expect(result).toContain(`initialMessages: ${JSON.stringify(expectedLines)}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty initialMessages', () => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: '',
|
||||
});
|
||||
|
||||
// Should not include initialMessages property when empty
|
||||
expect(result).not.toContain('initialMessages:');
|
||||
});
|
||||
|
||||
it('should handle whitespace-only initialMessages', () => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: ' \n\n\t \n ',
|
||||
});
|
||||
|
||||
// Should not include initialMessages property when only whitespace
|
||||
expect(result).not.toContain('initialMessages:');
|
||||
});
|
||||
|
||||
it('should filter empty lines and trim content', () => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: ' First message \n\n \n Second message \n',
|
||||
});
|
||||
|
||||
// Should only include non-empty, trimmed lines
|
||||
expect(result).toContain('initialMessages: ["First message","Second message"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('General Security', () => {
|
||||
it('should not expose raw user input in HTML comments or other locations', () => {
|
||||
const maliciousInput = '</script><script>alert("XSS")</script>';
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: maliciousInput,
|
||||
});
|
||||
|
||||
// Should not appear anywhere in the HTML outside of the sanitized JSON
|
||||
const lines = result.split('\n');
|
||||
const unsafeLines = lines.filter(
|
||||
(line) =>
|
||||
line.includes('<script>alert("XSS")</script>') && !line.includes('initialMessages: ['),
|
||||
);
|
||||
|
||||
expect(unsafeLines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('I18n XSS Prevention', () => {
|
||||
it('should prevent script injection through i18n config values', () => {
|
||||
const maliciousInput = '</script><script>alert(document.cookie)</script>';
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: '',
|
||||
i18n: {
|
||||
en: {
|
||||
title: maliciousInput,
|
||||
subtitle: maliciousInput,
|
||||
getStarted: maliciousInput,
|
||||
inputPlaceholder: maliciousInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Should not contain the malicious script
|
||||
expect(result).not.toContain('<script>alert(document.cookie)</script>');
|
||||
expect(result).not.toContain('</script><script>');
|
||||
expect(result).not.toContain('alert(document.cookie)');
|
||||
|
||||
// Should contain i18n config but sanitized
|
||||
expect(result).toContain('i18n:');
|
||||
});
|
||||
|
||||
it('should sanitize individual i18n fields', () => {
|
||||
const xssPayload = '<img src=x onerror=alert(1)>';
|
||||
const fields = ['title', 'subtitle', 'getStarted', 'inputPlaceholder'];
|
||||
|
||||
fields.forEach((field) => {
|
||||
const config = { [field]: xssPayload };
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: '',
|
||||
i18n: { en: config },
|
||||
});
|
||||
|
||||
// Should not contain dangerous HTML
|
||||
expect(result).not.toContain('onerror=');
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).not.toContain('alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve legitimate i18n content', () => {
|
||||
const legitimateConfig = {
|
||||
title: 'Welcome to Chat',
|
||||
subtitle: 'How can we help you today?',
|
||||
getStarted: 'Start Conversation',
|
||||
inputPlaceholder: 'Type your message...',
|
||||
};
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: '',
|
||||
i18n: { en: legitimateConfig },
|
||||
});
|
||||
|
||||
// Should contain the legitimate content
|
||||
expect(result).toContain(JSON.stringify(legitimateConfig));
|
||||
});
|
||||
|
||||
it('should handle empty i18n config', () => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
initialMessages: '',
|
||||
i18n: { en: {} },
|
||||
});
|
||||
|
||||
// Should still have i18n structure but no en property in the i18n config
|
||||
expect(result).toContain('i18n: {');
|
||||
expect(result).not.toContain('en: {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS Prevention in allowedFilesMimeTypes', () => {
|
||||
it('should prevent script injection through allowedFilesMimeTypes', () => {
|
||||
const maliciousInput = '</script><script>alert(document.cookie)</script>';
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
allowFileUploads: true,
|
||||
allowedFilesMimeTypes: maliciousInput,
|
||||
});
|
||||
|
||||
expect(result).not.toContain('<script>alert(document.cookie)</script>');
|
||||
expect(result).not.toContain('</script><script>');
|
||||
expect(result).not.toContain('alert(document.cookie)');
|
||||
});
|
||||
|
||||
it('should sanitize common XSS payloads in allowedFilesMimeTypes', () => {
|
||||
const xssPayloads = [
|
||||
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
|
||||
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
|
||||
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
|
||||
];
|
||||
|
||||
xssPayloads.forEach(({ input, dangerous }) => {
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
allowFileUploads: true,
|
||||
allowedFilesMimeTypes: input,
|
||||
});
|
||||
|
||||
dangerous.forEach((dangerousContent) => {
|
||||
expect(result).not.toContain(dangerousContent);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve legitimate MIME types', () => {
|
||||
const legitimateMimeTypes = 'image/*,text/plain,application/pdf';
|
||||
|
||||
const result = createPage({
|
||||
...defaultParams,
|
||||
allowFileUploads: true,
|
||||
allowedFilesMimeTypes: legitimateMimeTypes,
|
||||
});
|
||||
|
||||
expect(result).toContain(legitimateMimeTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSanitizedInitialMessages function', () => {
|
||||
it('should sanitize XSS payloads', () => {
|
||||
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';
|
||||
const result = getSanitizedInitialMessages(maliciousInput);
|
||||
|
||||
expect(result).toEqual(['"%09']);
|
||||
expect(result.join('')).not.toContain('<script>');
|
||||
expect(result.join('')).not.toContain('alert');
|
||||
});
|
||||
|
||||
it('should remove dangerous protocols', () => {
|
||||
const inputs = [
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
];
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const result = getSanitizedInitialMessages(input);
|
||||
const joined = result.join('');
|
||||
expect(joined).not.toContain('javascript:');
|
||||
expect(joined).not.toContain('data:');
|
||||
expect(joined).not.toContain('vbscript:');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve legitimate content', () => {
|
||||
const input = 'Hello world!\nHow are you?\nGoodbye!';
|
||||
const result = getSanitizedInitialMessages(input);
|
||||
|
||||
expect(result).toEqual(['Hello world!', 'How are you?', 'Goodbye!']);
|
||||
});
|
||||
|
||||
it('should handle empty and whitespace-only input', () => {
|
||||
expect(getSanitizedInitialMessages('')).toEqual([]);
|
||||
expect(getSanitizedInitialMessages(' \n\n \t \n ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should trim and filter empty lines', () => {
|
||||
const input = ' First message \n\n \n Second message \n';
|
||||
const result = getSanitizedInitialMessages(input);
|
||||
|
||||
expect(result).toEqual(['First message', 'Second message']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSanitizedI18nConfig function', () => {
|
||||
it('should sanitize XSS payloads in all values', () => {
|
||||
const maliciousInput = '</script><script>alert(document.cookie)</script>';
|
||||
const input = {
|
||||
title: maliciousInput,
|
||||
subtitle: maliciousInput,
|
||||
getStarted: maliciousInput,
|
||||
inputPlaceholder: maliciousInput,
|
||||
};
|
||||
|
||||
const result = getSanitizedI18nConfig(input);
|
||||
|
||||
Object.values(result).forEach((value) => {
|
||||
expect(value).not.toContain('<script>');
|
||||
expect(value).not.toContain('alert');
|
||||
expect(value).not.toContain('</script>');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove dangerous protocols', () => {
|
||||
const input = {
|
||||
title: 'javascript:alert(1)',
|
||||
subtitle: 'data:text/html,<script>alert(1)</script>',
|
||||
getStarted: 'vbscript:msgbox(1)',
|
||||
};
|
||||
|
||||
const result = getSanitizedI18nConfig(input);
|
||||
|
||||
Object.values(result).forEach((value) => {
|
||||
expect(value).not.toContain('javascript:');
|
||||
expect(value).not.toContain('data:');
|
||||
expect(value).not.toContain('vbscript:');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve legitimate content', () => {
|
||||
const input = {
|
||||
title: 'Welcome to Chat',
|
||||
subtitle: 'How can we help you today?',
|
||||
getStarted: 'Start Conversation',
|
||||
inputPlaceholder: 'Type your message...',
|
||||
};
|
||||
|
||||
const result = getSanitizedI18nConfig(input);
|
||||
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = getSanitizedI18nConfig({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle non-string values gracefully', () => {
|
||||
const input = {
|
||||
title: 'Valid title',
|
||||
count: 123,
|
||||
enabled: true,
|
||||
obj: { test: 1 },
|
||||
} as any;
|
||||
|
||||
const result = getSanitizedI18nConfig(input);
|
||||
|
||||
expect(result.title).toBe('Valid title');
|
||||
expect(result.count).toBe('123');
|
||||
expect(result.enabled).toBe('');
|
||||
expect(result.obj).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import * as sendAndWaitUtils from 'n8n-nodes-base/dist/utils/sendAndWait/utils';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { ChatNodeMessageType, FREE_TEXT_CHAT_RESPONSE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import { getChatMessage } from '../util';
|
||||
|
||||
describe('util', () => {
|
||||
describe('getChatMessage', () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return a string for v1.0', () => {
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.0 }));
|
||||
ctx.getNodeParameter.mockReturnValue('test');
|
||||
|
||||
const message = getChatMessage(ctx);
|
||||
|
||||
expect(message).toBe('test');
|
||||
});
|
||||
|
||||
it('should return a string for v1.1 with free text response type', () => {
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
|
||||
ctx.getNodeParameter.mockImplementation((paramName) => {
|
||||
switch (paramName) {
|
||||
case 'responseType':
|
||||
return FREE_TEXT_CHAT_RESPONSE_TYPE;
|
||||
case 'message':
|
||||
return 'test';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const message = getChatMessage(ctx);
|
||||
|
||||
expect(message).toBe('test');
|
||||
});
|
||||
|
||||
it('should return ChatNodeMessageWithButtons for v1.1 with approval response type', () => {
|
||||
jest.spyOn(sendAndWaitUtils, 'getSendAndWaitConfig').mockReturnValue({
|
||||
title: '',
|
||||
message: '',
|
||||
options: [
|
||||
{ label: 'Disapprove', url: 'https://no.com', style: 'secondary' },
|
||||
{ label: 'Approve', url: 'https://yes.com', style: 'primary' },
|
||||
],
|
||||
});
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
|
||||
ctx.getNodeParameter.mockImplementation((paramName) => {
|
||||
switch (paramName) {
|
||||
case 'responseType':
|
||||
return 'approval';
|
||||
case 'message':
|
||||
return 'test';
|
||||
case 'blockUserInput':
|
||||
return true;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const message = getChatMessage(ctx);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: ChatNodeMessageType.WITH_BUTTONS,
|
||||
text: 'test',
|
||||
blockUserInput: true,
|
||||
buttons: [
|
||||
{ text: 'Approve', link: 'https://yes.com', type: 'primary' },
|
||||
{ text: 'Disapprove', link: 'https://no.com', type: 'secondary' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user