mock-amqplib
Version:
stub rabbit mq in integration tests
456 lines (423 loc) • 13.3 kB
JavaScript
const EventEmitter = require('events');
const DEFAULT_EXCHANGE_NAME = '';
const msgQueueNames = new WeakMap();
const deadLetterTimers = new WeakMap();
const createQueue = options => {
let messages = [];
let subscriber = null;
const getTtl = () => {
if (
options &&
options.arguments &&
!isNaN(Number(options.arguments['x-message-ttl']))
) {
return Number(options.arguments['x-message-ttl']);
}
return undefined;
};
const clearExpiration = msg => {
if (deadLetterTimers.has(msg)) {
clearTimeout(deadLetterTimers.get(msg));
deadLetterTimers.delete(msg);
}
return msg;
};
const setExpiration = msg => {
const msgTtl = Number(msg.properties.expiration);
const queueTtl = getTtl();
const ttl = msgTtl >= 0 && queueTtl >= 0 ? Math.min(msgTtl, queueTtl)
: msgTtl >= 0 ? msgTtl
: queueTtl >= 0 ? queueTtl
: undefined;
if (ttl >= 0) {
deadLetterTimers.set(msg, setTimeout(() => {
const index = messages.indexOf(msg);
if (index >= 0) {
messages.splice(index, 1);
deadLetterProceed(clearExpiration(msg), 'expired', ttl === msgTtl);
}
}, ttl));
}
return msg;
};
return {
add: async item => {
if (subscriber) {
await subscriber(item);
} else {
messages.push(setExpiration(item));
}
},
get: () => clearExpiration(messages.shift()) || false,
addConsumer: consumer => {
messages.forEach(item => consumer(clearExpiration(item)));
messages = [];
subscriber = consumer;
},
stopConsume: () => (subscriber = null),
getMessageCount: () => messages.length,
getConsumerCount: () => subscriber ? 1 : 0,
purge: () => (messages = []),
getDeadLetterInfo: () => {
if (options && options.arguments) {
const {
'x-dead-letter-exchange': exchange,
'x-dead-letter-routing-key': routingKey,
} = options.arguments;
return { exchange, routingKey };
}
return {};
},
};
};
const createFanoutExchange = options => {
const bindings = [];
return {
bindQueue: (queueName, pattern, options) => {
bindings.push({
targetQueue: queueName,
options,
pattern
});
},
getTargetQueues: (routingKey, options = {}) => {
return bindings.map(b => b.targetQueue);
},
getOptions: () => options,
};
};
const createDirectExchange = options => {
const bindings = [];
return {
bindQueue: (queueName, pattern, options) => {
bindings.push({
targetQueue: queueName,
options,
pattern
});
},
getTargetQueues: (routingKey, options = {}) =>
bindings.filter(b => b.pattern === routingKey).map(b => b.targetQueue),
getOptions: () => options,
};
};
const createTopicExchange = options => {
const bindings = [];
const maskToRegexp = mask => {
const words = mask.split('.');
const del = '\\.';
let strForRegexp = '^';
let moveDel = false;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const first = i === 0;
const prefix = !first && !moveDel ? del : '';
moveDel = false;
if (word === '*') {
strForRegexp += `${prefix}\\w+`;
} else if (word === '#') {
if (first) {
moveDel = true;
strForRegexp += `(\\w+${del}?)*`;
} else {
strForRegexp += `(${prefix}${prefix && '?'}\\w+)*`;
}
} else {
strForRegexp += prefix + word;
}
}
strForRegexp += '$';
return new RegExp(strForRegexp);
}
return {
bindQueue: (queueName, pattern, options) => {
bindings.push({
targetQueue: queueName,
options,
pattern,
patternRegexp: maskToRegexp(pattern)
});
},
getTargetQueues: (routingKey, options = {}) =>
bindings.filter(b => b.patternRegexp.test(routingKey)).map(b => b.targetQueue),
getOptions: () => options,
};
};
const createHeadersExchange = options => {
const bindings = [];
return {
bindQueue: (queueName, pattern, options) => {
bindings.push({
targetQueue: queueName,
options,
pattern
});
},
getTargetQueues: (routingKey, options = {}) => {
const isMatching = (binding, headers = {}) =>
Object.keys(binding.options).every(key => binding.options[key] === headers[key]);
return bindings.filter(b => isMatching(b, options.headers)).map(b => b.targetQueue);
},
getOptions: () => options,
};
};
const queues = {};
const exchanges = {
[DEFAULT_EXCHANGE_NAME]: createDirectExchange({}),
};
const publishMessage = (exchangeName, routingKey, content, options) => {
const exchange = exchanges[exchangeName];
const queueNames = exchange.getTargetQueues(routingKey, options);
const { mandatory } = options;
const message = {
content,
fields: {
exchange: exchangeName,
routingKey
},
properties: {
headers: {},
...options
}
};
if (!queueNames.length) {
const { alternateExchange } = exchange.getOptions();
if (mandatory) {
// returns message to emit it as 'return' event
return message;
} else if (!!alternateExchange && alternateExchange !== exchangeName) {
return publishMessage(alternateExchange, routingKey, content, options);
}
}
for(const queueName of queueNames) {
const newMsg = { ...message };
msgQueueNames.set(newMsg, queueName);
queues[queueName].add(newMsg);
}
}
const getQueueName = msg => {
const queueName = msgQueueNames.get(msg);
if (!queueName) {
throw new Error('Message object is not found');
}
return queueName;
}
const deadLetterProceed = (message, reason, perMessageTtl = false) => {
const queueName = getQueueName(message);
const {
exchange: dlExchange,
routingKey: dlRoutingKey = message.fields.routingKey,
} = queues[queueName].getDeadLetterInfo();
if (dlExchange === undefined) {
return;
}
const msg = { ...message };
if (!msg.properties.headers) {
msg.properties.headers = {};
}
if (!msg.properties.headers['x-death']) {
msg.properties.headers['x-death'] = [];
}
if (!msg.properties.headers['x-first-death-reason']) {
msg.properties.headers['x-first-death-reason'] = reason;
msg.properties.headers['x-first-death-queue'] = queueName;
msg.properties.headers['x-first-death-exchange'] = msg.fields.exchange;
}
const dlEntry = {
count: msg.properties.headers['x-death'].filter(
v => v.queue === queueName && v.reason === reason
).length + 1,
exchange: msg.fields.exchange,
queue: queueName,
reason,
'routing-keys': [msg.fields.routingKey],
time: { '!': 'timestamp', value: Date.now() / 1000 },
};
if (reason === 'expired' && perMessageTtl) {
dlEntry['original-expiration'] = msg.properties.expiration;
delete msg.properties.expiration;
}
msg.properties.headers['x-death'].unshift(dlEntry);
publishMessage(dlExchange, dlRoutingKey, msg.content, msg.properties);
};
const createChannel = async () => ({
...EventEmitter.prototype,
close: () => {},
assertQueue: async function (queueName, options) {
if (!queueName) {
queueName = generateRandomQueueName();
}
if (!(queueName in queues)) {
queues[queueName] = createQueue(options);
const exchange = exchanges[DEFAULT_EXCHANGE_NAME];
exchange.bindQueue(queueName, queueName);
}
return {
queue: queueName,
messageCount: queues[queueName].getMessageCount(),
consumerCount: queues[queueName].getConsumerCount(),
};
},
assertExchange: async (exchangeName, type, options = {}) => {
let exchange;
switch(type) {
case 'fanout':
exchange = createFanoutExchange(options);
break;
case 'direct':
case 'x-delayed-message':
exchange = createDirectExchange(options);
break;
case 'topic':
exchange = createTopicExchange(options);
break;
case 'headers':
exchange = createHeadersExchange(options);
break;
}
exchanges[exchangeName] = exchange;
return { exchange: exchangeName };
},
bindQueue: async (queue, sourceExchange, pattern, options = {}) => {
const exchange = exchanges[sourceExchange];
exchange.bindQueue(queue, pattern, options);
},
publish: function (exchangeName, routingKey, content, options = {}) {
const res = publishMessage(exchangeName, routingKey, content, options);
if (typeof res === 'object') {
this.emit('return', res);
}
return true;
},
sendToQueue: function (queueName, content, options = { headers: {} }) {
return this.publish(DEFAULT_EXCHANGE_NAME, queueName, content, options);
},
get: async (queueName, { noAck } = {}) => {
return queues[queueName].get();
},
prefetch: async () => {},
consume: async (queueName, consumer) => {
queues[queueName].addConsumer(consumer);
return { consumerTag: queueName };
},
cancel: async consumerTag => queues[consumerTag].stopConsume(),
ack: () => {},
nack: function (message, allUpTo = false, requeue = true) {
if (requeue) {
queues[getQueueName(message)].add(message);
} else {
deadLetterProceed(message, 'rejected');
}
},
checkQueue: async queueName => ({
queue: queueName,
messageCount: queues[queueName].getMessageCount(),
consumerCount: queues[queueName].getConsumerCount(),
}),
checkExchange: async exchangeName => ({
exchange: exchangeName,
}),
purgeQueue: queueName => queues[queueName].purge()
});
const createConfirmChannel = async () => {
const basic = await createChannel();
// for waitForConfirms
const pendingPublishes = [];
const addPromise = async () => new Promise(outerResolve => {
let resolver, rejector;
const promise = new Promise((resolve, reject) => {
resolver = resolve;
rejector = reject;
});
pendingPublishes.push(promise);
// setImmediate to make sure promise has finished his assignment task
setImmediate(() => {
outerResolve({ promise, resolver, rejector });
});
});
const handler = (func, ...args) => {
const cb = args[args.length - 1];
const params = args.slice(0, -1);
const promiseStaff = { promise: undefined, resolver: undefined, rejector: undefined };
addPromise()
// get promise resolver/rejector and call main func
.then(
ret => {
Object.assign(promiseStaff, ret);
func(...params); // main call
})
// resolve or reject, remove promise from array, callback call
.then(
ret => {
promiseStaff.resolver && promiseStaff.resolver();
const i = pendingPublishes.indexOf(promiseStaff.promise);
pendingPublishes.splice(i, 1);
if (cb) {
process.nextTick(cb, null, ret);
}
},
rej => {
promiseStaff.rejector && promiseStaff.rejector();
const i = pendingPublishes.indexOf(promiseStaff.promise);
pendingPublishes.splice(i, 1);
if (cb) {
process.nextTick(cb, rej);
}
},
);
// to mimic stream.write behaviour
return true;
}
// bind new context to all methods
for (const key in basic) {
if (typeof basic[key] === 'function') {
basic[key] = basic[key].bind(basic);
}
}
return {
...basic,
publish: (exchange, routingKey, content, options, cb) =>
handler(basic.publish, exchange, routingKey, content, options, cb),
sendToQueue: (queue, content, options, cb) =>
handler(basic.sendToQueue, queue, content, options, cb),
waitForConfirms: async () => Promise.all(pendingPublishes.slice()),
};
};
const generateRandomQueueName = () => {
const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
let res = 'amq.gen-';
for( let i=0; i<22; i++ ){
res += ABC[(Math.floor(Math.random() * ABC.length))];
}
return res;
};
const credentials = {
plain: (username, password) => ({
mechanism: 'PLAIN',
response: () => '',
username,
password
}),
amqplain: (username, password) => ({
mechanism: 'AMQPLAIN',
response: () => '',
username,
password
}),
external: () => ({
mechanism: 'EXTERNAL',
response: () => '',
}),
}
module.exports = {
connect: async () => ({
...EventEmitter.prototype,
createChannel,
createConfirmChannel,
isConnected: true,
close: function () {
this.emit('close');
}
}),
credentials,
};