@getanthill/datastore
Version:
Event-Sourced Datastore
448 lines (386 loc) • 10.5 kB
text/typescript
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);
});
});
});