@getanthill/datastore
Version:
Event-Sourced Datastore
1,707 lines (1,252 loc) • 108 kB
text/typescript
import type { Services } from '../../typings';
import { MongoDbConnector, ObjectId } from '@getanthill/mongodb-connector';
import crypto from 'crypto';
import setup from '../../../test/setup';
import {
archive,
apply,
create,
createSnapshot,
decrypt,
deleteEntity,
encrypt,
find,
get,
getEvents,
getGraphData,
patch,
restore,
timetravel,
update,
unarchive,
} from './controllers';
import fixtureUsers from '../../../test/fixtures/users';
describe('controllers/models', () => {
let app;
let services: Services;
let mongodb: MongoDbConnector;
let models;
beforeAll(async () => {
app = await setup.build();
services = app.services;
mongodb = services.mongodb;
models = await setup.initModels(services, [fixtureUsers]);
});
beforeEach(async () => {
try {
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({}),
]);
} catch (err) {
// Possibly the User model does not exist
}
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
await setup.teardownDb(mongodb);
});
describe('#create', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
header: (h) => req.headers[h],
params: {},
body: {},
headers: {},
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = create({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = create({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a validation error in case invalid body schema validation', async () => {
const controller = create({ ...services, models });
req.params.model = 'users';
req.body = { firstname: 12 };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Event schema validation error');
expect(error.details).toMatchObject([
{
instancePath: '/firstname',
keyword: 'type',
message: 'must be string',
params: { type: 'string' },
schemaPath: '#/properties/firstname/type',
},
{ event: { firstname: 12 } },
{ model: 'users' },
]);
expect(error.status).toEqual(400);
});
it('returns a generic error otherwise', async () => {
const controller = create({ ...services, models });
req.params.model = 'users';
req.body = { firstname: 'John' };
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the created entity', async () => {
const controller = create({ ...services, models });
req.params.model = 'users';
req.body = { firstname: 'John' };
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'John',
version: 0,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the created entity with forced value on `created_at`', async () => {
const controller = create({ ...services, models });
req.params.model = 'users';
req.body = { firstname: 'John' };
req.headers['created-at'] = new Date('2021-01-01').toISOString();
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
created_at: new Date(req.headers['created-at']),
updated_at: new Date(req.headers['created-at']),
firstname: 'John',
version: 0,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns a 409 Conflict error in case of index violation', async () => {
const controller = create({ ...services, models });
req.params.model = 'users';
req.body = { firstname: 'John', email: 'john@doe.org' };
await controller(req, res, next);
res = { locals: {} };
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(2);
expect(error).not.toBe(null);
expect(error.message).toContain('E11000 duplicate key error collection');
expect(error.status).toEqual(409);
});
});
describe('#update', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
header: (h) => req.headers[h],
params: {},
body: {},
headers: {},
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = update({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = update({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => {
const controller = update({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
req.body = { firstname: 'John' };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity must be created first');
expect(error.status).toEqual(422);
});
it('returns a validation error in case invalid body schema validation', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
firstname: 12, // <-- invalid, must be string
};
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Event schema validation error');
expect(error.details).toMatchObject([
{
instancePath: '/firstname',
keyword: 'type',
message: 'must be string',
params: { type: 'string' },
schemaPath: '#/properties/firstname/type',
},
{ event: { firstname: 12 } },
{ model: 'users' },
]);
expect(error.status).toEqual(400);
});
it('returns a 405 `Entity is readonly` in case of an update temptative on a readonly entity', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John', is_readonly: true });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity is readonly');
expect(error.status).toEqual(405);
});
it('returns a 412 Precondition Failed in case of an imperative condition not satisfied', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { firstname: 'Jack' };
req.headers.version = '12'; // Invalid
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Imperative condition failed');
expect(error.status).toEqual(412);
});
it('returns a generic error otherwise', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { firstname: 'John' };
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the updated entity', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
expect(res.body.created_at).not.toEqual(res.body.updated_at);
});
it('returns the updated entity with forced value on `updated_at`', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { firstname: 'Jack' };
req.headers['created-at'] = new Date('2022-01-01').toISOString();
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body.created_at).not.toEqual(res.body.updated_at);
expect(res.body).toMatchObject({
updated_at: new Date(req.headers['created-at']),
firstname: 'Jack',
version: 1,
is_enabled: true,
});
});
it('returns the updated entity even on not already created entity with the upsert header set to true', async () => {
const controller = update({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
req.body = { user_id: req.params.correlation_id, firstname: 'Jack' };
req.headers['upsert'] = 'true';
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 0,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
expect(res.body.created_at).toEqual(res.body.updated_at);
});
it('returns the updated entity on upsert and entity already created', async () => {
const controller = update({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { user_id: req.params.correlation_id, firstname: 'Jack' };
req.headers['upsert'] = 'true';
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
expect(res.body.created_at).not.toEqual(res.body.updated_at);
});
it('supports concurrent upsert requests', async () => {
const controller = update({ ...services, models });
const correlationId = new ObjectId().toString();
req.params.model = 'users';
req.params.correlation_id = correlationId;
req.headers['upsert'] = 'true';
let responses = [
{ locals: {} },
{ locals: {} },
{ locals: {} },
{ locals: {} },
{ locals: {} },
];
await Promise.all(
responses.map((r) =>
controller(
{
...req,
body: { user_id: req.params.correlation_id, firstname: 'Jack' },
},
r,
next,
),
),
);
expect(next).toHaveBeenCalledWith();
const versions = responses
.map((r) => r.body.version)
.sort((a, b) => a - b);
const jack = models.factory('users', correlationId);
const state = await jack.getState();
const events = await jack.getEvents().toArray();
expect(state.version).toEqual(4);
expect(Math.max(...versions)).toEqual(4);
expect(events.map((e) => e.version).sort()).toEqual([0, 1, 2, 3, 4]);
});
it('returns a 409 Conflict error in case of index violation', async () => {
const controller = update({ ...services, models });
const alice = models.factory('users');
await alice.create({ firstname: 'John', email: 'john@doe.org' });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = { email: 'john@doe.org' };
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(error).not.toBe(null);
expect(error.message).toContain('E11000 duplicate key error collection');
expect(error.status).toEqual(409);
});
});
describe('#patch', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
header: (h) => req.headers[h],
headers: {},
params: {},
body: {},
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = patch({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = patch({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => {
const controller = patch({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'John' }],
};
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity must be created first');
expect(error.status).toEqual(422);
});
it('returns a 405 `Entity is readonly` in case of a patch on readonly entity', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John', is_readonly: true });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }],
};
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity is readonly');
expect(error.status).toEqual(405);
});
it('returns a 412 Precondition Failed in case of an imperative condition not satisfied', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.headers.version = '12'; // invalid
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }],
};
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Imperative condition failed');
expect(error.status).toEqual(412);
});
it('returns a validation error in case invalid body schema validation', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
firstname: 'John', // Must only accept `json_patch`
json_patch: [],
};
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Event schema validation error');
expect(error.details).toMatchObject([
{
instancePath: '',
keyword: 'additionalProperties',
message: 'must NOT have additional properties',
params: { additionalProperty: 'firstname' },
schemaPath: '#/additionalProperties',
},
{ event: { firstname: 'John', json_patch: [] } },
{ model: 'users' },
]);
expect(error.status).toEqual(400);
});
it('returns a generic error otherwise', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'John' }],
};
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the patched entity', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }],
};
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
expect(res.body.created_at).not.toEqual(res.body.updated_at);
});
it('returns the patched entity with forced `created_at` value', async () => {
const controller = patch({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }],
};
req.headers['created-at'] = new Date('2021-01-01').toISOString();
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
updated_at: new Date(req.headers['created-at']),
firstname: 'Jack',
version: 1,
is_enabled: true,
});
});
it('returns a 409 Conflict error in case of index violation', async () => {
const controller = patch({ ...services, models });
const alice = models.factory('users');
await alice.create({ firstname: 'John', email: 'john@doe.org' });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.body = {
json_patch: [{ op: 'replace', path: '/email', value: 'john@doe.org' }],
};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(error).not.toBe(null);
expect(error.message).toContain('E11000 duplicate key error collection');
expect(error.status).toEqual(409);
});
});
describe('#apply', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
header: (h) => req.headers[h],
headers: {},
params: {},
body: {},
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = apply({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = apply({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => {
const controller = apply({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity must be created first');
expect(error.status).toEqual(422);
});
it('returns a 405 `Entity is readonly` in case of an apply on a readonly entity', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John', is_readonly: true });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity is readonly');
expect(error.status).toEqual(405);
});
it('returns a 412 Precondition failed in case of an imperative condition not satisfied', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.headers.version = '12'; // Invalid
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Imperative condition failed');
expect(error.status).toEqual(412);
});
it('returns a validation error in case invalid body schema validation', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 12 };
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Event schema validation error');
expect(error.details).toMatchObject([
{
instancePath: '/firstname',
keyword: 'type',
message: 'must be string',
params: { type: 'string' },
schemaPath: '#/properties/firstname/type',
},
{ event: { firstname: 12 } },
{ model: 'users' },
]);
expect(error.status).toEqual(400);
});
it('returns a generic error otherwise', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the updated entity', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
expect(res.body.created_at).not.toEqual(res.body.updated_at);
});
it('returns the updated entity with forced `created_at` value', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
req.headers['created-at'] = new Date('2021-01-01').toISOString();
await controller(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(res.body).toMatchObject({
updated_at: new Date(req.headers['created-at']),
firstname: 'Jack',
version: 1,
is_enabled: true,
});
});
it('returns the updated entity with defined `retryDuration` handle options', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'firstname_updated';
req.params.event_version = '0_0_0';
req.body = { firstname: 'Jack' };
req.headers['created-at'] = new Date('2021-01-01').toISOString();
req.headers['retry-duration'] = 0; // <-- Disable the retry for a specific event not 5000ms
const iterations = new Array(20).fill(1);
await Promise.all(iterations.map((_, i) => controller(req, res, next)));
await new Promise((resolve) => setTimeout(resolve, 2000));
expect((await user.getEvents().toArray()).length < 21).toEqual(true);
});
it('returns the updated entity after an event `replay`', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
const statetoRestore = user.state;
const Users = models.getModel(fixtureUsers.name);
const events = await Users.getEventsCollection(Users.db(mongodb))
.find({ user_id: user.correlationId })
.toArray();
// Here we entirely remove events from the database:
await Users.getStatesCollection(Users.db(mongodb)).deleteMany({
user_id: user.correlationId,
});
await Users.getEventsCollection(Users.db(mongodb)).deleteMany({
user_id: user.correlationId,
});
for (const event of events) {
res.body = null;
req.params.model = 'users';
req.params.correlation_id = event.user_id;
req.params.event_type = event.type;
req.params.event_version = event.v;
req.body = event;
req.headers['replay'] = 'true';
await controller(req, res, next);
}
expect(res.body).toMatchObject(statetoRestore);
});
it('returns the updated entity after an event `replay` event on event already replayed', async () => {
const controller = apply({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
const statetoRestore = user.state;
const Users = models.getModel(fixtureUsers.name);
const events = await Users.getEventsCollection(Users.db(mongodb))
.find({ user_id: user.correlationId })
.toArray();
// Here we do not remove events from the database
for (const event of events) {
res.body = null;
req.params.model = 'users';
req.params.correlation_id = event.user_id;
req.params.event_type = event.type;
req.params.event_version = event.v;
req.body = event;
req.headers['replay'] = 'true';
await controller(req, res, next);
}
expect(res.body).toMatchObject(statetoRestore);
});
it('returns a 409 Conflict error in case of index violation', async () => {
const controller = apply({ ...services, models });
const alice = models.factory('users');
await alice.create({ firstname: 'John', email: 'john@doe.org' });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.event_type = 'email_updated';
req.params.event_version = '0_0_0';
req.body = { email: 'john@doe.org' };
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(error).not.toBe(null);
expect(error.message).toContain('E11000 duplicate key error collection');
expect(error.status).toEqual(409);
});
});
describe('#get', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
params: {},
body: {},
header: (h) => req.headers[h.toLowerCase()],
query: {},
headers: {},
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = get({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = get({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 404 Not Found if the state of the entity is null (not created)', async () => {
const controller = get({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Not Found');
expect(error.status).toEqual(404);
});
it('returns a generic error otherwise', async () => {
const controller = get({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the entity', async () => {
const controller = get({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
await controller(req, res, next);
expect(res.body).toMatchObject({
firstname: 'John',
version: 0,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the encrypted entity with decrypt header if not authorized', async () => {
const Model = services.models.getModel('users');
jest
.spyOn(Model, 'getEncryptionKeys')
.mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']);
jest
.spyOn(Model, 'getHashesEncryptionKeys')
.mockImplementation(() => [
Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'),
]);
const decryptMock = jest.spyOn(Model, 'decrypt');
const controller = get({ ...services, models });
const user = models.factory('users');
await user.create({
firstname: 'John',
sensitive_data: 'this is private',
});
req.headers.decrypt = 'true';
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
await controller(req, res, next);
expect(decryptMock).toHaveBeenCalledTimes(0);
expect(res.body).toMatchObject({
firstname: 'John',
version: 0,
is_enabled: true,
});
expect(res.body.sensitive_data).not.toEqual('this is private');
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the encrypted entity with decrypt header if authorized', async () => {
const Model = services.models.getModel('users');
jest
.spyOn(Model, 'getEncryptionKeys')
.mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']);
jest
.spyOn(Model, 'getHashesEncryptionKeys')
.mockImplementation(() => [
Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'),
]);
const decryptMock = jest.spyOn(Model, 'decrypt');
const controller = get({
...services,
models,
config: {
...services.config,
security: {
...services.config.security,
tokens: [
{ id: 'read', level: 'read', token: 'read' },
{ id: 'decrypt', level: 'decrypt', token: 'decrypt' },
{ id: 'write', level: 'write', token: 'write' },
{ id: 'admin', level: 'admin', token: 'admin' },
],
},
},
});
const user = models.factory('users');
await user.create({
firstname: 'John',
sensitive_data: 'this is private',
});
req.headers.authorization = 'decrypt';
req.headers.decrypt = 'true';
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
await controller(req, res, next);
expect(decryptMock).toHaveBeenCalledWith(user.state, undefined);
expect(res.body).toMatchObject({
firstname: 'John',
version: 0,
is_enabled: true,
});
expect(res.body.sensitive_data).toEqual('this is private');
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
});
describe('#timetravel', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = {
params: { version: '0' },
body: {},
headers: {},
header: (h) => req.headers[h.toLowerCase()],
};
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = timetravel({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = timetravel({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 404 Not Found if the state of the entity is null (not created)', async () => {
const controller = timetravel({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Not Found');
expect(error.status).toEqual(404);
});
it('returns a generic error otherwise', async () => {
const controller = timetravel({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the entity', async () => {
const controller = timetravel({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = 1;
await controller(req, res, next);
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the entity at a given date', async () => {
const controller = timetravel({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await new Promise((resolve) => setTimeout(resolve, 100));
await user.update({ firstname: 'Jack' });
const target = user.state.updated_at;
await new Promise((resolve) => setTimeout(resolve, 100));
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = new Date(
new Date(target).getTime() + 50,
).toISOString();
await controller(req, res, next);
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the entity at a given date', async () => {
const controller = timetravel({ ...services, models });
const alice = models.factory('users');
const user = models.factory('users');
await user.create({ firstname: 'John' });
await alice.create({ firstname: 'Alice' });
await new Promise((resolve) => setTimeout(resolve, 100));
await user.update({ firstname: 'Jack' });
await alice.update({ firstname: 'Alizz' });
const target = user.state.updated_at;
await new Promise((resolve) => setTimeout(resolve, 100));
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = new Date(
new Date(target).getTime() + 50,
).toISOString();
await controller(req, res, next);
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns the entity skipping the response validation with with-response-validation header set to false', async () => {
const controller = timetravel({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = 1;
req.headers['with-response-validation'] = 'false';
res.json = jest.fn();
await controller(req, res, next);
expect(res.body).toMatchObject({
firstname: 'Jack',
version: 1,
is_enabled: true,
});
expect(next).toHaveBeenCalledTimes(0);
expect(res.json).toHaveBeenCalledWith(res.body);
});
it('returns a 404 Not Found error in case of date prior to the creation', async () => {
const controller = timetravel({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
const target = new Date(new Date(user.state.updated_at).getTime() - 1);
await user.update({ firstname: 'Jack' });
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = target.toISOString();
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Not Found');
expect(error.status).toEqual(404);
});
});
describe('#restore', () => {
let error;
let req;
let res;
let next;
beforeEach(() => {
error = null;
next = jest.fn().mockImplementation((err) => (error = err));
req = { params: { version: '0' }, body: {} };
res = { locals: {} };
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls next if res.body is already set', async () => {
const controller = restore({ ...services, models });
res.body = {};
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenLastCalledWith();
});
it('returns an error if the model is invalid', async () => {
const controller = restore({ ...services, models });
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
expect(error.status).toEqual(400);
});
it('returns a 404 Not Found if the state of the entity is null (not created)', async () => {
const controller = restore({ ...services, models });
req.params.model = 'users';
req.params.correlation_id = new ObjectId().toString();
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Not Found');
expect(error.status).toEqual(404);
});
it('returns a 404 Not Found if the target state does not exist', async () => {
const controller = restore({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = '23';
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('State version does not exist');
expect(error.status).toEqual(404);
});
it('returns a 405 `Entity is readonly` if the entity is readonly', async () => {
const controller = restore({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
await user.update({ firstname: 'William', is_readonly: true });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = 1;
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Entity is readonly');
expect(error.status).toEqual(405);
});
it('returns a generic error otherwise', async () => {
const controller = restore({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
next = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Oooops');
})
.mockImplementation((err) => (error = err));
await controller(req, res, next);
expect(error).not.toBe(null);
expect(error.message).toEqual('Oooops');
});
it('returns the entity', async () => {
const controller = restore({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John' });
await user.update({ firstname: 'Jack' });
await user.update({ firstname: 'William' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = 1;
await controller(req, res, next);
expect(res.body).toMatchObject({ firstname: 'Jack', version: 3 });
expect(res.body).toHaveProperty('created_at');
expect(res.body).toHaveProperty('updated_at');
expect(res.body).toHaveProperty('user_id');
});
it('returns a 409 Conflict error in case of index violation', async () => {
const controller = restore({ ...services, models });
const user = models.factory('users');
await user.create({ firstname: 'John', email: 'john@doe.org' });
await user.update({ firstname: 'Jack', email: 'bernard@doe.org' });
// Alice is having now the ownership of this email address:
const alice = models.factory('users');
await alice.create({ firstname: 'John', email: 'john@doe.org' });
req.params.model = 'users';
req.params.correlation_id = user.state.user_id.toString();
req.params.version = 0;
await controller(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(error).not.toBe(null);
expect(error.message).toEqual('Can not rollback a restoration event');