@getanthill/datastore
Version:
Event-Sourced Datastore
365 lines (279 loc) • 9.25 kB
text/typescript
import MQTTClient from './mqtt';
import setup from '../../test/setup';
describe('services/mqtt', () => {
let mqtt;
let app;
beforeEach(async () => {
app = await setup.build();
mqtt = new MQTTClient(app.services.config.mqtt, {
// @ts-ignore
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
});
await mqtt.connect();
});
afterEach(async () => {
await mqtt.end();
await setup.teardownDb(app.services.mongodb);
});
describe('#connect / #end', () => {
it('throws an exception if not connected yet', async () => {
await mqtt.end();
let error;
try {
const _client = mqtt.client;
} catch (err) {
error = err;
}
expect(error).toEqual(MQTTClient.ERRORS.NOT_CONNECTED);
});
it('allows to connect to MQTT', async () => {
const message = mqtt.next('topic');
await mqtt.subscribe('topic', {});
await mqtt.publish('topic', { hello: 'mqtt' });
expect(await message).toEqual({ hello: 'mqtt' });
});
it('allows to connect to MQTT with a namespace configured', async () => {
mqtt.config.namespace = 'ds';
const message = mqtt.next('topic');
await mqtt.subscribe('topic', {});
await mqtt.publish('topic', { hello: 'mqtt' });
expect(await message).toEqual({ hello: 'mqtt' });
});
it('does noop if already connected', async () => {
await mqtt.connect();
const message = mqtt.next('topic');
await mqtt.subscribe('topic', {});
await mqtt.publish('topic', { hello: 'mqtt' });
expect(await message).toEqual({ hello: 'mqtt' });
});
it('allows to disconnect from MQTT', async () => {
await mqtt.end();
expect(mqtt._client).toEqual(null);
});
it('does noop if already disconnected', async () => {
await mqtt.end();
await mqtt.end();
expect(mqtt._client).toEqual(null);
});
});
describe('#authenticate', () => {
it('authenticates events based on userProperties on client connection', async () => {
await mqtt.end();
mqtt = new MQTTClient(
{
...app.services.config.mqtt,
options: {
...app.services.config.mqtt.options,
properties: { userProperties: { authorization: 'token' } },
},
},
{
// @ts-ignore
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
},
);
await mqtt.connect();
let message;
mqtt.on(
'topic',
mqtt.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
(m) => (message = m),
),
);
const wait = mqtt.next('topic');
await mqtt.subscribe('topic', {});
await mqtt.publish('topic', { hello: 'mqtt' });
await wait;
expect(message).toEqual({ hello: 'mqtt' });
});
it('authenticates events based on userProperties', async () => {
let message;
mqtt.on(
'topic',
mqtt.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
(m) => (message = m),
),
);
const wait = mqtt.next('topic');
await mqtt.subscribe('topic', {});
await mqtt.publish(
'topic',
{ hello: 'mqtt' },
{ properties: { userProperties: { authorization: 'token' } } },
);
await wait;
expect(message).toEqual({ hello: 'mqtt' });
});
it('invokes the handler if the event has valid token', () => {
const handler = jest.fn();
const middleware = mqtt.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
middleware(event, 'topic', { authorization: 'token' });
expect(handler).toHaveBeenCalledWith(
event,
'topic',
{ authorization: 'token' },
undefined,
);
});
it('noops on events without authorization token', () => {
const handler = jest.fn();
const middleware = mqtt.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 = mqtt.authenticate(
[{ id: 'token', level: 'read', token: 'token' }],
handler,
);
const event = {};
middleware(event, 'topic', { authorization: 'invalid' });
expect(handler).toHaveBeenCalledTimes(0);
});
});
describe('#mapTopic', () => {
it('returns the parsed topic without parameter', () => {
expect(mqtt.mapTopic('my/topic', {})).toMatchObject({
original: 'my/topic',
regexp: /my\/topic/,
topic: 'my/topic',
paramNames: [],
});
});
it('returns the parsed topic with parameter', () => {
expect(mqtt.mapTopic('my/topic/{topic_id}', {})).toMatchObject({
original: 'my/topic/{topic_id}',
regexp: /my\/topic\/([^\/]+)/,
topic: 'my/topic/+',
paramNames: ['topic_id'],
});
});
it('returns the parsed topic with multiple parameters', () => {
expect(
mqtt.mapTopic('my/topic/{topic_id}/{topic_name}', {}),
).toMatchObject({
original: 'my/topic/{topic_id}/{topic_name}',
regexp: /my\/topic\/([^\/]+)\/([^\/]+)/,
topic: 'my/topic/+/+',
paramNames: ['topic_id', 'topic_name'],
});
});
});
describe('#getRoute', () => {
it('returns null if no route has been registered yet', () => {
expect(mqtt.getRoute('unknown')).toEqual(null);
});
it('returns null if no route is matching the topic', () => {
mqtt.subscribe('my/topic', {});
expect(mqtt.getRoute('unknown')).toEqual(null);
});
it('returns the route if one route is matching the topic', () => {
mqtt.subscribe('my/topic', {});
expect(mqtt.getRoute('my/topic')).toMatchObject({
original: 'my/topic',
regexp: /my\/topic/,
topic: 'my/topic',
});
});
});
describe('#next', () => {
it('triggers a timeout error if no message is received', async () => {
let error;
try {
await mqtt.next('topic');
} catch (err) {
error = err;
}
expect(error.message).toEqual('[mqtt#next] Message timeout');
});
});
describe('#subscribe', () => {
it('allows to subscribe OpenAPI like topics', async () => {
await mqtt.subscribe('ds/accounts/updated/{account_id}', {});
expect(Array.from(mqtt.topics.keys())).toEqual(['ds/accounts/updated/+']);
});
it('calls the subscribe method on the mqtt client instance', async () => {
jest.spyOn(mqtt.client, 'subscribe');
await mqtt.subscribe('ds/accounts/updated/{account_id}', {});
expect(mqtt.client.subscribe).toHaveBeenCalledWith(
'$share/datastore/ds/accounts/updated/+',
);
});
});
describe('#onMessage', () => {
it('noops on no matching route', async () => {
let message;
mqtt.on('topic', (m) => (message = m));
mqtt.client.emit('message', 'topic', JSON.stringify({ hello: 'world' }));
expect(message).toEqual(undefined);
});
it('emits the message to all subscribers', async () => {
let message;
mqtt.on('topic', (m) => (message = m));
await mqtt.subscribe('topic', {});
mqtt.client.emit('message', 'topic', JSON.stringify({ hello: 'world' }));
expect(message).toEqual({ hello: 'world' });
});
it('emits the message to all subscribers with route params', async () => {
let event;
mqtt.on(
'topic/{topic_name}',
(m, r, h) => (event = { message: m, route: r, headers: h }),
);
await mqtt.subscribe('topic/{topic_name}', {});
mqtt.client.emit(
'message',
'topic/paris',
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;
mqtt.on('topic', (m) => (message = m));
jest.spyOn(mqtt.telemetry.logger, 'error');
await mqtt.subscribe('topic', { type: 'number' });
mqtt.client.emit('message', 'topic', JSON.stringify({ hello: 'world' }));
expect(message).toEqual(undefined);
expect(mqtt.telemetry.logger.error).toHaveBeenCalledTimes(1);
expect(mqtt.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',
},
],
});
});
});
});