amqplib-envelop
Version:
A wrapper for working with RabbitMQ using the amqplib npm package
204 lines (158 loc) • 6.89 kB
JavaScript
const { EventEmitter } = require('events');
const RpcClient = require('../src/RpcClient');
const CommandResult = require('../src/CommandResult');
const Command = require('../src/Command');
function createFakeRabbitmq() {
const rabbitmq = new EventEmitter();
rabbitmq.channel = {
assertQueue: jest.fn().mockResolvedValue({ queue: 'amq.gen-reply-1' }),
consume: jest.fn(),
sendToQueue: jest.fn(),
};
return rabbitmq;
}
describe('RpcClient', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('throws without a RabbitMQ instance', () => {
expect(() => new RpcClient(null, 'queue')).toThrow(/connected RabbitMQ instance/);
});
it('throws without a queue name', () => {
expect(() => new RpcClient(createFakeRabbitmq(), '')).toThrow(/request queue name/);
});
it('registers a reconnect listener on construction', () => {
const rabbitmq = createFakeRabbitmq();
// eslint-disable-next-line no-new
new RpcClient(rabbitmq, 'rpc.queue');
expect(rabbitmq.listenerCount('reconnect')).toBe(1);
});
it('rejects sendCommand() before init() has been called', async () => {
const client = new RpcClient(createFakeRabbitmq(), 'rpc.queue');
await expect(client.sendCommand('ping')).rejects.toThrow(/not initialized/);
});
describe('init()', () => {
it('creates an exclusive reply queue and starts consuming it', async () => {
const rabbitmq = createFakeRabbitmq();
const client = new RpcClient(rabbitmq, 'rpc.queue');
await client.init();
expect(rabbitmq.channel.assertQueue).toHaveBeenCalledWith('', { exclusive: true });
expect(client.replyQueue).toBe('amq.gen-reply-1');
expect(rabbitmq.channel.consume).toHaveBeenCalledWith(
'amq.gen-reply-1',
expect.any(Function),
{ noAck: true },
);
});
});
describe('sendCommand()', () => {
async function initClient(rabbitmq, timeout) {
const client = new RpcClient(rabbitmq, 'rpc.queue', timeout);
await client.init();
return client;
}
it('sends a packed Command to the request queue with correlation metadata', async () => {
const rabbitmq = createFakeRabbitmq();
const client = await initClient(rabbitmq, 5000);
client.sendCommand('getMovie', ['Endgame'], { persistent: true });
expect(rabbitmq.channel.sendToQueue).toHaveBeenCalledTimes(1);
const [queue, buffer, properties] = rabbitmq.channel.sendToQueue.mock.calls[0];
expect(queue).toBe('rpc.queue');
expect(Command.fromBuffer(buffer)).toEqual(new Command('getMovie', ['Endgame']));
expect(properties).toEqual({
persistent: true,
correlationId: '1',
replyTo: 'amq.gen-reply-1',
expiration: '5000',
});
});
it('resolves with the data from a successful reply', async () => {
const rabbitmq = createFakeRabbitmq();
const client = await initClient(rabbitmq, 5000);
const replyHandler = rabbitmq.channel.consume.mock.calls[0][1];
const promise = client.sendCommand('getMovie', ['Endgame']);
const { correlationId } = rabbitmq.channel.sendToQueue.mock.calls[0][2];
replyHandler({
properties: { correlationId },
content: new CommandResult(CommandResult.STATES.SUCCESS, { title: 'Endgame' }).pack(),
});
await expect(promise).resolves.toEqual({ title: 'Endgame' });
});
it('rejects with the error data from a failed reply', async () => {
const rabbitmq = createFakeRabbitmq();
const client = await initClient(rabbitmq, 5000);
const replyHandler = rabbitmq.channel.consume.mock.calls[0][1];
const promise = client.sendCommand('boom');
const { correlationId } = rabbitmq.channel.sendToQueue.mock.calls[0][2];
replyHandler({
properties: { correlationId },
content: new CommandResult(CommandResult.STATES.ERROR, new Error('kaboom')).pack(),
});
await expect(promise).rejects.toThrow('kaboom');
});
it('ignores replies for unknown/expired correlation ids', async () => {
const rabbitmq = createFakeRabbitmq();
await initClient(rabbitmq, 5000);
const replyHandler = rabbitmq.channel.consume.mock.calls[0][1];
expect(() => replyHandler({
properties: { correlationId: 'unknown' },
content: new CommandResult(CommandResult.STATES.SUCCESS, null).pack(),
})).not.toThrow();
expect(() => replyHandler(null)).not.toThrow();
});
it('rejects when no reply arrives before the timeout', async () => {
jest.useFakeTimers();
const rabbitmq = createFakeRabbitmq();
const client = await initClient(rabbitmq, 1000);
const promise = client.sendCommand('getMovie', ['Endgame']);
const assertion = expect(promise).rejects.toThrow(/timed out after 1000ms/);
await jest.advanceTimersByTimeAsync(1000);
await assertion;
});
it('increments the correlation id for each call', async () => {
const rabbitmq = createFakeRabbitmq();
const client = await initClient(rabbitmq, 5000);
client.sendCommand('a');
client.sendCommand('b');
const ids = rabbitmq.channel.sendToQueue.mock.calls.map((call) => call[2].correlationId);
expect(ids).toEqual(['1', '2']);
});
});
describe('reconnect handling', () => {
it('recreates the reply queue and consumer when the RabbitMQ instance reconnects', async () => {
const rabbitmq = createFakeRabbitmq();
const client = new RpcClient(rabbitmq, 'rpc.queue');
await client.init();
rabbitmq.channel.assertQueue.mockResolvedValueOnce({ queue: 'amq.gen-reply-2' });
rabbitmq.emit('reconnect');
await Promise.resolve();
await Promise.resolve();
expect(rabbitmq.channel.assertQueue).toHaveBeenCalledTimes(2);
expect(client.replyQueue).toBe('amq.gen-reply-2');
});
it('logs instead of throwing if recreating the reply queue fails', async () => {
const rabbitmq = createFakeRabbitmq();
jest.spyOn(console, 'error').mockImplementation(() => {});
const client = new RpcClient(rabbitmq, 'rpc.queue');
await client.init();
rabbitmq.channel.assertQueue.mockRejectedValueOnce(new Error('down'));
rabbitmq.emit('reconnect');
await Promise.resolve();
await Promise.resolve();
expect(console.error).toHaveBeenCalledWith('RpcClient reconnect error:', 'down');
});
});
describe('close()', () => {
it('removes the reconnect listener', () => {
const rabbitmq = createFakeRabbitmq();
const client = new RpcClient(rabbitmq, 'rpc.queue');
expect(rabbitmq.listenerCount('reconnect')).toBe(1);
client.close();
expect(rabbitmq.listenerCount('reconnect')).toBe(0);
});
});
});