UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

448 lines (386 loc) 10.5 kB
import { merge } from 'lodash'; import assert from 'assert'; import reducerFactory, { defaultReducer } from './reducer'; import FullyHomomorphicEncryptionClient from '../services/fhe'; describe('models/reducer', () => { 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', }, }, }, }, ANOTHER_CREATED: { '0_0_0': { is_created: true, type: 'object', properties: { name: { type: 'string', }, }, }, }, }, }; let schemas; let reducer; beforeEach(() => { schemas = merge({}, DEFAULT_SCHEMAS); reducer = reducerFactory(schemas); const constantDate = new Date('2020-11-10T00:00:00.000Z'); // @ts-ignore global.Date = class extends Date { constructor() { super(); return constantDate; } }; }); afterEach(() => { jest.restoreAllMocks(); }); it('throws an exception if trying to create an already existing entity', async () => { let error; try { await reducer( {}, { type: 'CREATED', v: '0_0_0', }, ); } catch (err) { error = err; } expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty('message', 'Entity already created'); }); it('throws an exception if trying to create an already existing entity with custom creation event', async () => { let error; try { await reducer( {}, { type: 'ANOTHER_CREATED', v: '0_0_0', }, ); } catch (err) { error = err; } expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty('message', 'Entity already created'); }); it('throws an exception if trying to work on a non created entity', async () => { let error; try { await reducer(null, { type: 'UPDATED', v: '0_0_0', }); } catch (err) { error = err; } expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty('message', 'Entity must be created first'); }); it('throws an exception if the event type is not supported by the schema', async () => { let error; try { await reducer(null, { type: 'UNKNOWN', v: '0_0_0', }); } catch (err) { error = err; } expect(error).toBeInstanceOf(assert.AssertionError); expect(error.message).toEqual('Invalid event type'); }); it('calls the handler if defined in the schema for this event', async () => { schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { handler: jest.fn().mockImplementation((state, event) => [ { ...state, ...event, hello: 'world', }, ]), }, }, }, }); reducer = reducerFactory(schemas); const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); expect(updatedState).toMatchObject({ hello: 'world', name: 'John', }); expect(schemas.events.CREATED['0_0_0'].handler).toHaveBeenCalledTimes(1); }); it('calls the handler defined as code in the model schema', async () => { schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { handler: ` return [{ ...state, ...event, hello: 'world', }, ];`, }, }, }, }); reducer = reducerFactory(schemas); const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); expect(updatedState).toMatchObject({ hello: 'world', name: 'John', }); }); it('throws an error in case of invalid handler', async () => { schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { handler: ` throw new Error('Ooops'); return [{ ...state, ...event, hello: 'world', }, ];`, }, }, }, }); reducer = reducerFactory(schemas); let error; try { await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); } catch (err) { error = err; } expect(error.message).toEqual('Ooops'); }); it('throws an error in case of invalid handler code', async () => { schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { handler: ` this } is cleary invalid`, }, }, }, }); reducer = reducerFactory(schemas); let error; try { await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); } catch (err) { error = err; } expect(error.message).toEqual("Unexpected identifier 'cleary'"); }); it('calls the handler defined as code in the model schema (fhe)', async () => { const fhe = new FullyHomomorphicEncryptionClient({}); await fhe.connect(); schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { is_fhe: true, handler: ` const value = (state?.value ?? 0) + 2; return [{ ...state, value, }, ];`, }, }, }, }); reducer = reducerFactory(schemas, { fhe, // @ts-expect-error Partial values used only modelConfig: { with_fully_homomorphic_encryption: true, fhe_public_key_field: fhe.keys!.public.save(), }, }); const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', value: 1, }); const cipher = fhe.seal.CipherText(); cipher.load(fhe.context!, updatedState.value); expect(fhe.fromCypher(cipher).slice(0, 1)).toEqual([2]); }); it('returns the updated state with default event reducer', async () => { const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); expect(updatedState).toEqual({ name: 'John', created_at: new Date('2020-11-10T00:00:00.000Z'), updated_at: new Date('2020-11-10T00:00:00.000Z'), }); }); it('returns the updated state with default event reducer on array', async () => { const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', locales: ['fr', 'en'], }); expect(updatedState).toEqual({ locales: ['fr', 'en'], created_at: new Date('2020-11-10T00:00:00.000Z'), updated_at: new Date('2020-11-10T00:00:00.000Z'), }); }); it('returns the updated state with default event reducer with array replacement', async () => { const updatedState = await reducer( { locales: ['fr', 'en'], }, { type: 'UPDATED', v: '0_0_0', locales: [], }, ); expect(updatedState).toEqual({ locales: [], created_at: new Date('2020-11-10T00:00:00.000Z'), updated_at: new Date('2020-11-10T00:00:00.000Z'), }); }); it('allows to create a state on custom creation event', async () => { const updatedState = await reducer(null, { type: 'ANOTHER_CREATED', v: '0_0_0', name: 'John', }); expect(updatedState).toEqual({ name: 'John', created_at: new Date('2020-11-10T00:00:00.000Z'), updated_at: new Date('2020-11-10T00:00:00.000Z'), }); }); describe('#defaultReducer', () => { it('adds any available field to the state', async () => { expect(defaultReducer({}, { a: 1 })).toMatchObject({ a: 1, }); }); it('adds created_at to non initialized states', async () => { const state = defaultReducer(null, { a: 1 }); expect(state).toHaveProperty('created_at'); expect(state.created_at).toEqual(new Date('2020-11-10T00:00:00.000Z')); }); it('adds updated_at to non initialized states', async () => { const state = defaultReducer(null, { a: 1 }); expect(state).toHaveProperty('updated_at'); expect(state.updated_at).toEqual(new Date('2020-11-10T00:00:00.000Z')); }); it('adds updated_at to already initialized states', async () => { const state = defaultReducer( { created_at: 1, updated_at: 2, }, { a: 1 }, ); expect(state).toHaveProperty('updated_at'); expect(state.updated_at).toEqual(new Date('2020-11-10T00:00:00.000Z')); }); }); describe('JSON PATCH', () => { it('applies JSON PATCH from event definition', async () => { const state = await reducer(null, { type: 'CREATED', v: '0_0_0', json_patch: [{ op: 'add', path: '/hello', value: 'world' }], }); expect(state).toMatchObject({ hello: 'world', }); expect(state).not.toHaveProperty('json_patch'); }); it('applies JSON PATCH from handler result', async () => { schemas = merge({}, DEFAULT_SCHEMAS, { events: { CREATED: { '0_0_0': { handler: jest.fn().mockImplementation((state, event) => [ { ...state, ...event, }, [{ op: 'add', path: '/hello', value: 'world' }], ]), }, }, }, }); reducer = reducerFactory(schemas); const updatedState = await reducer(null, { type: 'CREATED', v: '0_0_0', name: 'John', }); expect(updatedState).toMatchObject({ hello: 'world', name: 'John', }); expect(schemas.events.CREATED['0_0_0'].handler).toHaveBeenCalledTimes(1); }); }); });