@getanthill/datastore
Version:
Event-Sourced Datastore
406 lines (328 loc) • 11.9 kB
text/typescript
import Core from './Core';
import Streams from './Streams';
import { EventEmitter } from 'events';
describe('sdk/Stream', () => {
let core;
let client: Streams;
let eventSource;
beforeEach(async () => {
core = new Core();
client = new Streams(
{
token: 'token',
telemetry: {
logger: {
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
},
},
},
core,
);
client.getEventSource = jest.fn().mockImplementation(() => {
eventSource = new EventEmitter();
eventSource.addEventListener = eventSource.on;
eventSource.close = jest.fn();
setTimeout(() => eventSource.emit('open'), 10);
return eventSource;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('constructor', () => {
it('creates a new client with default configuration', () => {
client = new Streams({}, core);
expect(client.config).toEqual({
baseUrl: 'http://localhost:3001',
token: 'token',
debug: false,
connector: 'http',
});
});
it('creates a new client with configuration defined in the signature', () => {
client = new Streams(
{ baseUrl: 'https://datastore.org', token: 'private_token' },
core,
);
expect(client.config).toMatchObject({
baseUrl: 'https://datastore.org',
token: 'private_token',
});
});
it('creates a new client with core instanciated', () => {
client = new Streams({}, core);
expect(client._core).toEqual(core);
});
it('creates a new client with telemetry enabled', () => {
client = new Streams({ telemetry: { logger: true } }, core);
expect(client._telemetry).toEqual({ logger: true });
});
});
describe('#stream', () => {
let close;
afterEach(() => {
jest.restoreAllMocks();
close();
});
it('calls the stream route with default parameters (deprecated)', async () => {
const handler = jest.fn();
close = await client.stream(handler);
expect(client.getEventSource).toHaveBeenCalledWith(
'http://localhost:3001/api/stream/all/entities/sse?pipeline=[]',
{ authorization: 'token' },
);
});
it('calls the stream route with default parameters', async () => {
const handler = jest.fn();
close = await client.streamHTTP(handler);
expect(client.getEventSource).toHaveBeenCalledWith(
'http://localhost:3001/api/stream/all/entities/sse?pipeline=[]',
{ authorization: 'token' },
);
});
it('calls the stream route for a given model', async () => {
const handler = jest.fn();
close = await client.streamHTTP(handler, 'users');
expect(client.getEventSource).toHaveBeenCalledWith(
'http://localhost:3001/api/stream/users/entities/sse?pipeline=[]',
{ authorization: 'token' },
);
});
it('calls the stream route for a given model and for events', async () => {
const handler = jest.fn();
close = await client.streamHTTP(handler, 'users', 'events');
expect(client.getEventSource).toHaveBeenCalledWith(
'http://localhost:3001/api/stream/users/events/sse?pipeline=[]',
{ authorization: 'token' },
);
});
it('calls the stream route for a given aggregation pipeline', async () => {
const handler = jest.fn();
close = await client.streamHTTP(handler, 'users', 'entities', {
email: 'john',
});
expect(client.getEventSource).toHaveBeenCalledWith(
'http://localhost:3001/api/stream/users/entities/sse?pipeline=[{"$match":{"fullDocument.email":"john"}}]',
{ authorization: 'token' },
);
});
it('invokes the handler on JSON object reception', async () => {
const handler = jest.fn();
close = await client.streamHTTP(handler);
eventSource.emit('message', { data: JSON.stringify({ a: 1 }) });
expect(handler).toHaveBeenCalledWith({ a: 1 });
});
it('closes the connection on the invokation of the close returned handler', async () => {
const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
const handler = jest.fn();
close = await client.streamHTTP(handler);
close();
expect(eventSource.close).toHaveBeenCalledTimes(1);
});
});
describe('#listen / #close', () => {
let close;
beforeEach(() => {
close = jest.fn();
});
afterEach(() => {
close();
});
it('allows to listen events from the Datastore', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events');
const firstCallArgumentsExceptHandler = client.streamHTTP.mock.calls[0];
firstCallArgumentsExceptHandler.shift();
expect(firstCallArgumentsExceptHandler).toEqual([
'all',
'events',
undefined,
undefined,
]);
});
it('allows to listen events based on a customized projection', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events', { state: 'created' });
const firstCallArgumentsExceptHandler = client.streamHTTP.mock.calls[0];
firstCallArgumentsExceptHandler.shift();
expect(firstCallArgumentsExceptHandler).toEqual([
'all',
'events',
{ state: 'created' },
undefined,
]);
});
it('allows to listen events from AMQP based on a custom JSON Schema built from query', async () => {
const amqp = {
connect: jest.fn(),
init: jest.fn(),
on: jest.fn(),
subscribe: jest.fn(),
end: jest.fn(),
};
client.getAMQPClient = jest.fn().mockImplementation(() => amqp);
client.config.connector = 'amqp';
await client.listen('all', 'events', { state: 'created' });
expect(amqp.connect).toHaveBeenCalledTimes(1);
expect(amqp.on.mock.calls[0][0]).toEqual('*/*/events/*');
expect(amqp.subscribe).toHaveBeenCalledWith('*/*/events/*', {
required: ['state'],
properties: { state: { enum: ['created'], type: 'string' } },
type: 'object',
});
});
it('allows to listen events from AMQP based on a custom JSON Schema built with empty query', async () => {
const amqp = {
connect: jest.fn(),
init: jest.fn(),
on: jest.fn(),
subscribe: jest.fn(),
end: jest.fn(),
};
client.getAMQPClient = jest.fn().mockImplementation(() => amqp);
client.config.connector = 'amqp';
await client.listen('all', 'events');
expect(amqp.connect).toHaveBeenCalledTimes(1);
expect(amqp.on.mock.calls[0][0]).toEqual('*/*/events/*');
expect(amqp.subscribe).toHaveBeenCalledWith('*/*/events/*', {
properties: {},
type: 'object',
});
});
it('allows to listen events from AMQP based on a custom JSON Schema given as query', async () => {
const amqp = {
connect: jest.fn(),
init: jest.fn(),
on: jest.fn(),
subscribe: jest.fn(),
end: jest.fn(),
};
client.getAMQPClient = jest.fn().mockImplementation(() => amqp);
client.config.connector = 'amqp';
await client.listen(
'all',
'events',
{
type: 'object',
required: ['state'],
properties: {
state: { type: 'string', enum: ['created', 'canceled'] },
},
},
{ queryAsJSONSchema: true },
);
expect(amqp.connect).toHaveBeenCalledTimes(1);
expect(amqp.on.mock.calls[0][0]).toEqual('*/*/events/*');
expect(amqp.subscribe).toHaveBeenCalledWith('*/*/events/*', {
required: ['state'],
properties: {
state: { enum: ['created', 'canceled'], type: 'string' },
},
type: 'object',
});
});
it('binds only one stream per streamId', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events');
await client.listen('all', 'events');
expect(client.streamHTTP).toHaveBeenCalledTimes(1);
});
it('registers a stream with a streamId defined with query JSON stringified', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events', { test: 1 });
expect(Array.from(client._streams.keys())).toEqual([
'all:events:{"test":1}',
]);
expect(client._streams.get('all:events:{"test":1}')).toEqual(close);
});
it('registers a new stream in the Datastore client', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events');
expect(client._streams.get('all:events:{}')).toEqual(close);
});
it('closes a stream connection if it exists on close invokation', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events');
close('all', 'events');
expect(close).toHaveBeenCalledTimes(1);
});
it('closes all stream connections on closeAll invokation', async () => {
client.streamHTTP = jest.fn().mockImplementation(() => close);
await client.listen('all', 'events');
await client.listen('all', 'events', { test: 1 });
expect(Array.from(client._streams.keys())).toEqual([
'all:events:{}',
'all:events:{"test":1}',
]);
client.closeAll();
expect(close).toHaveBeenCalledTimes(2);
expect(client._streams.size).toEqual(0);
});
it('emits a message on the streamId ', async () => {
await client.listen('all', 'events');
let message;
client.on('all:events:{}', (e) => (message = e));
eventSource.emit('message', { data: JSON.stringify({ a: 1 }) });
expect(message).toEqual({ a: 1 });
});
it('closes an AMQP stream connection if it exists on close invokation', async () => {
const amqp = {
connect: jest.fn(),
init: jest.fn(),
on: jest.fn(),
subscribe: jest.fn(),
end: jest.fn().mockResolvedValue(null),
};
client.getAMQPClient = jest.fn().mockImplementation(() => amqp);
client.config.connector = 'amqp';
close = await client.listen(
'all',
'events',
{
type: 'object',
required: ['state'],
properties: {
state: { type: 'string', enum: ['created', 'canceled'] },
},
},
{ queryAsJSONSchema: true },
);
close('all', 'events');
expect(amqp.end).toHaveBeenCalledTimes(1);
});
it('logs an error in case of an AMQP stream connection error arises', async () => {
const error = new Error('Ooops');
const amqp = {
connect: jest.fn(),
init: jest.fn(),
on: jest.fn(),
subscribe: jest.fn(),
end: jest.fn().mockRejectedValue(error),
};
client.getAMQPClient = jest.fn().mockImplementation(() => amqp);
client.config.connector = 'amqp';
close = await client.listen(
'all',
'events',
{
type: 'object',
required: ['state'],
properties: {
state: { type: 'string', enum: ['created', 'canceled'] },
},
},
{ queryAsJSONSchema: true },
);
close('all', 'events');
await new Promise((resolve) => setTimeout(resolve, 500));
expect(amqp.end).toHaveBeenCalledTimes(1);
expect(client._telemetry?.logger.error).toHaveBeenCalledWith(
'[Streams#streamAMQP] Error on closing',
{ err: error },
);
});
});
});