@getanthill/datastore
Version:
Event-Sourced Datastore
1,970 lines (1,627 loc) • 54.2 kB
text/typescript
import crypto from 'crypto';
import { merge } from 'lodash';
import { MongoDbConnector } from '@getanthill/mongodb-connector';
import reducerFactory from './reducer';
import GenericFactory from './Generic';
import fixtureUsers from '../../test/fixtures/users';
import setup from '../../test/setup';
import { Services } from '../typings';
import * as utils from '../utils';
import * as c from '../constants';
import { Models } from './index';
describe('models/Generic', () => {
const DEFAULT_SCHEMAS = {
$id: 'events',
components: {},
events: {
CREATED: {
'0_0_0': {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
UPDATED: {
'0_0_0': {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
ARCHIVED: {
'0_0_0': {
type: 'object',
additionalProperties: true,
},
},
DELETED: {
'0_0_0': {
type: 'object',
additionalProperties: true,
},
},
RESTORED: {
'0_0_0': {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
ROLLBACKED: {
'0_0_0': {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
AGE_UPDATED: {
'0_0_0': {
upsert: true,
type: 'object',
properties: {
age: {
type: 'integer',
},
},
},
},
},
};
const DEFAULT_DEFINITION = {
DATABASE: 'datastore',
COLLECTION: 'users',
COLLECTION_OPTIONS: {},
INDEXES: [],
VALIDATOR_SCHEMA: {},
VALIDATOR_OPTIONS: {},
};
const ORIGINAL_SCHEMA = fixtureUsers.schema;
const MODEL_CONFIG = fixtureUsers;
let schemas;
let reducer;
let models: Models;
let app;
let services: Services;
let mongodb: MongoDbConnector;
let DateMock;
let DateNowMock;
beforeAll(async () => {
app = await setup.build();
services = app.services;
mongodb = services.mongodb;
});
beforeEach(async () => {
schemas = merge({}, DEFAULT_SCHEMAS);
reducer = reducerFactory(schemas);
models = await setup.initModels(services, [fixtureUsers]);
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({}),
]);
DateMock = jest
.spyOn(utils, 'getDate')
.mockReturnValue(new Date('2020-11-10T00:00:00.000Z'));
DateNowMock = jest
.spyOn(utils, 'getDateNow')
.mockReturnValue(new Date('2020-11-10T00:00:00.000Z').getTime());
});
afterEach(async () => {
jest.restoreAllMocks();
});
afterAll(async () => {
await setup.teardownDb(mongodb);
});
it('instanciates a new Generic model from definition', () => {
const Model = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
expect(Model).toHaveProperty('getSchema');
expect(Model).toHaveProperty('getCorrelationField');
expect(Model).toHaveProperty('getCollectionName');
expect(Model).toHaveProperty('db');
// Event Sourced
expect(Model).toHaveProperty('getState');
expect(Model).toHaveProperty('find');
});
it('allows to create and store a new model in the database', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
expect(Users.getModelConfig()).toEqual(MODEL_CONFIG);
});
it('allows to create and store a new model in the database', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
const data = await Users.find(mongodb, {
user_id: user.state.user_id,
}).toArray();
expect(data).toMatchObject([
{
user_id: user.state.user_id,
firstname: 'John',
},
]);
});
it('allows to update a model existing in the database', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED });
const data = await Users.find(mongodb, {
user_id: user.state.user_id,
}).toArray();
expect(data).toMatchObject([
{
user_id: user.state.user_id,
firstname: 'Jack',
},
]);
});
it('allows to upsert a model existing in database if does not exists yet', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.upsert({ firstname: 'John' });
const data = await Users.find(mongodb, {
user_id: user.state.user_id,
}).toArray();
expect(data).toMatchObject([
{
user_id: user.state.user_id,
firstname: 'John',
},
]);
});
it('throws an exception if entity not created but imperative version is greater than 0', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
let error;
try {
await user.upsert(
{ firstname: 'John' },
{
imperativeVersion: 1,
},
);
} catch (err) {
error = err;
}
expect(error.message).toEqual('Entity must be created first');
});
it('allows to update a model existing in database already', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John' });
await user.upsert({ firstname: 'Jack' });
const data = await Users.find(mongodb, {
user_id: user.state.user_id,
}).toArray();
expect(data).toMatchObject([
{
user_id: user.state.user_id,
firstname: 'Jack',
},
]);
});
it('allows to performs parallel upserts', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: {
...schemas,
retry_duration: 5000,
},
ORIGINAL_SCHEMA: {
...ORIGINAL_SCHEMA,
retry_duration: 5000,
},
MODEL_CONFIG,
services,
});
const user = new Users(services);
await Promise.all(
new Array(10)
.fill(1)
.map((_, i) => user.upsert({ firstname: `Jack ${i}` })),
);
const [data] = await Users.find(mongodb, {
user_id: user.state.user_id,
}).toArray();
expect(data.version).toBeGreaterThan(5);
});
it('throws an exception if an error occured on second update trial', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: {
...schemas,
retry_duration: 5000,
},
ORIGINAL_SCHEMA: {
...ORIGINAL_SCHEMA,
retry_duration: 5000,
},
MODEL_CONFIG,
services,
});
const user = new Users(services);
jest
.spyOn(user, 'update')
.mockRejectedValueOnce(new Error('Entity must be created first'))
.mockRejectedValue(new Error('Ooops'));
jest
.spyOn(user, 'create')
.mockRejectedValue(new Error('Entity already created'));
let error;
try {
await Promise.all(
new Array(10)
.fill(1)
.map((_, i) => user.upsert({ firstname: `Jack ${i}` })),
);
} catch (err) {
error = err;
}
expect(error.message).toEqual('Ooops');
});
it('throws an exception if an error occured on create trial', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
jest
.spyOn(user, 'update')
.mockRejectedValueOnce(new Error('Entity must be created first'));
jest.spyOn(user, 'create').mockRejectedValue(new Error('Ooops'));
let error;
try {
await user.upsert({ firstname: 'Jack' });
} catch (err) {
error = err;
}
expect(error.message).toEqual('Ooops');
});
it('blocks the update of a model flagged as readonly', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({
firstname: 'John',
is_readonly: true,
type: c.EVENT_TYPE_CREATED,
});
let error;
try {
await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED });
} catch (err) {
error = err;
}
expect(error.message).toEqual('Entity is readonly');
});
it('allows to restore a model at a precedent version', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED });
await user.restore(0);
expect(user.state).toMatchObject({
user_id: user.state.user_id,
firstname: 'John',
version: 2,
});
});
it('allows to count entities in the database', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
let user = new Users(services);
await user.create({ firstname: 'John' });
const count = await Users.count(services.mongodb, {
firstname: 'John',
});
expect(count).toEqual(1);
});
it('allows to find entities in the database', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
let user = new Users(services);
await user.create({ firstname: 'John' });
const users = await Users.find(services.mongodb, {
firstname: 'John',
}).toArray();
expect(users).toMatchObject([user.state]);
});
it('allows to explain a find query', async () => {
const servicesWithExplainFeature = merge({}, services, {
config: {
features: {
mongodb: {
explain: true,
},
},
},
});
models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]);
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services: servicesWithExplainFeature,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
const users = await Users.find(services.mongodb, {
firstname: 'John',
}).toArray();
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(
global.infoMock.mock.calls.filter(
(call) =>
call[0] === '[Generic#find] Query explain' &&
call[1].model_name === 'users',
),
).toMatchObject([
[
'[Generic#find] Query explain',
{
model_name: 'users',
query: { firstname: 'John' },
stage: 'COLLSCAN',
// executation_time_ms: 0,
index_name: undefined,
keys_examined: 0,
docs_examined: 1,
},
],
]);
});
it('logs a warning on slow query detected', async () => {
const servicesWithExplainFeature = merge({}, services, {
config: {
features: {
mongodb: {
explain: true,
slowQueryThresholdInMilliseconds: 1000,
},
},
},
});
models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]);
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services: servicesWithExplainFeature,
});
await Users.explain(
{
explain: jest.fn().mockResolvedValue({
executionStats: {
executionTimeMillis: 2000,
},
}),
},
{},
);
expect(global.warnMock.mock.calls[0][0]).toEqual(
'[Generic#find] Slow query detected',
);
});
it('is safe on query explain plan error', async () => {
const servicesWithExplainFeature = merge({}, services, {
config: {
features: {
mongodb: {
explain: true,
},
},
},
});
const error = new Error('Ooops');
global.debugMock = jest
.spyOn(services.telemetry.logger, 'debug')
.mockImplementation((msg, context?) => {
if (msg === '[Generic#find] Query explain') {
throw error;
}
return this;
});
models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]);
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services: servicesWithExplainFeature,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
const users = await Users.find(services.mongodb, {
firstname: 'John',
}).toArray();
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(
global.errorMock.mock.calls.filter(
(call) =>
call[0] === '[Generic#find] Query explain error' &&
call[1].model_name === 'users',
),
).toEqual([
[
'[Generic#find] Query explain error',
{
model_name: 'users',
query: { firstname: 'John' },
err: error,
},
],
]);
});
it('allows to retrieve events for a given entity', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED });
const events = await user.getEvents().toArray();
expect(events).toMatchObject([
{
type: 'CREATED',
v: '0_0_0',
firstname: 'John',
version: 0,
},
{
type: 'UPDATED',
v: '0_0_0',
firstname: 'Jack',
version: 1,
},
]);
});
it('allows to apply arbitrary events', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
await user.apply('AGE_UPDATED', {
age: 12,
type: 'AGE_UPDATED',
});
expect(user.state).toMatchObject({
firstname: 'John',
age: 12,
});
});
it('allows to apply arbitrary events without creation', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services, setup.uuid());
await user.apply('AGE_UPDATED', {
age: 12,
type: 'AGE_UPDATED',
});
expect(user.state).toMatchObject({
version: 0,
age: 12,
});
});
it('allows to apply multiply arbitrary events without creation', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
const user = new Users(services);
await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED });
await user.apply('AGE_UPDATED', {
age: 13,
type: 'AGE_UPDATED',
});
expect(user.state).toMatchObject({
version: 1,
age: 13,
});
});
describe('#archive / #unarchive / #delete', () => {
let Users;
let user;
let _services;
beforeEach(async () => {
_services = {
...services,
config: {
...services.config,
security: {
...services.config.security,
encryptionKeys: {
archive: [crypto.randomBytes(16).toString('hex')],
users: [crypto.randomBytes(16).toString('hex')],
},
},
features: {
...services.config.features,
deleteAfterArchiveDurationInSeconds: 1209600,
},
},
};
models = await setup.initModels(_services, [
{
...fixtureUsers,
encrypted_fields: ['firstname'],
},
]);
reducer = reducerFactory(models.getModel('users').getSchema());
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: {
...MODEL_CONFIG,
encrypted_fields: ['firstname'],
},
services: _services,
});
user = new Users(services);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('sets the entity as readonly', async () => {
await user.create({
firstname: 'John',
age: 24,
});
await user.archive();
expect(user.state).toMatchObject({
age: 24,
version: 1,
is_readonly: true,
});
});
it('sets the entity as archived', async () => {
await user.create({
firstname: 'John',
age: 24,
});
await user.archive();
expect(user.state).toMatchObject({
age: 24,
version: 1,
is_archived: true,
});
});
it('sets the entity as not deleted yet', async () => {
await user.create({
firstname: 'John',
age: 24,
});
await user.archive();
expect(user.state).toMatchObject({
age: 24,
version: 1,
is_deleted: false,
});
});
it('sets the entity as archived with date fields', async () => {
await user.create({
firstname: 'John',
last_seen: new Date(),
});
user = new Users(services, user.correlationId);
await user.archive();
expect(user.state).toMatchObject({
version: 1,
is_archived: true,
});
});
it('noops on entity already archived', async () => {
await user.create({
firstname: 'John',
age: 24,
});
await user.archive();
await user.archive();
expect(user.state).toMatchObject({
age: 24,
version: 1,
is_archived: true,
});
});
it('archives and encrypts values if an archive encryption key is available', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
const stateBeforeArchive = user.state;
await user.archive();
const stateAfterArchive = user.state;
expect(stateAfterArchive.firstname).not.toEqual(
stateBeforeArchive.firstname,
);
expect(stateAfterArchive).toMatchObject({
is_readonly: true,
is_archived: true,
version: 1,
});
});
it('allows to restore the value prior to the archive operation', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
const stateBeforeArchive = user.state;
await user.archive();
const stateAfterArchive = user.state;
expect(stateAfterArchive).not.toEqual(stateBeforeArchive);
expect(stateAfterArchive).toMatchObject({
is_readonly: true,
is_archived: true,
version: 1,
});
await user.unarchive();
expect(user.state).toMatchObject({
version: 2,
firstname: stateBeforeArchive.firstname,
is_readonly: true,
});
});
it('keeps the value of the `is_readonly` flag prior to the archive process', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: false,
});
const stateBeforeArchive = user.state;
await user.archive();
const stateAfterArchive = user.state;
expect(stateAfterArchive).not.toEqual(stateBeforeArchive);
expect(stateAfterArchive).toMatchObject({
is_readonly: true,
is_archived: true,
version: 1,
});
await user.unarchive();
expect(user.state).toMatchObject({
version: 2,
firstname: stateBeforeArchive.firstname,
is_readonly: false,
});
});
it('restores the value of the `is_readonly` flag to `false` if none has been defined', async () => {
await user.create({
firstname: 'John',
age: 24,
});
const stateBeforeArchive = user.state;
await user.archive();
const stateAfterArchive = user.state;
expect(stateAfterArchive).not.toEqual(stateBeforeArchive);
expect(stateAfterArchive).toMatchObject({
is_readonly: true,
is_archived: true,
version: 1,
});
await user.unarchive();
expect(user.state).toMatchObject({
version: 2,
firstname: stateBeforeArchive.firstname,
is_readonly: false,
});
});
it('archives and encrypts values with a model valid encryption key is no archive one is provided', async () => {
Users.options.services.config.security.encryptionKeys = {
users: Users.options.services.config.security.encryptionKeys.users,
};
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
const stateBeforeArchive = user.state;
await user.archive();
const stateAfterArchive = user.state;
expect(stateAfterArchive.firstname).not.toEqual(
stateBeforeArchive.firstname,
);
expect(stateAfterArchive).toMatchObject({
is_readonly: true,
is_archived: true,
version: 1,
});
});
it('allows to restore the value prior to the archive operation with a model valid encryption key is no archive one is provided', async () => {
Users.options.services.config.security.encryptionKeys = {
users: Users.options.services.config.security.encryptionKeys.users,
};
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
const stateBeforeArchive = user.state;
await user.archive();
await user.unarchive();
expect(user.state.version).toEqual(2);
expect(user.state.firstname).toEqual(stateBeforeArchive.firstname);
});
it('performs a noop on non archived entities', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
const stateBeforeUnarchive = user.state;
await user.unarchive();
expect(user.state).toEqual(stateBeforeUnarchive);
});
it('performs a noop on a already deleted entities', async () => {
_services.config.features.deleteAfterArchiveDurationInSeconds = 0;
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
await user.archive();
await user.delete();
await user.unarchive();
expect(user.state).toMatchObject({
version: 2,
is_readonly: true,
is_archived: true,
is_deleted: true,
});
});
it('throws an exception on not already archived entity', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
let error;
try {
await user.delete();
} catch (err) {
error = err;
}
expect(error.message).toEqual('Entity must be archived first');
});
it('allows to delete an archived entity', async () => {
DateNowMock.mockRestore();
DateNowMock = jest
.spyOn(utils, 'getDateNow')
.mockReturnValue(new Date('2021-11-10T00:00:00.000Z').getTime());
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
await user.archive();
await user.delete();
const events = await user.getEvents().toArray();
expect(user.state).toMatchObject({
version: 2,
is_deleted: true,
});
expect(user.state.firstname).toEqual(undefined);
expect(events[0].firstname).toEqual(undefined);
expect(events[1].firstname).toEqual(undefined);
expect(events[2].firstname).toEqual(undefined);
});
it('throws an exception on entity archived too recently', async () => {
await user.create({
firstname: 'John',
age: 24,
is_readonly: true,
});
await user.archive();
let error;
try {
await user.delete();
} catch (err) {
error = err;
}
expect(error.message).toEqual('Entity archived too recently');
});
});
describe('#handleWithRetry', () => {
let Users;
beforeEach(() => {
Users = models.getModel(fixtureUsers.name);
DateNowMock.mockRestore();
});
it('handles an event without retry configured', async () => {
const alice = new Users(services);
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
0,
(err) => {
throw err;
},
);
expect(state).toMatchObject({
version: 0,
firstname: 'Alice',
});
});
it('handles an event update with imperative condition failure successfully', async () => {
const alice = new Users(services);
let error;
try {
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
0,
(err) => {
throw err;
},
{
imperativeVersion: 1, // Impossible on create
},
);
} catch (err) {
error = err;
}
expect(error).toEqual(Users.ERRORS.HANDLER_IMPERATIVE_CONDITION_FAILED);
});
it('handles an event update with imperative condition success', async () => {
const alice = new Users(services);
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
0,
(err) => {
throw err;
},
0,
);
expect(state).toMatchObject({
version: 0,
});
});
it('handles an event with a retry duration configured', async () => {
const alice = new Users(services);
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
1000,
(err) => {
throw err;
},
);
expect(state).toMatchObject({
version: 0,
firstname: 'Alice',
});
});
it('handles a temporary event failure correctly', async () => {
Users.RETRY_ERRORS.push('Ooops');
const alice = new Users(services);
alice.handle = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('Ooops');
})
.mockImplementation(() => ({
version: 0,
firstname: 'Alice',
}));
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
1000,
(err) => {
throw err;
},
);
expect(state).toMatchObject({
version: 0,
firstname: 'Alice',
});
});
it('returns the entity state on correlation_id_unicity error', async () => {
const alice = new Users(services);
alice.getState = jest.fn().mockImplementation(() => ({
version: 0,
firstname: 'Alice',
}));
alice.handle = jest.fn().mockImplementationOnce(() => {
throw new Error('correlation_id_unicity');
});
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
1000,
(err) => {
throw err;
},
);
expect(state).toMatchObject({
version: 0,
firstname: 'Alice',
});
});
it('invokes the storeStateErrorHandler in case of a non whitelisted error', async () => {
const alice = new Users(services);
let error = new Error('Not registered error');
let catchedError;
alice.handle = jest
.fn()
.mockImplementationOnce(() => {
throw error;
})
.mockImplementation(() => ({
version: 0,
firstname: 'Alice',
}));
try {
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
1000,
(err) => {
throw err;
},
);
} catch (err) {
catchedError = err;
}
expect(catchedError).toEqual(error);
});
it('invokes the storeStateErrorHandler in case of long terms error', async () => {
Users.RETRY_ERRORS.push('Ooops');
const alice = new Users(services);
let error = new Error('Ooops');
let catchedError;
alice.handle = jest.fn().mockImplementation(() => {
throw error;
});
try {
const state = await alice.handleWithRetry(
() => [
{
type: 'CREATED',
v: '0_0_0',
firstname: 'Alice',
},
],
10,
(err) => {
throw err;
},
);
} catch (err) {
catchedError = err;
}
expect(catchedError).toEqual(error);
});
});
describe('retry', () => {
beforeEach(() => {
DateNowMock.mockRestore();
});
it('handles concurrent events smoothly if required on retryable model', async () => {
models = await setup.initModels(services, [
{
...fixtureUsers,
retry_duration: 3000,
},
]);
const Users = models.getModel(fixtureUsers.name);
const alice = new Users(services);
await alice.create({ firstname: 'Alice' });
const iterations = new Array(20).fill(1);
await Promise.all(
iterations.map((_, i) => alice.update({ firstname: `${i}` })),
);
await new Promise((resolve) => setTimeout(resolve, 2000));
expect(alice.state).toMatchObject({
/**
* We do not have any information about the final value
* of Alice because events have been inserted in parallel
*/
// firstname: 'Alice',
version: 20,
});
expect(await alice.getEvents().toArray()).toHaveLength(21);
});
it('handles concurrent events smoothly on retryable events', async () => {
models = await setup.initModels(services, [
{
is_enabled: fixtureUsers.is_enabled,
db: fixtureUsers.db,
name: fixtureUsers.name,
correlation_field: fixtureUsers.correlation_field,
encrypted_fields: fixtureUsers.encrypted_fields,
indexes: fixtureUsers.indexes,
schema: fixtureUsers.schema,
},
]);
const Users = models.getModel(fixtureUsers.name);
const alice = new Users(services);
await alice.create({ firstname: 'Alice' });
const iterations = new Array(20).fill(1);
await Promise.all(
iterations.map((_, i) =>
alice.update(
{ firstname: `${i}` },
{
retryDuration: 3000,
},
),
),
);
await new Promise((resolve) => setTimeout(resolve, 2000));
expect(alice.state).toMatchObject({
/**
* We do not have any information about the final value
* of Alice because events have been inserted in parallel
*/
// firstname: 'Alice',
version: 20,
});
expect(await alice.getEvents().toArray()).toHaveLength(21);
});
it('throws an exception if the retry duration is exceeded', async () => {
models = await setup.initModels(services, [
{
...fixtureUsers,
retry_duration: 10,
},
]);
const Users = models.getModel(fixtureUsers.name);
const alice = new Users(services);
await alice.create({ firstname: 'Alice' });
let error;
try {
const iterations = new Array(100).fill(1);
await Promise.all(
iterations.map((i) => alice.update({ firstname: `${i}` })),
);
} catch (err) {
error = err;
}
expect(error.message.includes('correlation_id_version_unicity')).toEqual(
true,
);
expect(alice.state).not.toMatchObject({
firstname: 'Alice',
version: 101,
});
expect((await alice.getEvents().toArray()).length).toBeLessThan(101);
});
});
describe('#getIsReadonlyProperty', () => {
let Users;
beforeEach(() => {
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: {
...MODEL_CONFIG,
is_readonly: 'is_readonly',
},
services,
});
});
it('returns property from modelConfig given as input', () => {
expect(
Users.getIsReadonlyProperty({ is_readonly: 'is_really_readonly' }),
).toEqual('is_really_readonly');
});
it('returns property from modelConfig', () => {
expect(Users.getIsReadonlyProperty()).toEqual('is_readonly');
});
it('returns property from configuration', () => {
services.config.features.properties.is_readonly = 'iz_readonly';
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: {
...MODEL_CONFIG,
// is_readonly: 'is_readonly', // <-- missing
},
services,
});
expect(Users.getIsReadonlyProperty()).toEqual('iz_readonly');
});
});
describe('#encrypt / #decrypt', () => {
let _services;
let Users;
let user;
beforeEach(() => {
_services = {
...services,
config: {
...services.config,
security: {
...services.config.security,
encryptionKeys: {
users: [crypto.randomBytes(16).toString('hex')],
},
},
},
};
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: {
...MODEL_CONFIG,
encrypted_fields: ['firstname', 'deep.property', 'object'],
},
services: _services,
});
user = new Users(services);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('does nothing if the model has no encrypted key', async () => {
jest.spyOn(Users, 'getEncryptionKeys').mockImplementation(() => []);
const encryptedUser = Users.encrypt({
firstname: 'John',
age: 24,
});
expect(encryptedUser).toMatchObject({
firstname: 'John',
age: 24,
});
});
it('does nothing if the model has no encrypted field', async () => {
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: MODEL_CONFIG,
services: _services,
});
Users.getEncryptionKeys = jest.fn().mockImplementation(() => []);
Users.getEncryptedFields = jest.fn().mockImplementation(() => []);
const decryptedUser = Users.decrypt({
firstname: 'John',
age: 24,
});
expect(decryptedUser).toMatchObject({
firstname: 'John',
age: 24,
});
});
it('does nothing if the value has no encrypted field', async () => {
const decryptedUser = Users.decrypt({
firstname: 'John',
age: 24,
});
expect(decryptedUser).toMatchObject({
firstname: 'John',
age: 24,
});
});
it('encrypt the value at the given field if an encryption key is available', async () => {
const originalData = {
firstname: 'John',
age: 24,
non_initially_encrypted: 'clear text',
};
const encryptedUser = Users.encrypt(originalData, [
'non_initially_encrypted',
]);
expect(encryptedUser.non_initially_encrypted).not.toEqual('clear text');
const decryptedUserWithoutAdditionalEncryptionFields =
Users.decrypt(encryptedUser);
expect(decryptedUserWithoutAdditionalEncryptionFields).not.toMatchObject(
originalData,
);
const decryptedUser = Users.decrypt(encryptedUser, [
'non_initially_encrypted',
]);
expect(decryptedUser).toMatchObject(originalData);
});
it('encrypt the value at the given field if an encryption key is available and with a randomized key taken', async () => {
_services = {
...services,
config: {
...services.config,
security: {
...services.config.security,
activeNumberEncryptionKeys: 3,
encryptionKeys: {
users: [
crypto.randomBytes(16).toString('hex'),
crypto.randomBytes(16).toString('hex'),
crypto.randomBytes(16).toString('hex'),
],
},
},
},
};
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG: {
...MODEL_CONFIG,
encrypted_fields: ['firstname', 'deep.property', 'object'],
},
services: _services,
});
user = new Users(services);
const originalData = {
firstname: 'John',
age: 24,
non_initially_encrypted: 'clear text',
};
const encryptedUser = Users.encrypt(originalData, [
'non_initially_encrypted',
]);
expect(encryptedUser.non_initially_encrypted).not.toEqual('clear text');
const decryptedUserWithoutAdditionalEncryptionFields =
Users.decrypt(encryptedUser);
expect(decryptedUserWithoutAdditionalEncryptionFields).not.toMatchObject(
originalData,
);
const decryptedUser = Users.decrypt(encryptedUser, [
'non_initially_encrypted',
]);
expect(decryptedUser).toMatchObject(originalData);
});
it('allows to encrypt data on specific keys at the root level', async () => {
const encryptedUser = Users.encrypt({ firstname: 'John', age: 24 });
expect(encryptedUser).not.toMatchObject({
firstname: 'John',
age: 24,
});
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject({
firstname: 'John',
age: 24,
});
});
it('allows to encrypt data on specific keys on a deep property', async () => {
const originalData = {
firstname: 'John',
deep: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject(originalData);
});
it('allows to encrypt data on specific keys on an object property', async () => {
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject(originalData);
});
it('allows to decrypt data on previous historical encryption key', async () => {
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
_services.config.security.encryptionKeys = {
users: [
crypto.randomBytes(16).toString('hex'),
..._services.config.security.encryptionKeys.users, // Used encrypted key
],
};
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject(originalData);
});
/**
* @warn this test is there to check the backward
* compatibility with the previous implementation
* of the 'aes-256-cbc' algorithm which is now
* deprecated
*/
it('allows to decrypt data on encryption performed with deprecated `aes-256-cbc` algorithm', async () => {
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
// Encode with 'aes-256-cbc' algorithm:
const key = _services.config.security.encryptionKeys.users[0];
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv);
const value = JSON.stringify('John');
const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
encryptedUser.firstname.encrypted =
key.slice(0, 6) +
':' +
iv.toString('hex') +
':' +
encrypted.toString('hex');
// << End of previous implementation
_services.config.security.encryptionKeys = {
users: [
crypto.randomBytes(16).toString('hex'),
..._services.config.security.encryptionKeys.users, // Used encrypted key
],
};
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject(originalData);
});
it('returns encrypted data if the encryption key is not found', async () => {
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
_services.config.security.encryptionKeys = {
users: [crypto.randomBytes(16).toString('hex')],
};
Users.encryptionKeys = null;
Users.hashedEncryptionKeys = null;
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).not.toMatchObject(originalData);
});
it('encrypts data in state collection', async () => {
const alice = await user.create({ firstname: 'Alice', lastname: 'Doe' });
const state = await Users.find(mongodb, {
user_id: alice.state.user_id,
}).toArray();
expect(state.firstname).not.toEqual('Alice');
});
it('encrypts data in events collection', async () => {
const alice = await user.create({ firstname: 'Alice', lastname: 'Doe' });
const events = await alice.getEvents().toArray();
expect(events[0].firstname).not.toEqual('Alice');
});
it('does not encrypt if no key is configured', async () => {
Users.options.services.config.security.encryptionKeys = {};
Users.encryptionKeys = null;
Users.hashedEncryptionKeys = null;
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).toMatchObject(originalData);
});
it('does not decrypt if no key is configured', async () => {
const originalData = {
firstname: 'John',
object: { property: 24 },
};
const encryptedUser = Users.encrypt(originalData);
expect(encryptedUser).not.toMatchObject(originalData);
Users.options.services.config.security.encryptionKeys = {};
Users.encryptionKeys = null;
Users.hashedEncryptionKeys = null;
const decryptedUser = Users.decrypt(encryptedUser);
expect(decryptedUser).toMatchObject(encryptedUser);
});
});
describe('#getSchema', () => {
it('returns the schema of the model', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
expect(Users.getSchema()).toEqual(schemas);
});
});
describe('#getCorrelationField', () => {
it('returns the correlation field of the model', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
expect(Users.getCorrelationField()).toEqual('user_id');
});
});
describe('#getCollectionName', () => {
it('returns the collection name of the states collection', async () => {
const Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
expect(Users.getCollectionName()).toEqual('users');
});
});
describe('#rollback', () => {
let Users;
beforeEach(async () => {
Users = GenericFactory(DEFAULT_DEFINITION, reducer, {
CORRELATION_FIELD: 'user_id',
SCHEMA: schemas,
ORIGINAL_SCHEMA,
MODEL_CONFIG,
services,
});
await Promise.all([
Users.getStatesCollection(Users.db(mongodb)).deleteMany({}),
Users.getEventsCollection(Users.db(mongodb)).deleteMany({}),
Users.getSnapshotsCollection(Users.db(mongodb)).deleteMany({}),
]);
await Users.getStatesCollection(Users.db(mongodb)).createIndex(
{ firstname: 1, lastname: 1 },
{ name: 'firstname_lastname_unicity', unique: true },
);
});
afterEach(async () => {
await Users.getStatesCollection(Users.db(mongodb)).dropIndex(
'firstname_lastname_unicity',
);
});
it('rollbacks a newly created model not matching database constraints (events removed from the db) and throws an error', async () => {
const alice = new Users(services);
await alice.create({ firstname: 'Alice', lastname: 'Doe' });
const alice2 = new Users(services);
let error;
try {
await alice2.create({ firstname: 'Alice', lastname: 'Doe' });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain('E11000 duplicate key error collection');
expect(alice.state).toMatchObject({
firstname: 'Alice',
lastname: 'Doe',
});
expect(alice2.state).toEqual(null);
const states = await Users.getStatesCollection(Users.db(mongodb))
.find({})
.toArray();
expect(states.length).toEqual(1);
expect(states[0]).toMatchObject({
firstname: 'Alice',
lastname: 'Doe',
user_id: alice.correlationId,
});
const events = await Users.getEventsCollection(Users.db(mongodb))
.find({})
.toArray();
expect(events.length).toEqual(1);