@getanthill/datastore
Version:
Event-Sourced Datastore
1,846 lines (1,408 loc) • 107 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).toBeCalledTimes(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: {},
};
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 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('updat