UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

679 lines (517 loc) 19.5 kB
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' }); }); }); });