refactor(RabbitMQ Trigger Node): Improve type-safety, add tests, and fix issues with manual triggers (#10663)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-09-05 08:11:38 +02:00 committed by GitHub
parent a5a92ec8b1
commit e50f0e6a4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 470 additions and 215 deletions

View file

@ -1,4 +1,4 @@
import type { ICredentialType, IDisplayOptions, INodeProperties } from 'n8n-workflow';
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class RabbitMQ implements ICredentialType {
name = 'rabbitmq';
@ -90,7 +90,7 @@ export class RabbitMQ implements ICredentialType {
ssl: [true],
passwordless: [true],
},
} as IDisplayOptions,
},
default: '',
description: 'SSL Client Certificate to use',
},

View file

@ -1,64 +1,55 @@
import type { IDataObject, IExecuteFunctions, ITriggerFunctions } from 'n8n-workflow';
import { sleep } from 'n8n-workflow';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
ITriggerFunctions,
} from 'n8n-workflow';
import { jsonParse, sleep } from 'n8n-workflow';
import * as amqplib from 'amqplib';
import { formatPrivateKey } from '@utils/utilities';
import type { ExchangeType, Options, RabbitMQCredentials, TriggerOptions } from './types';
const credentialKeys = ['hostname', 'port', 'username', 'password', 'vhost'] as const;
export async function rabbitmqConnect(
this: IExecuteFunctions | ITriggerFunctions,
options: IDataObject,
): Promise<amqplib.Channel> {
const credentials = await this.getCredentials('rabbitmq');
const credentialKeys = ['hostname', 'port', 'username', 'password', 'vhost'];
const credentialData: IDataObject = {};
credentialKeys.forEach((key) => {
credentialData[key] = credentials[key] === '' ? undefined : credentials[key];
});
credentials: RabbitMQCredentials,
): Promise<amqplib.Connection> {
const credentialData = credentialKeys.reduce((acc, key) => {
acc[key] = credentials[key] === '' ? undefined : credentials[key];
return acc;
}, {} as IDataObject) as amqplib.Options.Connect;
const optsData: IDataObject = {};
if (credentials.ssl === true) {
if (credentials.ssl) {
credentialData.protocol = 'amqps';
optsData.ca =
credentials.ca === '' ? undefined : [Buffer.from(formatPrivateKey(credentials.ca as string))];
if (credentials.passwordless === true) {
credentials.ca === '' ? undefined : [Buffer.from(formatPrivateKey(credentials.ca))];
if (credentials.passwordless) {
optsData.cert =
credentials.cert === ''
? undefined
: Buffer.from(formatPrivateKey(credentials.cert as string));
credentials.cert === '' ? undefined : Buffer.from(formatPrivateKey(credentials.cert));
optsData.key =
credentials.key === ''
? undefined
: Buffer.from(formatPrivateKey(credentials.key as string));
credentials.key === '' ? undefined : Buffer.from(formatPrivateKey(credentials.key));
optsData.passphrase = credentials.passphrase === '' ? undefined : credentials.passphrase;
optsData.credentials = amqplib.credentials.external();
}
}
return await amqplib.connect(credentialData, optsData);
}
export async function rabbitmqCreateChannel(
this: IExecuteFunctions | ITriggerFunctions,
): Promise<amqplib.Channel> {
const credentials = await this.getCredentials<RabbitMQCredentials>('rabbitmq');
return await new Promise(async (resolve, reject) => {
try {
const connection = await amqplib.connect(credentialData, optsData);
connection.on('error', (error: Error) => {
reject(error);
});
const channel = (await connection.createChannel().catch(console.warn)) as amqplib.Channel;
if (
options.arguments &&
((options.arguments as IDataObject).argument! as IDataObject[]).length
) {
const additionalArguments: IDataObject = {};
((options.arguments as IDataObject).argument as IDataObject[]).forEach(
(argument: IDataObject) => {
additionalArguments[argument.key as string] = argument.value;
},
);
options.arguments = additionalArguments;
}
const connection = await rabbitmqConnect(credentials);
// TODO: why is this error handler being added here?
connection.on('error', reject);
const channel = await connection.createChannel();
resolve(channel);
} catch (error) {
reject(error);
@ -69,9 +60,9 @@ export async function rabbitmqConnect(
export async function rabbitmqConnectQueue(
this: IExecuteFunctions | ITriggerFunctions,
queue: string,
options: IDataObject,
options: Options | TriggerOptions,
): Promise<amqplib.Channel> {
const channel = await rabbitmqConnect.call(this, options);
const channel = await rabbitmqCreateChannel.call(this);
return await new Promise(async (resolve, reject) => {
try {
@ -81,16 +72,10 @@ export async function rabbitmqConnectQueue(
await channel.checkQueue(queue);
}
if (options.binding && ((options.binding as IDataObject).bindings! as IDataObject[]).length) {
((options.binding as IDataObject).bindings as IDataObject[]).forEach(
async (binding: IDataObject) => {
await channel.bindQueue(
queue,
binding.exchange as string,
binding.routingKey as string,
);
},
);
if ('binding' in options && options.binding?.bindings.length) {
options.binding.bindings.forEach(async (binding) => {
await channel.bindQueue(queue, binding.exchange, binding.routingKey);
});
}
resolve(channel);
@ -103,15 +88,15 @@ export async function rabbitmqConnectQueue(
export async function rabbitmqConnectExchange(
this: IExecuteFunctions | ITriggerFunctions,
exchange: string,
type: string,
options: IDataObject,
options: Options | TriggerOptions,
): Promise<amqplib.Channel> {
const channel = await rabbitmqConnect.call(this, options);
const exchangeType = this.getNodeParameter('exchangeType', 0) as ExchangeType;
const channel = await rabbitmqCreateChannel.call(this);
return await new Promise(async (resolve, reject) => {
try {
if (options.assertExchange) {
await channel.assertExchange(exchange, type, options);
await channel.assertExchange(exchange, exchangeType, options);
} else {
await channel.checkExchange(exchange);
}
@ -170,3 +155,41 @@ export class MessageTracker {
await channel.connection.close();
}
}
export const parsePublishArguments = (options: Options) => {
const additionalArguments: IDataObject = {};
if (options.arguments?.argument.length) {
options.arguments.argument.forEach((argument) => {
additionalArguments[argument.key] = argument.value;
});
}
return additionalArguments as amqplib.Options.Publish;
};
export const parseMessage = async (
message: amqplib.Message,
options: TriggerOptions,
helpers: ITriggerFunctions['helpers'],
): Promise<INodeExecutionData> => {
if (options.contentIsBinary) {
const { content } = message;
message.content = undefined as unknown as Buffer;
return {
binary: {
data: await helpers.prepareBinaryData(content),
},
json: message as unknown as IDataObject,
};
} else {
let content: IDataObject | string = message.content.toString();
if (options.jsonParseBody) {
content = jsonParse(content);
}
if (options.onlyContent) {
return { json: content as IDataObject };
} else {
message.content = content as unknown as Buffer;
return { json: message as unknown as IDataObject };
}
}
};

View file

@ -1,6 +1,5 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import * as amqplib from 'amqplib';
import type { Options } from 'amqplib';
import type * as amqplib from 'amqplib';
import type {
IExecuteFunctions,
ICredentialsDecrypted,
@ -14,8 +13,13 @@ import type {
} from 'n8n-workflow';
import { NodeApiError, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { rabbitmqConnectExchange, rabbitmqConnectQueue } from './GenericFunctions';
import { formatPrivateKey } from '@utils/utilities';
import {
parsePublishArguments,
rabbitmqConnect,
rabbitmqConnectExchange,
rabbitmqConnectQueue,
} from './GenericFunctions';
import type { Options, RabbitMQCredentials } from './types';
export class RabbitMQ implements INodeType {
description: INodeTypeDescription = {
@ -363,38 +367,8 @@ export class RabbitMQ implements INodeType {
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const credentials = credential.data as IDataObject;
try {
const credentialKeys = ['hostname', 'port', 'username', 'password', 'vhost'];
const credentialData: IDataObject = {};
credentialKeys.forEach((key) => {
credentialData[key] = credentials[key] === '' ? undefined : credentials[key];
});
const optsData: IDataObject = {};
if (credentials.ssl === true) {
credentialData.protocol = 'amqps';
optsData.ca =
credentials.ca === ''
? undefined
: [Buffer.from(formatPrivateKey(credentials.ca as string))];
if (credentials.passwordless === true) {
optsData.cert =
credentials.cert === ''
? undefined
: Buffer.from(formatPrivateKey(credentials.cert as string));
optsData.key =
credentials.key === ''
? undefined
: Buffer.from(formatPrivateKey(credentials.key as string));
optsData.passphrase =
credentials.passphrase === '' ? undefined : credentials.passphrase;
optsData.credentials = amqplib.credentials.external();
}
}
const connection = await amqplib.connect(credentialData, optsData);
const connection = await rabbitmqConnect(credential.data as RabbitMQCredentials);
await connection.close();
} catch (error) {
return {
@ -411,7 +385,7 @@ export class RabbitMQ implements INodeType {
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let channel, options: IDataObject;
let channel: amqplib.Channel | undefined;
try {
const items = this.getInputData();
const operation = this.getNodeParameter('operation', 0);
@ -424,7 +398,7 @@ export class RabbitMQ implements INodeType {
if (mode === 'queue') {
const queue = this.getNodeParameter('queue', 0) as string;
options = this.getNodeParameter('options', 0, {});
const options = this.getNodeParameter('options', 0, {}) as Options;
channel = await rabbitmqConnectQueue.call(this, queue, options);
@ -457,7 +431,7 @@ export class RabbitMQ implements INodeType {
queuePromises.push(
channel.sendToQueue(queue, Buffer.from(message), {
headers,
...(options.arguments ? (options.arguments as Options.Publish) : {}),
...parsePublishArguments(options),
}),
);
}
@ -492,12 +466,11 @@ export class RabbitMQ implements INodeType {
await channel.connection.close();
} else if (mode === 'exchange') {
const exchange = this.getNodeParameter('exchange', 0) as string;
const type = this.getNodeParameter('exchangeType', 0) as string;
const routingKey = this.getNodeParameter('routingKey', 0) as string;
options = this.getNodeParameter('options', 0, {});
const options = this.getNodeParameter('options', 0, {}) as Options;
channel = await rabbitmqConnectExchange.call(this, exchange, type, options);
channel = await rabbitmqConnectExchange.call(this, exchange, options);
const sendInputData = this.getNodeParameter('sendInputData', 0) as boolean;
@ -529,7 +502,7 @@ export class RabbitMQ implements INodeType {
exchangePromises.push(
channel.publish(exchange, routingKey, Buffer.from(message), {
headers,
...(options.arguments ? (options.arguments as Options.Publish) : {}),
...parsePublishArguments(options),
}),
);
}

View file

@ -1,9 +1,8 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { Message } from 'amqplib';
import type {
IDataObject,
IDeferredPromise,
IExecuteResponsePromiseData,
INodeExecutionData,
INodeProperties,
INodeType,
INodeTypeDescription,
@ -15,7 +14,8 @@ import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { rabbitDefaultOptions } from './DefaultOptions';
import { MessageTracker, rabbitmqConnectQueue } from './GenericFunctions';
import { MessageTracker, rabbitmqConnectQueue, parseMessage } from './GenericFunctions';
import type { TriggerOptions } from './types';
export class RabbitMQTrigger implements INodeType {
description: INodeTypeDescription = {
@ -205,28 +205,50 @@ export class RabbitMQTrigger implements INodeType {
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const queue = this.getNodeParameter('queue') as string;
const options = this.getNodeParameter('options', {}) as IDataObject;
const options = this.getNodeParameter('options', {}) as TriggerOptions;
const channel = await rabbitmqConnectQueue.call(this, queue, options);
let parallelMessages =
options.parallelMessages !== undefined && options.parallelMessages !== -1
? parseInt(options.parallelMessages as string, 10)
: -1;
if (this.getMode() === 'manual') {
const manualTriggerFunction = async () => {
// Do only catch a single message when executing manually, else messages will leak
await channel.prefetch(1);
if (parallelMessages === 0 || parallelMessages < -1) {
const processMessage = async (message: Message | null) => {
if (message !== null) {
const item = await parseMessage(message, options, this.helpers);
channel.ack(message);
this.emit([[item]]);
} else {
this.emitError(new Error('Connection got closed unexpectedly'));
}
};
const existingMessage = await channel.get(queue);
if (existingMessage) await processMessage(existingMessage);
else await channel.consume(queue, processMessage);
};
const closeFunction = async () => {
await channel.close();
await channel.connection.close();
return;
};
return {
closeFunction,
manualTriggerFunction,
};
}
const parallelMessages = options.parallelMessages ?? -1;
if (isNaN(parallelMessages) || parallelMessages === 0 || parallelMessages < -1) {
throw new NodeOperationError(
this.getNode(),
'Parallel message processing limit must be greater than zero (or -1 for no limit)',
'Parallel message processing limit must be a number greater than zero (or -1 for no limit)',
);
}
if (this.getMode() === 'manual') {
// Do only catch a single message when executing manually, else messages will leak
parallelMessages = 1;
}
let acknowledgeMode = options.acknowledge ? options.acknowledge : 'immediately';
let acknowledgeMode = options.acknowledge ?? 'immediately';
if (parallelMessages !== -1 && acknowledgeMode === 'immediately') {
// If parallel message limit is set, then the default mode is "executionFinishes"
@ -236,108 +258,82 @@ export class RabbitMQTrigger implements INodeType {
}
const messageTracker = new MessageTracker();
let consumerTag: string;
let closeGotCalled = false;
const startConsumer = async () => {
if (parallelMessages !== -1) {
await channel.prefetch(parallelMessages);
if (parallelMessages !== -1) {
await channel.prefetch(parallelMessages);
}
channel.on('close', () => {
if (!closeGotCalled) {
this.emitError(new Error('Connection got closed unexpectedly'));
}
});
channel.on('close', () => {
if (!closeGotCalled) {
this.emitError(new Error('Connection got closed unexpectedly'));
}
});
const consumerInfo = await channel.consume(queue, async (message) => {
if (message !== null) {
try {
if (acknowledgeMode !== 'immediately') {
messageTracker.received(message);
}
let content: IDataObject | string = message.content.toString();
const item: INodeExecutionData = {
json: {},
};
if (options.contentIsBinary === true) {
item.binary = {
data: await this.helpers.prepareBinaryData(message.content),
};
item.json = message as unknown as IDataObject;
message.content = undefined as unknown as Buffer;
} else {
if (options.jsonParseBody === true) {
content = JSON.parse(content);
}
if (options.onlyContent === true) {
item.json = content as IDataObject;
} else {
message.content = content as unknown as Buffer;
item.json = message as unknown as IDataObject;
}
}
let responsePromise: IDeferredPromise<IRun> | undefined = undefined;
let responsePromiseHook: IDeferredPromise<IExecuteResponsePromiseData> | undefined =
undefined;
if (acknowledgeMode !== 'immediately' && acknowledgeMode !== 'laterMessageNode') {
responsePromise = await this.helpers.createDeferredPromise();
} else if (acknowledgeMode === 'laterMessageNode') {
responsePromiseHook =
await this.helpers.createDeferredPromise<IExecuteResponsePromiseData>();
}
if (responsePromiseHook) {
this.emit([[item]], responsePromiseHook, undefined);
} else {
this.emit([[item]], undefined, responsePromise);
}
if (responsePromise && acknowledgeMode !== 'laterMessageNode') {
// Acknowledge message after the execution finished
await responsePromise.promise().then(async (data: IRun) => {
if (data.data.resultData.error) {
// The execution did fail
if (acknowledgeMode === 'executionFinishesSuccessfully') {
channel.nack(message);
messageTracker.answered(message);
return;
}
}
channel.ack(message);
messageTracker.answered(message);
});
} else if (responsePromiseHook && acknowledgeMode === 'laterMessageNode') {
await responsePromiseHook.promise().then(() => {
channel.ack(message);
messageTracker.answered(message);
});
} else {
// Acknowledge message directly
channel.ack(message);
}
} catch (error) {
const workflow = this.getWorkflow();
const node = this.getNode();
if (acknowledgeMode !== 'immediately') {
messageTracker.answered(message);
}
this.logger.error(
`There was a problem with the RabbitMQ Trigger node "${node.name}" in workflow "${workflow.id}": "${error.message}"`,
{
node: node.name,
workflowId: workflow.id,
},
);
const consumerInfo = await channel.consume(queue, async (message) => {
if (message !== null) {
try {
if (acknowledgeMode !== 'immediately') {
messageTracker.received(message);
}
const item = await parseMessage(message, options, this.helpers);
let responsePromise: IDeferredPromise<IRun> | undefined = undefined;
let responsePromiseHook: IDeferredPromise<IExecuteResponsePromiseData> | undefined =
undefined;
if (acknowledgeMode !== 'immediately' && acknowledgeMode !== 'laterMessageNode') {
responsePromise = await this.helpers.createDeferredPromise();
} else if (acknowledgeMode === 'laterMessageNode') {
responsePromiseHook =
await this.helpers.createDeferredPromise<IExecuteResponsePromiseData>();
}
if (responsePromiseHook) {
this.emit([[item]], responsePromiseHook, undefined);
} else {
this.emit([[item]], undefined, responsePromise);
}
if (responsePromise && acknowledgeMode !== 'laterMessageNode') {
// Acknowledge message after the execution finished
await responsePromise.promise().then(async (data: IRun) => {
if (data.data.resultData.error) {
// The execution did fail
if (acknowledgeMode === 'executionFinishesSuccessfully') {
channel.nack(message);
messageTracker.answered(message);
return;
}
}
channel.ack(message);
messageTracker.answered(message);
});
} else if (responsePromiseHook && acknowledgeMode === 'laterMessageNode') {
await responsePromiseHook.promise().then(() => {
channel.ack(message);
messageTracker.answered(message);
});
} else {
// Acknowledge message directly
channel.ack(message);
}
} catch (error) {
const workflow = this.getWorkflow();
const node = this.getNode();
if (acknowledgeMode !== 'immediately') {
messageTracker.answered(message);
}
this.logger.error(
`There was a problem with the RabbitMQ Trigger node "${node.name}" in workflow "${workflow.id}": "${error.message}"`,
{
node: node.name,
workflowId: workflow.id,
},
);
}
});
consumerTag = consumerInfo.consumerTag;
};
await startConsumer();
}
});
const consumerTag = consumerInfo.consumerTag;
// The "closeFunction" function gets called by n8n whenever
// the workflow gets deactivated and can so clean up.

View file

@ -0,0 +1,192 @@
import type { Channel, Connection, ConsumeMessage, Message } from 'amqplib';
import { mock } from 'jest-mock-extended';
import type { ITriggerFunctions } from 'n8n-workflow';
const mockChannel = mock<Channel>();
const mockConnection = mock<Connection>({ createChannel: async () => mockChannel });
mockChannel.connection = mockConnection;
const connect = jest.fn().mockReturnValue(mockConnection);
jest.mock('amqplib', () => ({ connect }));
import type { TriggerOptions } from '../types';
import {
parseMessage,
rabbitmqConnect,
rabbitmqConnectExchange,
rabbitmqConnectQueue,
rabbitmqCreateChannel,
MessageTracker,
} from '../GenericFunctions';
describe('RabbitMQ GenericFunctions', () => {
const credentials = {
hostname: 'some.host',
port: 5672,
username: 'user',
password: 'pass',
vhost: '/',
};
const context = mock<ITriggerFunctions>();
beforeEach(() => jest.clearAllMocks());
describe('parseMessage', () => {
const helpers = mock<ITriggerFunctions['helpers']>();
it('should handle binary data', async () => {
const message = mock<Message>();
const content = Buffer.from('test');
message.content = content;
const options = mock<TriggerOptions>({ contentIsBinary: true });
helpers.prepareBinaryData.mockResolvedValue(mock());
const item = await parseMessage(message, options, helpers);
expect(item.json).toBe(message);
expect(item.binary?.data).toBeDefined();
expect(helpers.prepareBinaryData).toHaveBeenCalledWith(content);
expect(message.content).toBeUndefined();
});
it('should handle JSON data', async () => {
const message = mock<Message>();
const content = Buffer.from(JSON.stringify({ test: 'test' }));
message.content = content;
const options = mock<TriggerOptions>({
contentIsBinary: false,
jsonParseBody: true,
onlyContent: false,
});
const item = await parseMessage(message, options, helpers);
expect(item.json).toBe(message);
expect(item.binary).toBeUndefined();
expect(helpers.prepareBinaryData).not.toHaveBeenCalled();
expect(message.content).toEqual({ test: 'test' });
});
it('should return only content, when requested', async () => {
const message = mock<Message>();
const content = Buffer.from(JSON.stringify({ test: 'test' }));
message.content = content;
const options = mock<TriggerOptions>({
contentIsBinary: false,
jsonParseBody: false,
onlyContent: true,
});
const item = await parseMessage(message, options, helpers);
expect(item.json).toBe(content.toString());
expect(item.binary).toBeUndefined();
expect(helpers.prepareBinaryData).not.toHaveBeenCalled();
expect(message.content).toEqual(content);
});
});
describe('rabbitmqConnect', () => {
it('should connect to RabbitMQ', async () => {
const connection = await rabbitmqConnect({ ...credentials, ssl: false });
expect(connect).toHaveBeenCalledWith(credentials, {});
expect(connection).toBe(mockConnection);
});
it('should connect to RabbitMQ over SSL', async () => {
const connection = await rabbitmqConnect({
...credentials,
ssl: true,
ca: 'ca',
passwordless: false,
});
expect(connect).toHaveBeenCalledWith(
{ ...credentials, protocol: 'amqps' },
{ ca: [Buffer.from('ca')] },
);
expect(connection).toBe(mockConnection);
});
});
describe('rabbitmqCreateChannel', () => {
it('should create a channel', async () => {
context.getCredentials.mockResolvedValue(credentials);
const channel = await rabbitmqCreateChannel.call(context);
expect(channel).toBe(mockChannel);
});
});
describe('rabbitmqConnectQueue', () => {
it('should assert a queue', async () => {
context.getCredentials.mockResolvedValue(credentials);
const options = mock<TriggerOptions>({ assertQueue: true });
await rabbitmqConnectQueue.call(context, 'queue', options);
expect(mockChannel.assertQueue).toHaveBeenCalledWith('queue', options);
expect(mockChannel.checkQueue).not.toHaveBeenCalled();
expect(mockChannel.bindQueue).not.toHaveBeenCalled();
});
it('should check a queue', async () => {
context.getCredentials.mockResolvedValue(credentials);
const options = mock<TriggerOptions>({ assertQueue: false });
await rabbitmqConnectQueue.call(context, 'queue', options);
expect(mockChannel.assertQueue).not.toHaveBeenCalled();
expect(mockChannel.checkQueue).toHaveBeenCalledWith('queue');
expect(mockChannel.bindQueue).not.toHaveBeenCalled();
});
});
describe('rabbitmqConnectExchange', () => {
it('should assert a queue', async () => {
context.getCredentials.mockResolvedValue(credentials);
context.getNodeParameter.calledWith('exchangeType', 0).mockReturnValue('topic');
const options = mock<TriggerOptions>({ assertExchange: true });
await rabbitmqConnectExchange.call(context, 'exchange', options);
expect(mockChannel.assertExchange).toHaveBeenCalledWith('exchange', 'topic', options);
expect(mockChannel.checkExchange).not.toHaveBeenCalled();
});
it('should check a queue', async () => {
context.getCredentials.mockResolvedValue(credentials);
const options = mock<TriggerOptions>({ assertExchange: false });
await rabbitmqConnectExchange.call(context, 'exchange', options);
expect(mockChannel.assertExchange).not.toHaveBeenCalled();
expect(mockChannel.checkExchange).toHaveBeenCalledWith('exchange');
});
});
describe('MessageTracker', () => {
let messageTracker: MessageTracker;
beforeEach(() => {
messageTracker = new MessageTracker();
});
it('should track received messages', () => {
const message = { fields: { deliveryTag: 1 } } as ConsumeMessage;
messageTracker.received(message);
expect(messageTracker.messages).toContain(1);
});
it('should track answered messages', () => {
const message = { fields: { deliveryTag: 1 } } as ConsumeMessage;
messageTracker.received(message);
messageTracker.answered(message);
expect(messageTracker.messages).not.toContain(1);
});
it('should return the number of unanswered messages', () => {
const message = { fields: { deliveryTag: 1 } } as ConsumeMessage;
messageTracker.received(message);
expect(messageTracker.unansweredMessages()).toBe(1);
});
it('should close the channel and connection', async () => {
await messageTracker.closeChannel(mockChannel, 'consumerTag');
expect(mockChannel.cancel).toHaveBeenCalledWith('consumerTag');
expect(mockChannel.close).toHaveBeenCalled();
expect(mockConnection.close).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,71 @@
type Argument = {
key: string;
value?: string;
};
type Binding = {
exchange: string;
routingKey: string;
};
type Header = {
key: string;
value?: string;
};
export type Options = {
autoDelete: boolean;
assertExchange: boolean;
assertQueue: boolean;
durable: boolean;
exclusive: boolean;
arguments: {
argument: Argument[];
};
headers: {
header: Header[];
};
};
type ContentOptions =
| {
contentIsBinary: true;
}
| {
contentIsBinary: false;
jsonParseBody: boolean;
onlyContent: boolean;
};
export type TriggerOptions = Options & {
acknowledge:
| 'executionFinishes'
| 'executionFinishesSuccessfully'
| 'immediately'
| 'laterMessageNode';
parallelMessages: number;
binding: {
bindings: Binding[];
};
} & ContentOptions;
export type RabbitMQCredentials = {
hostname: string;
port: number;
username: string;
password: string;
vhost: string;
} & (
| { ssl: false }
| ({ ssl: true; ca: string } & (
| { passwordless: false }
| {
passwordless: true;
cert: string;
key: string;
passphrase: string;
}
))
);
export type ExchangeType = 'direct' | 'topic' | 'headers' | 'fanout';