UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

1,078 lines (902 loc) 26.4 kB
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, }, ); }); }); });