@getanthill/datastore
Version:
Event-Sourced Datastore
679 lines (517 loc) • 19.5 kB
text/typescript
import AMQPClient from './amqp';
import setup from '../../test/setup';
describe('services/amqp', () => {
let amqp;
let app;
beforeEach(async () => {
app = await setup.build();
amqp = new AMQPClient(app.services.config.amqp, {
// @ts-ignore
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
});
await amqp.connect();
await amqp._channel.deleteQueue(amqp.config.queue.consumer.name);
await amqp._channel.deleteQueue(amqp.config.queue.errors.name);
// await amqp._channel.purgeQueue(amqp.config.queue.name);
await amqp._channel.deleteExchange(amqp.config.exchange.consumer.name);
await amqp._channel.deleteExchange(amqp.config.exchange.producer.name);
await amqp.init();
});
afterEach(async () => {
await amqp.end();
await setup.teardownDb(app.services.mongodb);
jest.restoreAllMocks();
});
describe('constructor', () => {
it('initiates the connection URL with single node mode', () => {
amqp = new AMQPClient({
...app.services.config.amqp,
url: 'amqp://guest:guest@localhost:5672',
});
(expect(amqp._connectionId).toEqual(0),
expect(amqp._connectionUrls).toEqual([
'amqp://guest:guest@localhost:5672',
]));
});
it('initiates the connection URL with multiple nodes mode', () => {
amqp = new AMQPClient({
...app.services.config.amqp,
url: [
'amqp://guest:guest@localhost:5672',
'amqp://guest:guest@localhost:5673',
],
});
(expect(amqp._connectionId).toEqual(0),
expect(amqp._connectionUrls).toEqual([
'amqp://guest:guest@localhost:5672',
'amqp://guest:guest@localhost:5673',
]));
});
});
describe('#connect / #end', () => {
it('throws an exception on connection error', async () => {
await amqp.end();
let error;
try {
const _con = amqp.connection;
} catch (err) {
error = err;
}
expect(error).toEqual(AMQPClient.ERRORS.NOT_CONNECTED);
});
it('throws an exception if not connected yet (connection)', async () => {
await amqp.end();
amqp._alreadyConnected = false;
let error;
try {
amqp.config.url = 'amqp://invalid:5672';
amqp._connectionUrls = [amqp.config.url];
await amqp.connect();
} catch (err) {
error = err;
await amqp.end();
}
expect(['ECONNREFUSED', 'ENOTFOUND'].includes(error.code)).toEqual(true);
});
it('logs an error in case of connection error event', async () => {
const loggerErrorMock = jest.spyOn(amqp.telemetry.logger, 'error');
const error = new Error('Ooops');
amqp._connection.emit('error', error);
expect(loggerErrorMock).toHaveBeenCalledWith('[AMQP] Error', error);
});
it('throws an exception if not connected yet (channel)', async () => {
await amqp.end();
let error;
try {
const _chan = amqp.channel;
} catch (err) {
error = err;
}
expect(error).toEqual(AMQPClient.ERRORS.NOT_CONNECTED);
});
it('allows to connect to AMQP', async () => {
const message = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
expect(amqp.connection).toEqual(amqp._connection);
});
it('allows to connect to AMQP with multiple URLs', async () => {
await amqp.end();
amqp._alreadyConnected = false;
amqp.config.url = ['amqp://invalid:5673', amqp.config.url];
amqp._connectionUrls = amqp.config.url;
await amqp.connect();
await new Promise((resolve) =>
setTimeout(
resolve,
amqp.config.failover?.reconnectionTimeoutInMilliseconds + 100,
),
);
const message = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
expect(amqp.connection).toEqual(amqp._connection);
});
it('allows to connect to AMQP and subscribe more than on topic', async () => {
const message = amqp.next('topic-2');
await amqp.subscribe('topic-1', {});
await amqp.subscribe('topic-2', {});
await amqp.publish('topic-2', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
expect(amqp.connection).toEqual(amqp._connection);
});
it('allows to connect to AMQP with a namespace configured', async () => {
amqp.config.namespace = 'ds';
const message = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
expect(amqp.connection).toEqual(amqp._connection);
});
it('allows to connect to AMQP an listen on complex routing keys', async () => {
const message = amqp.next('ds/accounts/updated/{account_id}');
await amqp.subscribe('ds/accounts/updated/{account_id}', {});
await amqp.publish('ds/accounts/updated/123', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
it('does noop if already connected', async () => {
await amqp.connect();
const message = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
it('allows to disconnect from amqp', async () => {
await amqp.end();
expect(amqp._connection).toEqual(null);
});
it('does noop if already disconnected', async () => {
await amqp.end();
await amqp.end();
expect(amqp._connection).toEqual(null);
});
it('allows to reconnect after a connection error', async () => {
amqp.end();
amqp = new AMQPClient(app.services.config.amqp, {
// @ts-ignore
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
});
jest.spyOn(amqp, 'resubscribe').mockImplementationOnce(() => {
throw new Error('Ooops');
});
await amqp.connect();
await new Promise((resolve) => setTimeout(resolve, 1000));
const message = amqp.next('ds/accounts/updated/{account_id}', 5000);
await amqp.subscribe('ds/accounts/updated/{account_id}', {});
await amqp.publish('ds/accounts/updated/123', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
it('allows to reconnect without loosing bindings', async () => {
const message = amqp.next('ds/accounts/updated/{account_id}', 5000);
await amqp.subscribe('ds/accounts/updated/{account_id}', {});
// Closing the connection directly:
await amqp._connection.close();
await new Promise((resolve) => setTimeout(resolve, 1000));
await amqp.publish('ds/accounts/updated/123', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
it('allows to postpone messages publication once reconnected', async () => {
const message = amqp.next('ds/accounts/updated/{account_id}', 5000);
await amqp.subscribe('ds/accounts/updated/{account_id}', {});
// Closing the connection directly:
await amqp._connection.close();
await amqp.publish('ds/accounts/updated/123', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
});
describe('#init', () => {
it('allows to subscribe to AMQP messages', async () => {
const message = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
expect(amqp.connection).toEqual(amqp._connection);
});
it('allows to subscribe to AMQP messages', async () => {
await amqp.end();
amqp.config.queue.errors.isEnabled = true;
const message = amqp.next('users/created/error');
await amqp.connect();
await amqp.init();
await amqp.subscribe('users/created/error', {}, amqp.config.queue.errors);
await amqp.publish('users/created/error', { hello: 123 });
expect(await message).toEqual({ hello: 123 });
});
});
describe('#authenticate', () => {
it('authenticates events based on userProperties on client connection', async () => {
await amqp.end();
amqp = new AMQPClient(
{
...app.services.config.amqp,
headers: {
...app.services.config.amqp.headers,
authorization: 'token',
},
},
{
// @ts-ignore
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
},
);
await amqp.connect();
let message;
amqp.on(
'topic',
amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
(m) => (message = m),
),
);
const wait = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
await wait;
expect(message).toEqual({ hello: 'amqp' });
});
it('authenticates events based on userProperties', async () => {
let message;
amqp.on(
'topic',
amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
(m) => (message = m),
),
);
const wait = amqp.next('topic');
await amqp.subscribe('topic', {});
await amqp.publish(
'topic',
{ hello: 'amqp' },
{ headers: { authorization: 'token' } },
);
await wait;
expect(message).toEqual({ hello: 'amqp' });
});
it('invokes the handler if the event has valid token', () => {
const handler = jest.fn();
const middleware = amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
middleware(event, 'topic', { authorization: 'token' });
expect(handler).toHaveBeenCalledWith(
event,
'topic',
{ authorization: 'token' },
undefined,
);
});
it('ack the message on unauthenticated event', () => {
const handler = jest.fn();
const middleware = amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
const ack = jest.fn();
middleware(event, 'topic', { authorization: 'unknown' }, { ack });
expect(ack).toHaveBeenCalledTimes(1);
});
it('noops on events without authorization token', () => {
const handler = jest.fn();
const middleware = amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
middleware(event, 'topic', {});
expect(handler).toHaveBeenCalledTimes(0);
});
it('noops on events without valid authorization token', () => {
const handler = jest.fn();
const middleware = amqp.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
middleware(event, 'topic', { authorization: 'invalid' });
expect(handler).toHaveBeenCalledTimes(0);
});
});
describe('#getRoute', () => {
it('returns null if no route has been registered yet', () => {
expect(amqp.getRoute('unknown')).toEqual(null);
});
it('returns null if no route is matching the topic', async () => {
await amqp.subscribe('my/topic', {});
expect(amqp.getRoute('unknown')).toEqual(null);
});
it('returns the route if one route is matching the topic', async () => {
await amqp.subscribe('my/topic', {});
expect(amqp.getRoute('my/topic')).toMatchObject({
original: 'my/topic',
regexp: /my\/topic/,
routingKey: 'my.topic',
});
});
});
describe('#next', () => {
it('triggers a timeout error if no message is received', async () => {
let error;
try {
await amqp.next('topic', 100);
} catch (err) {
error = err;
}
expect(error.message).toEqual('[amqp#next] Message timeout');
});
});
describe('#onMessage', () => {
it('noops on empty message', async () => {
let message;
amqp.on('topic', (m) => (message = m));
amqp.onMessage(null);
expect(message).toEqual(undefined);
});
it('noops but nacks on no matching route on first delivery', async () => {
let message;
amqp.on('topic', (m) => (message = m));
const ackMock = jest.spyOn(amqp.channel, 'ack');
const nackMock = jest.spyOn(amqp.channel, 'nack');
const receivedMessage = {
fields: { routingKey: 'topic' },
properties: { headers: {} },
content: Buffer.from(JSON.stringify({ hello: 'world' })),
};
amqp.onMessage(receivedMessage);
expect(message).toEqual(undefined);
expect(ackMock).toHaveBeenCalledTimes(0);
expect(nackMock).toHaveBeenCalledWith(receivedMessage);
});
it('noops and acks on no matching route on second delivery', async () => {
let message;
amqp.on('topic', (m) => (message = m));
const ackMock = jest.spyOn(amqp.channel, 'ack');
const nackMock = jest.spyOn(amqp.channel, 'nack');
const receivedMessage = {
fields: { routingKey: 'topic', redelivered: true },
properties: { headers: {} },
content: Buffer.from(JSON.stringify({ hello: 'world' })),
};
amqp.onMessage(receivedMessage);
expect(message).toEqual(undefined);
expect(nackMock).toHaveBeenCalledTimes(0);
expect(ackMock).toHaveBeenCalledWith(receivedMessage);
});
it('logs a warn message on non JSON message', async () => {
let message;
amqp.on('topic', (m) => (message = m));
await amqp.subscribe('topic', {});
// const nackIfFirstSeenMock = jest.spyOn(amqp, 'nackIfFirstSeen');
amqp.nackIfFirstSeen = jest.fn();
const warnMock = jest.spyOn(amqp.telemetry.logger, 'warn');
const receivedMessage = {
fields: { routingKey: 'topic', redelivered: true },
properties: { headers: {} },
content: Buffer.from('Non JSON object'),
};
amqp.onMessage(receivedMessage);
expect(message).toEqual(undefined);
expect(amqp.nackIfFirstSeen).toHaveBeenCalledWith(receivedMessage);
expect(warnMock.mock.calls[0][0]).toEqual(
'[services#amqp] Failed processing message',
);
});
it('emits the message to all subscribers', async () => {
let message;
amqp.on('topic', (m) => (message = m));
await amqp.subscribe('topic', {});
amqp.onMessage({
fields: { routingKey: 'topic' },
properties: {},
content: Buffer.from(JSON.stringify({ hello: 'world' })),
});
expect(message).toEqual({ hello: 'world' });
});
it('emits the message to all subscribers with route params', async () => {
let event;
amqp.on(
'topic/{topic_name}',
(message, route, headers) => (event = { message, route, headers }),
);
await amqp.subscribe('topic/{topic_name}', {});
amqp.onMessage({
fields: { routingKey: 'topic/paris' },
properties: {},
content: Buffer.from(JSON.stringify({ hello: 'world' })),
});
expect(event).toMatchObject({
headers: {},
route: { params: { topic_name: 'paris' } },
message: { hello: 'world' },
});
});
it('logs an error message in case of invalid message contract', async () => {
let message;
amqp.on('topic', (m) => (message = m));
jest.spyOn(amqp.telemetry.logger, 'error');
await amqp.subscribe('topic', { type: 'number' });
amqp.channel.ack = jest.fn();
const sentMessage = {
fields: { routingKey: 'topic' },
properties: { headers: {} },
content: Buffer.from(JSON.stringify({ hello: 'world' })),
};
amqp.onMessage(sentMessage);
expect(message).toEqual(undefined);
expect(amqp.telemetry.logger.error).toHaveBeenCalledTimes(1);
expect(amqp.telemetry.logger.error.mock.calls[0][1]).toMatchObject({
event: { hello: 'world' },
schema: { type: 'number' },
errors: [
{
instancePath: '',
keyword: 'type',
message: 'must be number',
params: { type: 'number' },
schemaPath: '#/type',
},
],
});
expect(amqp.channel.ack).toHaveBeenCalledWith(sentMessage);
});
it('logs a debug message in case of invalid message contract and lowered level defined', async () => {
let message;
amqp.on('topic', (m) => (message = m));
amqp.config.logLevelOnInvalidMessage = 'debug';
jest.spyOn(amqp.telemetry.logger, 'error');
await amqp.subscribe('topic', { type: 'number' });
amqp.channel.ack = jest.fn();
const sentMessage = {
fields: { routingKey: 'topic' },
properties: { headers: {} },
content: Buffer.from(JSON.stringify({ hello: 'world' })),
};
amqp.onMessage(sentMessage);
expect(message).toEqual(undefined);
expect(amqp.telemetry.logger.debug).toHaveBeenCalledTimes(5);
expect(amqp.telemetry.logger.debug.mock.calls[4][1]).toMatchObject({
event: { hello: 'world' },
schema: { type: 'number' },
errors: [
{
instancePath: '',
keyword: 'type',
message: 'must be number',
params: { type: 'number' },
schemaPath: '#/type',
},
],
});
expect(amqp.channel.ack).toHaveBeenCalledWith(sentMessage);
});
it('allows to ack a message', async () => {
const message = amqp.next('topic', 1000, async (opts) => opts.ack());
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await message).toEqual({ hello: 'amqp' });
});
it('allows to nack a message once', async () => {
const first = amqp.next('topic', 1000, async (opts) => opts.nack());
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await first).toEqual({ hello: 'amqp' });
const second = amqp.next('topic', 1000, async (opts) => opts.ack());
expect(await second).toEqual({ hello: 'amqp' });
});
it('allows to nack a message twice', async () => {
const first = amqp.next('topic', 1000, async (opts) => opts.nack());
await amqp.subscribe('topic', {});
await amqp.publish('topic', { hello: 'amqp' });
expect(await first).toEqual({ hello: 'amqp' });
const second = amqp.next('topic', 1000, async (opts) => opts.nack());
expect(await second).toEqual({ hello: 'amqp' });
const third = amqp.next('topic', 1000, async (opts) => opts.ack());
expect(await third).toEqual({ hello: 'amqp' });
});
});
});