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,425 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { ITriggerFunctions, IDeferredPromise, IRun } from 'n8n-workflow';
|
||||
import type { EventContext } from 'rhea';
|
||||
|
||||
import { handleMessage } from './handleMessage';
|
||||
|
||||
interface MockReceiver {
|
||||
has_credit: jest.Mock<boolean>;
|
||||
add_credit: jest.Mock;
|
||||
}
|
||||
|
||||
describe('handleMessage', () => {
|
||||
let mockTriggerFunctions: jest.Mocked<ITriggerFunctions>;
|
||||
let mockContext: EventContext;
|
||||
let mockReceiver: MockReceiver;
|
||||
let mockDeferredPromise: jest.Mocked<IDeferredPromise<IRun>>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
mockDeferredPromise = {
|
||||
promise: Promise.resolve({} as IRun),
|
||||
resolve: jest.fn(),
|
||||
reject: jest.fn(),
|
||||
} as jest.Mocked<IDeferredPromise<IRun>>;
|
||||
|
||||
mockReceiver = {
|
||||
has_credit: jest.fn<boolean, []>().mockReturnValue(true),
|
||||
add_credit: jest.fn(),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
message: {
|
||||
body: 'test message',
|
||||
message_id: 1,
|
||||
},
|
||||
receiver: mockReceiver as unknown as EventContext['receiver'],
|
||||
} as EventContext;
|
||||
|
||||
mockTriggerFunctions = mockDeep<ITriggerFunctions>({
|
||||
helpers: {
|
||||
createDeferredPromise: jest.fn().mockReturnValue(mockDeferredPromise),
|
||||
returnJsonArray: jest.fn((data) => data),
|
||||
},
|
||||
emit: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('message handling', () => {
|
||||
it('should return null when context has no message', async () => {
|
||||
mockContext.message = null as unknown as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockTriggerFunctions.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null for duplicate messages', async () => {
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: 1,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockTriggerFunctions.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit message data correctly', async () => {
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: 1 });
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[[mockContext.message]],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return messageId from processed message', async () => {
|
||||
mockContext.message = {
|
||||
body: 'test',
|
||||
message_id: 'test-id-123',
|
||||
} as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: 'test-id-123' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonParseBody option', () => {
|
||||
it('should parse JSON body when jsonParseBody is true', async () => {
|
||||
mockContext.message = {
|
||||
body: '{"foo":"bar","number":123}',
|
||||
message_id: 2,
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
jsonParseBody: true,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[
|
||||
[
|
||||
{
|
||||
body: { foo: 'bar', number: 123 },
|
||||
message_id: 2,
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not parse JSON body when jsonParseBody is false', async () => {
|
||||
mockContext.message = {
|
||||
body: '{"foo":"bar"}',
|
||||
message_id: 3,
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
jsonParseBody: false,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[
|
||||
[
|
||||
{
|
||||
body: '{"foo":"bar"}',
|
||||
message_id: 3,
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyBody option', () => {
|
||||
it('should return only body when onlyBody is true', async () => {
|
||||
mockContext.message = {
|
||||
body: { nested: { data: 'value' } },
|
||||
message_id: 4,
|
||||
otherProperty: 'should be ignored',
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
onlyBody: true,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[[{ nested: { data: 'value' } }]],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return full message when onlyBody is false', async () => {
|
||||
mockContext.message = {
|
||||
body: { nested: { data: 'value' } },
|
||||
message_id: 5,
|
||||
otherProperty: 'should be included',
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
onlyBody: false,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[[mockContext.message]],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonConvertByteArrayToString option', () => {
|
||||
it('should convert byte array to string when jsonConvertByteArrayToString is true', async () => {
|
||||
mockContext.message = {
|
||||
body: {
|
||||
content: {
|
||||
data: [72, 101, 108, 108, 111],
|
||||
},
|
||||
},
|
||||
message_id: 6,
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
jsonConvertByteArrayToString: true,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalled();
|
||||
const callArgs = mockTriggerFunctions.emit.mock.calls[0][0];
|
||||
expect(callArgs[0][0].body).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should not convert when jsonConvertByteArrayToString is false', async () => {
|
||||
mockContext.message = {
|
||||
body: {
|
||||
content: {
|
||||
data: [72, 101, 108, 108, 111],
|
||||
},
|
||||
},
|
||||
message_id: 7,
|
||||
} as EventContext['message'];
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
jsonConvertByteArrayToString: false,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[[mockContext.message]],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallelProcessing option', () => {
|
||||
it('should create deferred promise when parallelProcessing is false', async () => {
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
parallelProcessing: false,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.helpers.createDeferredPromise).toHaveBeenCalled();
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith(
|
||||
[[mockContext.message]],
|
||||
undefined,
|
||||
mockDeferredPromise,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create deferred promise when parallelProcessing is true', async () => {
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
parallelProcessing: true,
|
||||
});
|
||||
|
||||
expect(mockTriggerFunctions.helpers.createDeferredPromise).not.toHaveBeenCalled();
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalledWith([[mockContext.message]]);
|
||||
});
|
||||
|
||||
it('should await promise when parallelProcessing is false', async () => {
|
||||
let promiseResolved = false;
|
||||
mockDeferredPromise.promise = new Promise<IRun>((resolve) => {
|
||||
setTimeout(() => {
|
||||
promiseResolved = true;
|
||||
resolve({} as IRun);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const handlePromise = handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
parallelProcessing: false,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
await handlePromise;
|
||||
|
||||
expect(promiseResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiver credit management', () => {
|
||||
it('should add credit when receiver has no credit', async () => {
|
||||
mockReceiver.has_credit.mockReturnValue(false);
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 50,
|
||||
sleepTime: 20,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(25);
|
||||
|
||||
expect(mockReceiver.add_credit).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('should not add credit when receiver has credit', async () => {
|
||||
mockReceiver.has_credit.mockReturnValue(true);
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(20);
|
||||
|
||||
expect(mockReceiver.add_credit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default sleepTime of 10ms when not provided', async () => {
|
||||
mockReceiver.has_credit.mockReturnValue(false);
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(15);
|
||||
|
||||
expect(mockReceiver.add_credit).toHaveBeenCalledWith(100);
|
||||
});
|
||||
|
||||
it('should use custom sleepTime when provided', async () => {
|
||||
mockReceiver.has_credit.mockReturnValue(false);
|
||||
|
||||
await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
sleepTime: 50,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(30);
|
||||
expect(mockReceiver.add_credit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(25);
|
||||
expect(mockReceiver.add_credit).toHaveBeenCalledWith(100);
|
||||
});
|
||||
|
||||
it('should handle missing receiver gracefully', async () => {
|
||||
mockContext.receiver = undefined;
|
||||
|
||||
await expect(
|
||||
handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
}),
|
||||
).resolves.toEqual({ messageId: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle message with undefined message_id', async () => {
|
||||
mockContext.message = {
|
||||
body: 'test',
|
||||
message_id: undefined,
|
||||
} as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: undefined });
|
||||
expect(mockTriggerFunctions.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle message with Buffer message_id', async () => {
|
||||
const bufferId = Buffer.from('test-id');
|
||||
mockContext.message = {
|
||||
body: 'test',
|
||||
message_id: bufferId,
|
||||
} as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: bufferId });
|
||||
});
|
||||
|
||||
it('should handle message with string message_id', async () => {
|
||||
mockContext.message = {
|
||||
body: 'test',
|
||||
message_id: 'string-id-123',
|
||||
} as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: 'string-id-123' });
|
||||
});
|
||||
|
||||
it('should handle message with number message_id', async () => {
|
||||
mockContext.message = {
|
||||
body: 'test',
|
||||
message_id: 999,
|
||||
} as EventContext['message'];
|
||||
|
||||
const result = await handleMessage.call(mockTriggerFunctions, mockContext, {
|
||||
lastMessageId: undefined,
|
||||
pullMessagesNumber: 100,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messageId: 999 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
deepCopy,
|
||||
type IDeferredPromise,
|
||||
type IRun,
|
||||
type ITriggerFunctions,
|
||||
jsonParse,
|
||||
} from 'n8n-workflow';
|
||||
import type { EventContext } from 'rhea';
|
||||
|
||||
type MessageId = string | number | Buffer | undefined;
|
||||
|
||||
interface HandleMessageOptions {
|
||||
lastMessageId: MessageId;
|
||||
pullMessagesNumber: number;
|
||||
jsonConvertByteArrayToString?: boolean;
|
||||
jsonParseBody?: boolean;
|
||||
onlyBody?: boolean;
|
||||
parallelProcessing?: boolean;
|
||||
sleepTime?: number;
|
||||
}
|
||||
|
||||
export async function handleMessage(
|
||||
this: ITriggerFunctions,
|
||||
context: EventContext,
|
||||
options: HandleMessageOptions,
|
||||
): Promise<{ messageId: MessageId } | null> {
|
||||
// No message in the context
|
||||
if (!context.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ignore duplicate message check, don't think it's necessary, but it was in the rhea-lib example code
|
||||
if (context.message.message_id && context.message.message_id === options.lastMessageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = context.message;
|
||||
|
||||
if (options.jsonConvertByteArrayToString === true && data.body.content !== undefined) {
|
||||
// The buffer is not ready... Stringify and parse back to load it.
|
||||
const cont = deepCopy(data.body.content);
|
||||
data.body = String.fromCharCode.apply(null, cont.data as number[]);
|
||||
}
|
||||
|
||||
if (options.jsonConvertByteArrayToString === true && data.body.content !== undefined) {
|
||||
// The buffer is not ready... Stringify and parse back to load it.
|
||||
const content = deepCopy(data.body.content);
|
||||
data.body = String.fromCharCode.apply(null, content.data as number[]);
|
||||
}
|
||||
|
||||
if (options.jsonParseBody === true) {
|
||||
data.body = jsonParse(data.body as string);
|
||||
}
|
||||
if (options.onlyBody === true) {
|
||||
data = data.body;
|
||||
}
|
||||
|
||||
let responsePromise: IDeferredPromise<IRun> | undefined = undefined;
|
||||
if (!options.parallelProcessing) {
|
||||
responsePromise = this.helpers.createDeferredPromise();
|
||||
}
|
||||
if (responsePromise) {
|
||||
this.emit([this.helpers.returnJsonArray([data as any])], undefined, responsePromise);
|
||||
await responsePromise.promise;
|
||||
} else {
|
||||
this.emit([this.helpers.returnJsonArray([data as any])]);
|
||||
}
|
||||
|
||||
if (!context.receiver?.has_credit()) {
|
||||
setTimeout(
|
||||
() => {
|
||||
context.receiver?.add_credit(options.pullMessagesNumber);
|
||||
},
|
||||
(options.sleepTime as number) || 10,
|
||||
);
|
||||
}
|
||||
return { messageId: context.message.message_id };
|
||||
}
|
||||
Reference in New Issue
Block a user