@getanthill/datastore
Version:
Event-Sourced Datastore
1,284 lines (984 loc) • 33.5 kB
text/typescript
import type { Services } from '../../typings';
import { MongoDbConnector } from '@getanthill/mongodb-connector';
import setup from '../../../test/setup';
import { stream, serverSentEvents, writeJSON } from './controllers';
import fixtureUsers from '../../../test/fixtures/users';
import { EventEmitter } from 'events';
describe('controllers/stream', () => {
let app;
let services: Services;
let mongodb: MongoDbConnector;
let models;
class Req extends EventEmitter {
query: {};
params: {};
body: [];
headers: {};
constructor({ query, params, body, headers }) {
super();
this.query = query;
this.params = params;
this.body = body;
this.headers = headers;
}
header(h) {
return this.headers[h];
}
}
class Res extends EventEmitter {}
function mockPartsToObject(fn) {
if (fn.mock.calls[fn.mock.calls.length - 1][0] !== ']') {
fn.mock.calls.push([']']);
}
return JSON.parse(fn.mock.calls.map((call) => call[0]).join(''));
}
function mockPartsToSSEObject(fn) {
return JSON.parse(
'[' +
fn.mock.calls
.map((call) => call[0])
.filter((m) => m !== ':\n\n' && m !== 'data: ' && m !== '\n\n')
.join(',') +
']',
);
}
function getWaiters(res, count: number, timeout = 30_000) {
let _resolve0;
const startsPromise = new Promise((resolve) => (_resolve0 = resolve));
let _resolve;
const eventsPromise = new Promise((resolve) => (_resolve = resolve));
let _resolve2;
const endPromise = new Promise((resolve2) => (_resolve2 = resolve2));
let _count = 0;
res.write.mockImplementation((a) => {
if (a === '[') {
_resolve0();
return;
}
if (a === ']' || a === 'data: ' || a === '\n\n') {
return;
}
_count += 1;
if (_count === count) {
_resolve();
}
});
res.end.mockImplementation(_resolve2);
return {
starts: startsPromise,
events: eventsPromise,
end: endPromise,
};
}
beforeAll(async () => {
app = await setup.build();
services = app.services;
mongodb = services.mongodb;
});
beforeEach(async () => {
services.config.features.api.stream.maxWaitOnReconnectInMilliseconds = 30_000;
models = await setup.initModels(services, [
fixtureUsers,
{
...fixtureUsers,
name: 'guests',
},
]);
const Users = models.getModel(fixtureUsers.name);
await Promise.all([
Users.getStatesCollection(Users.db(mongodb)).deleteMany({}),
Users.getEventsCollection(Users.db(mongodb)).deleteMany({}),
Users.getSnapshotsCollection(Users.db(mongodb)).deleteMany({}),
]);
});
afterAll(async () => {
await setup.teardownDb(mongodb);
});
describe('#writeJSON', () => {
it('skips writing if no data is provided', () => {
const res = {
write: jest.fn(),
};
// @ts-ignore
writeJSON({}, res, null);
expect(res.write).toHaveBeenCalledTimes(0);
});
it('writes the data in JSON if available', () => {
const res = {
write: jest.fn(),
};
// @ts-ignore
writeJSON({}, res, { a: 1 });
expect(res.write).toHaveBeenCalledWith(JSON.stringify({ a: 1 }));
});
});
describe('#stream', () => {
let error;
let req;
let res;
let next;
let waiters;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = new Req({
query: {},
params: {},
body: [],
headers: {},
});
res = new Res();
res.writeHead = jest.fn();
res.write = jest.fn();
res.end = jest.fn();
waiters = getWaiters(res, 1);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('opens a stream and returns the inserted element if created', async () => {
const controller = stream({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(res.write.mock.calls[0][0]).toEqual('[');
expect(res.write.mock.calls[1][0].startsWith('{')).toEqual(true);
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
firstname: 'Alice',
},
]);
});
it('opens a stream with empty pipeline if no body provided', async () => {
const controller = stream({ ...services, models });
delete req.body;
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(res.write.mock.calls[0][0]).toEqual('[');
expect(res.write.mock.calls[1][0].startsWith('{')).toEqual(true);
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
firstname: 'Alice',
},
]);
});
it('opens a stream and returns the inserted element as an entity', async () => {
const controller = stream({ ...services, models });
req.params.model = 'users';
req.headers.output = 'entity';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(res.write.mock.calls[0][0]).toEqual('[');
expect(res.write.mock.calls[1][0].startsWith('{')).toEqual(true);
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
firstname: 'Alice',
},
]);
});
it('opens a stream and returns the raw inserted element change document', async () => {
const controller = stream({ ...services, models });
req.params.model = 'users';
req.headers.output = 'raw';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(res.write.mock.calls[0][0]).toEqual('[');
expect(res.write.mock.calls[1][0].startsWith('{')).toEqual(true);
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
fullDocument: {
firstname: 'Alice',
},
ns: {
coll: 'users',
},
},
]);
});
it('opens a stream and returns multiple inserted elements', async () => {
waiters = getWaiters(res, 2);
const controller = stream({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
res.emit('close');
await waiters.end;
const response = mockPartsToObject(res.write);
expect(
response.sort((a, b) => a.firstname.localeCompare(b.firstname)),
).toMatchObject([
{
firstname: 'Alice',
},
{
firstname: 'Bernard',
},
]);
});
it('opens a stream and returns inserted and updated elements', async () => {
waiters = getWaiters(res, 2);
const controller = stream({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
firstname: 'Alice',
version: 0,
},
{
firstname: 'Alizzz',
version: 1,
},
]);
});
it('opens a stream and returns events associated to a model', async () => {
waiters = getWaiters(res, 2);
const controller = stream({ ...services, models });
req.params.model = 'users';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
{
firstname: 'Alizzz',
type: 'UPDATED',
version: 1,
},
]);
});
it('opens a stream on all models', async () => {
waiters = getWaiters(res, 2);
const controller = stream({ ...services, models });
req.params.model = 'all';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Alizzz',
version: 1,
},
},
]);
});
it('opens a stream on all models events', async () => {
waiters = getWaiters(res, 2);
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
const response = mockPartsToObject(res.write);
expect(response).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Alizzz',
type: 'UPDATED',
version: 1,
},
},
]);
});
it('throws an exception in case of error', async () => {
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
const error = new Error('Ooops');
res.write = jest.fn().mockImplementation(() => {
throw error;
});
await controller(req, res, next);
expect(next).toHaveBeenCalledWith(error);
});
it('logs an error in case of invalid request', async () => {
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
req.query.pipeline = JSON.stringify([{ is: 'invalid' }]);
await controller(req, res, next);
expect(services.telemetry.logger.error.mock.calls[0][0]).toEqual(
'[api/stream] Stream error',
);
});
it('keeps the stream live even after a mongodb connection loss', async () => {
waiters = getWaiters(res, 3);
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await services.mongodb.disconnect();
// Required because the reconnect of the stream failure occurs after the
// connection heartbeat delay
const dbConnectionHeartbeat = 1_000;
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 10));
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Bernard',
type: 'CREATED',
version: 0,
},
},
]);
});
it('keeps the stream live even after a second mongodb connection loss', async () => {
waiters = getWaiters(res, 3);
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await services.mongodb.disconnect();
// Required because the reconnect of the stream failure occurs after the
// connection heartbeat delay
const dbConnectionHeartbeat = 1_000;
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 100));
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
// Lost the connection a second time...
await services.mongodb.disconnect();
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 100));
const gerard = models.factory('users');
await gerard.create({ firstname: 'Gerard' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Bernard',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Gerard',
type: 'CREATED',
version: 0,
},
},
]);
});
it('fails if the max reconnect delay is reached', async () => {
waiters = getWaiters(res, 1);
const controller = stream({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
services.config.features.api.stream.maxWaitOnReconnectInMilliseconds = 0;
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await services.mongodb.disconnect();
// Required because the reconnect of the stream failure occurs after the
// connection heartbeat delay
const dbConnectionHeartbeat = 1_000;
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 10));
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
// No need to wait because the connection closes on error
await waiters.end;
expect(mockPartsToObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
]);
});
});
describe('#stream/sse', () => {
let error;
let req;
let res;
let next;
let waiters;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = new Req({
query: {
// pipeline: [],
},
params: {},
body: [],
headers: {},
});
res = new Res();
res.writeHead = jest.fn();
res.write = jest.fn();
res.end = jest.fn();
waiters = getWaiters(res, 2);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('opens a stream and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
firstname: 'Alice',
});
});
it('opens a stream without any query and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
const uuid = setup.uuid();
req.params.model = 'users';
req.query.q = JSON.stringify({});
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: `Alice ${uuid}` });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
firstname: `Alice ${uuid}`,
});
});
it('opens a stream with a specific query and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
const uuid = setup.uuid();
req.params.model = 'users';
req.query.q = JSON.stringify({
firstname: `Alice ${uuid}`,
});
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: `Alice ${uuid}` });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
firstname: `Alice ${uuid}`,
});
});
it('opens a stream with a specific pipeline and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
const uuid = setup.uuid();
req.params.model = 'users';
req.body = [
{
$match: {
'fullDocument.firstname': `Alice ${uuid}`,
},
},
];
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: `Alice ${uuid}` });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
firstname: `Alice ${uuid}`,
});
});
it('opens a stream with a mix between query and pipeline and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
const uuid = setup.uuid();
req.params.model = 'users';
req.body = [
{
$match: {
'fullDocument.is_enabled': false,
},
},
];
req.query.q = JSON.stringify({
firstname: `Alice ${uuid}`,
});
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: `Alice ${uuid}`, is_enabled: false });
const alice2 = models.factory('users');
await alice2.create({ firstname: `Alice ${uuid}`, is_enabled: true });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
firstname: `Alice ${uuid}`,
is_enabled: false,
},
]);
});
it('opens a stream with a mix between query and pipeline from query and returns the inserted element if created', async () => {
const controller = serverSentEvents({ ...services, models });
const uuid = setup.uuid();
req.params.model = 'users';
req.query.pipeline = JSON.stringify([
{
$match: {
'fullDocument.is_enabled': false,
},
},
]);
req.query.q = JSON.stringify({
firstname: `Alice ${uuid}`,
});
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: `Alice ${uuid}`, is_enabled: false });
const alice2 = models.factory('users');
await alice2.create({ firstname: `Alice ${uuid}`, is_enabled: true });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
firstname: `Alice ${uuid}`,
is_enabled: false,
},
]);
});
it('opens a stream and returns the inserted element as an entity', async () => {
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
req.headers.output = 'entity';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
firstname: 'Alice',
});
});
it('opens a stream and returns the raw inserted element change document', async () => {
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
req.headers.output = 'raw';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(res.end).toHaveBeenCalledTimes(1);
expect(mockPartsToSSEObject(res.write)[0]).toMatchObject({
fullDocument: {
firstname: 'Alice',
},
ns: {
coll: 'users',
},
});
});
it('opens a stream and returns multiple inserted elements', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
firstname: 'Alice',
},
{
firstname: 'Bernard',
},
]);
});
it('opens a stream and returns inserted and updated elements', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
firstname: 'Alice',
version: 0,
},
{
firstname: 'Alizzz',
version: 1,
},
]);
});
it('opens a stream and returns events associated to a model', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'users';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
{
firstname: 'Alizzz',
type: 'UPDATED',
version: 1,
},
]);
});
it('opens a stream on all models', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Alizzz',
version: 1,
},
},
]);
});
it('opens a stream on some specific models only', async () => {
waiters = getWaiters(res, 2);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.headers['only-models'] = 'guests';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
const guest = models.factory('guests');
await guest.create({ firstname: 'Guest' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
model: 'guests',
entity: {
firstname: 'Guest',
version: 0,
},
},
]);
});
it('opens a stream on all models events', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await alice.update({ firstname: 'Alizzz' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Alizzz',
type: 'UPDATED',
version: 1,
},
},
]);
});
it('throws an exception in case of error', async () => {
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
const error = new Error('Ooops');
res.writeHead = jest.fn().mockImplementation(() => {
throw error;
});
await controller(req, res, next);
expect(next).toHaveBeenCalledWith(error);
});
it('logs an error in case of invalid request', async () => {
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
req.query.pipeline = JSON.stringify([{ is: 'invalid' }]);
await controller(req, res, next);
expect(services.telemetry.logger.error.mock.calls[0][0]).toEqual(
'[api/stream] Stream error',
);
});
it('keeps the stream live even after a mongodb connection loss', async () => {
waiters = getWaiters(res, 3);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await services.mongodb.disconnect();
// Required because the reconnect of the stream failure occurs after the
// connection heartbeat delay
const dbConnectionHeartbeat = 1_000;
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 10));
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
res.emit('close');
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
{
model: 'users',
entity: {
firstname: 'Bernard',
type: 'CREATED',
version: 0,
},
},
]);
});
it('fails if the max reconnect delay is reached', async () => {
waiters = getWaiters(res, 2);
const controller = serverSentEvents({ ...services, models });
req.params.model = 'all';
req.params.source = 'events';
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(0);
services.config.features.api.stream.maxWaitOnReconnectInMilliseconds = 0;
const alice = models.factory('users');
await alice.create({ firstname: 'Alice' });
await services.mongodb.disconnect();
// Required because the reconnect of the stream failure occurs after the
// connection heartbeat delay
const dbConnectionHeartbeat = 1_000;
await new Promise((resolve) =>
setTimeout(resolve, dbConnectionHeartbeat),
);
await services.mongodb.connect();
// Required because the change stream does not reconnect in a milliseconds
await new Promise((resolve) => setTimeout(resolve, 10));
const bernard = models.factory('users');
await bernard.create({ firstname: 'Bernard' });
await waiters.events;
// No need to wait because the connection closes on error
await waiters.end;
expect(mockPartsToSSEObject(res.write)).toMatchObject([
{
model: 'users',
entity: {
firstname: 'Alice',
type: 'CREATED',
version: 0,
},
},
]);
});
});
});