@getanthill/datastore
Version:
Event-Sourced Datastore
1,078 lines (902 loc) • 26.4 kB
text/typescript
import type { Services } from '../typings';
import { MongoDbConnector } from '@getanthill/mongodb-connector';
import usersModel from '../../test/fixtures/users';
import { init, Models } from '.';
import setup from '../../test/setup';
import merge from 'lodash/merge';
describe('models', () => {
let app;
let services: Services;
let mongodb: MongoDbConnector;
beforeAll(async () => {
app = await setup.build();
services = app.services;
mongodb = services.mongodb;
});
afterEach(async () => {
jest.restoreAllMocks();
await setup.cleanModels(services);
});
afterAll(async () => {
await setup.teardownDb(services.mongodb);
});
describe('#loadModels', () => {
let models;
beforeEach(async () => {
models = init({ models: [] }, services);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('registers a new model', () => {
jest.spyOn(models, 'addModel');
jest.spyOn(models, 'removeModel');
models.loadModels([{ is_enabled: true, name: 'users' }]);
expect(models.removeModel).toHaveBeenCalledTimes(0);
expect(models.addModel).toHaveBeenCalledWith(
{
is_enabled: true,
name: 'users',
},
undefined,
);
});
it('removes models without the property `is_enabled=true`', () => {
jest.spyOn(models, 'addModel');
jest.spyOn(models, 'removeModel');
models.loadModels([{ name: 'users' }]);
expect(models.addModel).toHaveBeenCalledTimes(0);
expect(models.removeModel).toHaveBeenCalledWith('users');
});
});
describe('#load / #reload', () => {
let models;
beforeEach(async () => {
models = init({ models: [] }, services);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls reload on load', async () => {
jest.spyOn(models, 'reload').mockImplementation(() => null);
await models.load();
expect(models.reload).toHaveBeenCalledWith(false);
});
it('loads non internal models only', async () => {
const internalModel = {
find: jest
.fn()
.mockReturnValue({ toArray: jest.fn().mockReturnValue([]) }),
};
jest.spyOn(models, 'getModel').mockReturnValue(internalModel);
await models.load();
expect(internalModel.find).toHaveBeenCalledWith(services.mongodb, {
is_enabled: true,
name: {
$nin: ['internal_models', '_logs'],
},
});
});
it('loads only models selected in configuration if set', async () => {
models = init(
{ models: [] },
{
...services,
config: {
...services.config,
features: {
...services.config.features,
loadOnlyModels: ['users'],
},
},
},
);
const internalModel = {
find: jest
.fn()
.mockReturnValue({ toArray: jest.fn().mockReturnValue([]) }),
};
jest.spyOn(models, 'getModel').mockReturnValue(internalModel);
await models.load();
expect(internalModel.find).toHaveBeenCalledWith(services.mongodb, {
is_enabled: true,
name: {
$in: ['users'],
$nin: ['internal_models', '_logs'],
},
});
});
it('loads new models', async () => {
const internalModel = {
find: jest
.fn()
.mockReturnValue({ toArray: jest.fn().mockReturnValue([]) }),
};
jest.spyOn(models, 'getModel').mockReturnValue(internalModel);
jest.spyOn(models, 'reset');
jest.spyOn(models, 'loadModels');
await models.load();
expect(models.loadModels).toHaveBeenCalledTimes(1);
});
});
describe('#createModel', () => {
let models;
beforeEach(async () => {
models = init({ models: [] }, services);
await models.initInternalModels();
});
it('creates a new model in the database', async () => {
const createdModel = await models.createModel(usersModel);
expect(createdModel.state).toMatchObject({
name: 'users',
});
});
it('creates a new model in the database but does not activate it if is_enabled=false', async () => {
const createdModel = await models.createModel({
...usersModel,
is_enabled: false,
});
expect(createdModel.state).toMatchObject({
is_enabled: false,
name: 'users',
});
expect(models.MODELS.get('users')).toEqual(undefined);
});
it('throws an error if a model with the same name is already registered', async () => {
let error;
await models.createModel(usersModel);
try {
await models.createModel(usersModel);
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('Model already exists');
});
it('rollbacks the model if not persisted in the database', async () => {
models.factory = jest.fn().mockImplementationOnce(() => {
throw new Error('Oooops');
});
let error;
try {
await models.createModel(usersModel);
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('Oooops');
expect(models.hasModel(usersModel.name)).toEqual(false);
});
});
describe('#updateModel', () => {
let models;
beforeEach(async () => {
models = init({ models: [] }, services);
await models.createModel(usersModel);
});
it('updates the model in the database', async () => {
const updatedModel = await models.updateModel(usersModel.name, {
correlation_field: 'users_super_id',
});
expect(updatedModel.state).toMatchObject({
correlation_field: 'users_super_id',
version: 1,
});
});
it('updates the model in the database with updated indexes', async () => {
const updatedModel = await models.updateModel(usersModel.name, {
correlation_field: 'users_super_id',
indexes: [
...(usersModel.indexes ?? []),
{
collection: 'users_events',
fields: { email: 1 },
opts: { name: 'email', unique: true, sparse: true },
},
],
});
expect(updatedModel.state).toMatchObject({
correlation_field: 'users_super_id',
version: 1,
});
});
it('returns an error if the model is invalid', async () => {
let error;
try {
await models.updateModel('invalid_mode', {
correlation_field: 'invalid_id',
});
} catch (err) {
error = err;
}
expect(error).not.toBe(null);
expect(error.message).toEqual('Invalid Model');
});
it('throws a validation error in case of invalid update', async () => {
let error;
try {
await models.updateModel(usersModel.name, {
correlation_field: 123,
});
} catch (err) {
error = err;
}
expect(error).not.toBe(null);
expect(error.message).toEqual('Event schema validation error');
expect(error.details).toMatchObject([
{
instancePath: '/correlation_field',
keyword: 'type',
message: 'must be string',
params: { type: 'string' },
schemaPath: '#/properties/correlation_field/type',
},
{
event: {
correlation_field: 123,
},
},
]);
});
});
describe('#createModelIndexes', () => {
let models;
let fixtureModel = {
...usersModel,
is_enabled: true,
name: 'special_indexes',
indexes: [
{
collection: 'special_indexes',
fields: { firstname: 1, lastname: 1 },
opts: { name: 'firstname_lastname_unicity', unique: true },
},
],
};
beforeEach(async () => {
models = init({ models: [] }, services);
await models.createModel(fixtureModel);
await models.reload();
});
it('is safe on models without indexes defined', async () => {
const fixtureModelWithoutIndex = {
...fixtureModel,
name: 'special_indexes_1',
};
delete fixtureModelWithoutIndex.indexes;
await models.createModel(fixtureModelWithoutIndex);
await models.reload();
const indexes = await models.createModelIndexes(fixtureModelWithoutIndex);
expect(indexes).toHaveLength(3);
});
it('does not create indexes already initialized', async () => {
await models.createModelIndexes(fixtureModel);
await models.createModelIndexes(fixtureModel);
expect(
global.debugMock.mock.calls.findIndex(
(c) =>
c[0] === '[Models] Index already initialized. No action performed.',
),
).toBeGreaterThan(-1);
});
it('does not create indexes already defined', async () => {
const fixtureModelWithDuplicatedIndexes = {
...fixtureModel,
name: 'special_indexes_2',
indexes: [
{
collection: 'special_indexes_2',
fields: { firstname: 1, lastname: 1 },
opts: { name: 'firstname_lastname_unicity', unique: true },
},
{
collection: 'special_indexes_2',
fields: { firstname: 1, lastname: 1 },
opts: { name: 'firstname_lastname_unicity', unique: true },
},
],
};
await models.createModel(fixtureModelWithDuplicatedIndexes);
await models.reload();
await models.createModelIndexes(fixtureModelWithDuplicatedIndexes);
expect(
global.debugMock.mock.calls.findIndex(
(c) =>
c[0] ===
'[Models] Index already seen and initialized (duplicate). No action performed.',
),
).toBeGreaterThan(-1);
});
it('creates the indexes as defined in the model configuration', async () => {
const indexes = await models.createModelIndexes(fixtureModel);
expect(indexes[0]).toEqual(
expect.arrayContaining([
{ key: { _id: 1 }, name: '_id_', v: 2 },
{
key: { user_id: 1 },
name: 'correlation_id_unicity',
unique: true,
v: 2,
},
{
key: { firstname: 1, lastname: 1 },
name: 'firstname_lastname_unicity',
unique: true,
v: 2,
},
{ key: { _id: 1, created_at: 1 }, name: 'created_at_1', v: 2 },
{ key: { _id: 1, version: 1 }, name: 'version_1', v: 2 },
]),
);
expect(indexes[1]).toEqual(
expect.arrayContaining([
{ key: { _id: 1 }, name: '_id_', v: 2 },
{ key: { _id: 1, created_at: 1 }, name: 'created_at_1', v: 2 },
{
key: { _id: 1, created_at: 1, version: 1 },
name: 'version_1_created_at_1',
v: 2,
},
{ key: { _id: 1, version: 1 }, name: 'version_1', v: 2 },
{
key: { user_id: 1, version: 1 },
name: 'correlation_id_version_unicity',
unique: true,
v: 2,
},
]),
);
expect(indexes[2]).toEqual(
expect.arrayContaining([
{ key: { _id: 1 }, name: '_id_', v: 2 },
{
key: { user_id: 1, version: 1 },
name: 'correlation_id_version_unicity',
unique: true,
v: 2,
},
]),
);
const alice = await models.factory('special_indexes');
await alice.create({ firstname: 'alice', lastname: 'doe' });
const bernard = await models.factory('special_indexes');
let errorBernard;
try {
await bernard.create({ firstname: 'alice', lastname: 'doe' });
} catch (err) {
errorBernard = err;
}
const eve = await models.factory('special_indexes');
await eve.create({ firstname: 'eve', lastname: 'doe' });
let errorEve;
try {
await eve.update({ firstname: 'alice', phone: { number: '+336' } });
} catch (err) {
errorEve = err;
}
expect(bernard.state).toEqual(null);
expect(errorBernard).toBeInstanceOf(Error);
expect(errorBernard.message).toContain(
'E11000 duplicate key error collection',
);
expect(eve.state).toMatchObject({
firstname: 'eve',
lastname: 'doe',
});
expect(eve.state).not.toHaveProperty('phone');
expect(errorEve).toBeInstanceOf(Error);
expect(errorEve.message).toContain(
'E11000 duplicate key error collection',
);
});
it('creates the ttl indexes in events collection without error', async () => {
const modelConfig = {
...fixtureModel,
indexes: [
{
collection: 'special_indexes_events',
fields: { expired_at: 1 },
opts: {
name: 'expired_at_ttl',
expireAfterSeconds: 0,
background: false,
},
},
...fixtureModel.indexes,
],
};
await models.createModelIndexes(modelConfig);
await new Promise((resolve) => setTimeout(resolve, 2500));
const indexes = await models.getModelIndexes(
models.getModel(modelConfig.name),
);
expect(indexes.length).toEqual(3);
/**
* @fixme this test works correctly in local but does
* not pass in Gitlab CI... ttl index is not created
* or present in the response of the getModelIndexes.
*/
expect(indexes[1]).toEqual(
expect.arrayContaining([
{ key: { _id: 1 }, name: '_id_', v: 2 },
{ key: { _id: 1, created_at: 1 }, name: 'created_at_1', v: 2 },
{
key: { _id: 1, created_at: 1, version: 1 },
name: 'version_1_created_at_1',
v: 2,
},
{ key: { _id: 1, version: 1 }, name: 'version_1', v: 2 },
{
key: { user_id: 1, version: 1 },
name: 'correlation_id_version_unicity',
unique: true,
v: 2,
},
]),
);
}, 10000);
});
describe('#addModel', () => {
it('adds a new model to the list if not registered already', () => {
const models = init(
{
models: [],
},
services,
);
models.addModel(usersModel);
expect(models).toBeInstanceOf(Models);
expect(models.getModel('users')).not.toBe(null);
});
it('throws an error if a model with the same name is already registered', () => {
const models = init(
{
models: [],
},
services,
);
models.addModel(usersModel);
let error;
try {
models.addModel(usersModel);
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('Model already exists');
});
});
describe('#hasModel', () => {
it('returns true if the model is registered', () => {
const models = init(
{
models: [],
},
services,
);
expect(models.hasModel('users')).toEqual(false);
models.addModel(usersModel);
expect(models.hasModel('users')).toEqual(true);
});
});
describe('#removeModel', () => {
it('removes a model from the managed list', () => {
const models = init(
{
models: [],
},
services,
);
models.addModel(usersModel);
expect(models.hasModel('users')).toEqual(true);
models.removeModel('users');
expect(models.hasModel('users')).toEqual(false);
});
});
describe('#init', () => {
it('initializes the Datastore models from configuration', () => {
const models = init(
{
models: [usersModel],
},
services,
);
expect(models).toBeInstanceOf(Models);
expect(models.getModel('users')).not.toBe(null);
});
});
describe('#reset', () => {
it('resets all models', () => {
const models = init(
{
models: [usersModel],
},
services,
);
models.reset();
expect(models.MODELS).toEqual(
new Map([
['_cache', models.getModel('_cache')],
['_logs', models.getModel('_logs')],
['internal_models', models.getModel('internal_models')],
]),
);
});
it('resets all models with authorization ones', () => {
const models = init(
{
models: [usersModel],
},
{
...services,
config: {
...services.config,
authz: {
isEnabled: true,
},
},
},
);
models.reset();
expect(models.MODELS).toEqual(
new Map([
['attributes', models.getModel('attributes')],
['_cache', models.getModel('_cache')],
['_logs', models.getModel('_logs')],
['policies', models.getModel('policies')],
['internal_models', models.getModel('internal_models')],
]),
);
});
});
describe('#getModel', () => {
it('returns a valid operational model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const Users = models.getModel('users');
expect(Users).not.toEqual(null);
const user = new Users(services);
await user.create({
firstname: 'John',
lastname: 'Doe',
});
expect(user.state).toMatchObject({
firstname: 'John',
lastname: 'Doe',
});
});
it('throws an exception on try to get a non existing model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
let error;
try {
models.getModel('accounts');
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
expect(error.message).toEqual('Invalid Model');
});
});
describe('#getModelByCorrelationField', () => {
it('returns a valid operational model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const Users = models.getModelByCorrelationField('user_id');
expect(Users).not.toEqual(null);
const user = new Users(services);
await user.create({
firstname: 'John',
lastname: 'Doe',
});
expect(user.state).toMatchObject({
firstname: 'John',
lastname: 'Doe',
});
});
it('returns null if no model is matching this correlation_field', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const Users = models.getModelByCorrelationField('invalid_id');
expect(Users).toEqual(null);
});
});
describe('#factory', () => {
it('returns an instance of a pre-configured model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const user = models.factory('users');
await user.create({
firstname: 'John',
lastname: 'Doe',
});
expect(user.state).toMatchObject({
firstname: 'John',
lastname: 'Doe',
});
});
it('returns an instance with correlation_id set of a pre-configured model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const user = models.factory('users');
await user.create({
firstname: 'John',
lastname: 'Doe',
});
const other = models.factory('users', user.state.user_id);
await other.getState();
expect(other.state).toMatchObject({
firstname: 'John',
lastname: 'Doe',
});
});
it('returns an instance with correlation_id as string set of a pre-configured model', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const user = models.factory('users');
await user.create({
firstname: 'John',
lastname: 'Doe',
});
const other = models.factory('users', user.state.user_id.toString());
await other.getState();
expect(other.state).toMatchObject({
firstname: 'John',
lastname: 'Doe',
});
});
});
describe('#log', () => {
it('stores a log into the internal collection', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const log = await models.log(
10,
'users',
'correlation_id',
'[decrypt] Entity decrypted',
{},
);
expect(log?.state).toMatchObject({
level: 10,
model: 'users',
correlation_id: 'correlation_id',
message: '[decrypt] Entity decrypted',
context: {},
version: 0,
});
});
it('handles the error internally without raising exception', async () => {
const models = init(
{
models: [usersModel],
},
services,
);
const error = new Error('Ooops');
jest.spyOn(models.services.telemetry.logger, 'error');
jest.spyOn(models, 'getModel').mockImplementation(() => {
return class LogMock {
async create() {
throw error;
}
};
});
const log = await models.log(
10,
'users',
'correlation_id',
'[decrypt] Entity decrypted',
{},
);
expect(log).toBeUndefined();
expect(models.services.telemetry.logger.error).toHaveBeenCalledWith(
'[Models] Failed logging...',
{
level: 10,
model: 'users',
message: '[decrypt] Entity decrypted',
err: error,
},
);
});
});
describe('#getFromCache / #setToCache', () => {
it('noops on get if cache is disabled', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: false,
},
},
},
}),
);
const value = await models.getFromCache('key');
expect(value).toEqual(null);
});
it('noops on set if cache is disabled', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: false,
},
},
},
}),
);
const value = await models.setToCache('key', { a: 1 });
expect(value).toEqual(null);
});
it('sets cache entry successfully', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: true,
},
},
},
}),
);
const key = `key_${setup.uuid()}`;
const entry = await models.setToCache(key, { a: 1 });
expect(entry.state).toMatchObject({
cache_id: `ds/${key}`,
value: { a: 1 },
version: 0,
});
});
it('sets cache entry successfully with a given expires_by date', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: true,
},
},
},
}),
);
const key = `key_${setup.uuid()}`;
const entry = await models.setToCache(
key,
{ a: 1 },
'2023-01-01T00:00:00.000Z',
);
expect(entry.state).toMatchObject({
cache_id: `ds/${key}`,
value: { a: 1 },
expires_by: new Date('2023-01-01T00:00:00.000Z'),
version: 0,
});
});
it('returns null if no cache entry is found', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: true,
},
},
},
}),
);
const key = `key_${setup.uuid()}`;
const entry = await models.getFromCache(key);
expect(entry).toEqual(null);
});
it('the cache value if available', async () => {
const models = init(
{
models: [usersModel],
},
merge({}, services, {
config: {
features: {
cache: {
isEnabled: true,
},
},
},
}),
);
const key = `key_${setup.uuid()}`;
await models.setToCache(key, { a: 1 });
const entry = await models.getFromCache(key);
expect(entry).toEqual({ a: 1 });
});
it('is safe on cache creation', async () => {
const telemetry = {
logger: {
error: jest.fn(),
},
};
const models = init(
{
models: [usersModel],
},
merge({}, services, {
telemetry,
config: {
features: {
cache: {
isEnabled: true,
},
},
},
}),
);
const error = new Error('Ooops');
jest.spyOn(models, 'getModel').mockImplementation(() => {
return class Mock {
async upsert() {
throw error;
}
};
});
const key = `key_${setup.uuid()}`;
await models.setToCache(key, { a: 1 });
expect(telemetry.logger.error).toHaveBeenCalledWith(
'[Models] Failed caching...',
{
err: error,
},
);
});
});
});