UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

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