UNPKG

octonom

Version:

Object Document Mapper for any database

405 lines (350 loc) 15.8 kB
import { spy } from 'sinon'; import { ArrayCollection } from '../array-collection'; import { SanitizationError, ValidationError } from '../errors'; import { Model } from '../model'; import { ObjectSchema, populateObject, proxifyObject, setObject, toObject, validateObject, } from './object'; import { ReferenceSchema } from './reference'; import { ISchemaParent, ISchemaParentInstance, SchemaInstanceMap, SchemaMap } from './schema'; import { StringSchema } from './string'; class ReferencedModel extends Model { public id: string; } ReferencedModel.setSchema('id', new StringSchema()); const collection = new ArrayCollection<ReferencedModel>(ReferencedModel, {modelIdField: 'id'}); const referencedInstance = new ReferencedModel({id: '0xACAB'}); collection.insert(referencedInstance); interface IOuter { foo?: string | ReferencedModel; bar?: string; } const outerSchemaMap: SchemaMap<IOuter> = { foo: new ReferenceSchema({collection: () => collection}), bar: new StringSchema(), }; describe('helpers', () => { interface IFooBar { foo?: string; bar?: string; } const schemaMap: SchemaMap<IFooBar> = { foo: new StringSchema({required: true, default: 'default'}), bar: new StringSchema(), }; let parentInstance: ISchemaParentInstance; beforeEach(() => { parentInstance = { value: {}, schema: new StringSchema(), beforeChange: spy(), afterChange: spy(), }; }); describe('populateObject()', () => { it('should throw if a key is not in schema.', async () => { await expect(populateObject({}, {}, outerSchemaMap, {baz: true} as any)) .to.be.rejectedWith(Error, 'Key baz not found in schema.'); }); it('should throw if a key is not in schema.', async () => { await expect(populateObject({bar: 'foo'}, {bar: outerSchemaMap.bar.create('foo')}, outerSchemaMap, {bar: true})) .to.be.rejectedWith(Error, 'Key bar is not populatable.'); }); it('should ignore a key if it is undefined', async () => { const obj: IOuter = {bar: 'foo'}; const result = await populateObject(obj, {bar: outerSchemaMap.bar.create('foo')}, outerSchemaMap, {foo: true}); expect(result).to.equal(obj).and.to.eql({bar: 'foo'}); }); it('should populate a key', async () => { const obj = {foo: '0xACAB', bar: 'test'}; const instanceMap = { foo: outerSchemaMap.foo.create('0xACAB'), bar: outerSchemaMap.bar.create('test'), }; const result = await populateObject(obj, instanceMap, outerSchemaMap, {foo: true}); expect(result).to.equal(obj).and.to.eql({foo: {id: '0xACAB'}, bar: 'test'}); expect(result.foo).to.be.an.instanceOf(ReferencedModel); }); }); describe('proxifyObject()', () => { it('should get a value for a key', () => { const obj = {foo: 'bar'}; const fooInstance = schemaMap.foo.create('bar'); const instanceMap: SchemaInstanceMap<IFooBar> = {foo: fooInstance}; const proxy = proxifyObject<IFooBar>(obj, instanceMap, parentInstance, schemaMap); expect(proxy.foo).to.equal('bar'); }); it('should assign a value to a key', () => { const obj = {}; const instanceMap: SchemaInstanceMap<IFooBar> = {}; const proxy = proxifyObject<IFooBar>(obj, instanceMap, parentInstance, schemaMap); proxy.foo = 'bar'; expect(obj).to.eql({foo: 'bar'}); expect(instanceMap).to.eql({ foo: {value: 'bar', parent: {instance: parentInstance, path: 'foo'}, schema: schemaMap.foo}, }); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith([], {foo: 'bar'}, parentInstance); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith([], {foo: 'bar'}, parentInstance); }); it('should delete a key', () => { const obj = {foo: 'bar'}; const fooInstance = schemaMap.foo.create('bar'); const instanceMap: SchemaInstanceMap<IFooBar> = {foo: fooInstance}; const proxy = proxifyObject<IFooBar>(obj, instanceMap, parentInstance, schemaMap); delete proxy.foo; expect(obj).to.eql({}); expect(instanceMap).to.eql({}); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith([], {foo: undefined}, parentInstance); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith([], {foo: undefined}, parentInstance); expect(fooInstance).to.not.have.property('parent'); }); }); describe('setObject', () => { it('should throw if key not in schema', () => { expect(() => setObject({baz: 'foo'}, {}, {}, parentInstance, schemaMap, {})) .to.throw(SanitizationError, 'Key baz not found in schema.'); }); it('should throw if a nested schema sanitization throws', () => { expect(() => setObject({bar: 42}, {}, {}, parentInstance, schemaMap, {})) .to.throw(SanitizationError, 'Value is not a string.'); }); it('should do nothing with an empty data object', () => { const obj = {}; const octoValueMap: SchemaInstanceMap = {}; setObject({}, obj, octoValueMap, parentInstance, schemaMap, {}); expect(obj).to.eql({}); expect(octoValueMap).to.eql({}); }); it('should set a valid property', () => { const obj = {}; const instanceMap: SchemaInstanceMap<IFooBar> = {}; setObject({foo: 'bar'}, obj, instanceMap, parentInstance, schemaMap, {}); expect(obj).to.eql({foo: 'bar'}); expect(instanceMap).to.eql({ foo: {value: 'bar', schema: schemaMap.foo, parent: {instance: parentInstance, path: 'foo'}}, }); }); it('should not modify an existing property', () => { const obj = {bar: 'baz'}; const barInstance = {value: 'baz', schema: schemaMap.bar, parent: {instance: parentInstance, path: 'bar'}}; const instanceMap: SchemaInstanceMap<IFooBar> = {bar: barInstance}; setObject<IFooBar>({foo: 'bar'}, obj, instanceMap, parentInstance, schemaMap, {}); expect(obj).to.eql({foo: 'bar', bar: 'baz'}); expect(instanceMap.bar).to.equal(barInstance); expect(instanceMap).to.eql({ bar: barInstance, foo: {value: 'bar', schema: schemaMap.foo, parent: {instance: parentInstance, path: 'foo'}}, }); }); it('should set a default value', () => { const obj = {}; const instanceMap: SchemaInstanceMap<IFooBar> = {}; setObject({bar: 'bar'}, obj, instanceMap, parentInstance, schemaMap, {defaults: true}); expect(obj).to.eql({foo: 'default', bar: 'bar'}); expect(instanceMap).to.eql({ bar: {value: 'bar', schema: schemaMap.bar, parent: {instance: parentInstance, path: 'bar'}}, foo: {value: 'default', schema: schemaMap.foo, parent: {instance: parentInstance, path: 'foo'}}, }); }); it('should replace an object', () => { const obj = {bar: 'baz'}; const barInstance = {value: 'baz', schema: schemaMap.bar, parent: {instance: parentInstance, path: 'bar'}}; const instanceMap: SchemaInstanceMap<IFooBar> = {bar: barInstance}; setObject<IFooBar>({foo: 'bar'}, obj, instanceMap, parentInstance, schemaMap, {replace: true}); expect(obj).to.eql({foo: 'bar'}); expect(instanceMap).to.eql({ foo: {value: 'bar', schema: schemaMap.foo, parent: {instance: parentInstance, path: 'foo'}}, }); }); }); describe('toObject()', () => { it('should return an object', () => { const instanceMap: SchemaInstanceMap<IFooBar> = { foo: {value: 'bar', schema: schemaMap.foo, parent: {instance: parentInstance, path: 'foo'}}, }; expect(toObject(instanceMap)).to.eql({foo: 'bar'}); }); }); describe('validateObject()', () => { const instance: ISchemaParentInstance = { value: {}, schema: new StringSchema(), beforeChange: spy(), afterChange: spy(), }; it('should throw if required key is undefined in the instanceMap', async () => { const instanceMap: SchemaInstanceMap<IFooBar> = {}; await expect(validateObject(instanceMap, instance, schemaMap)) .to.be.rejectedWith(ValidationError, 'Key foo is required.'); }); it('should pass for a valid object', async () => { const instanceMap: SchemaInstanceMap = { foo: schemaMap.foo.create('bar', {parent: {instance: parentInstance, path: 'foo'}}), }; await validateObject(instanceMap, instance, schemaMap); }); }); }); describe('ObjectSchema', () => { let parentInstance: ISchemaParentInstance; let parent: ISchemaParent = {instance: parentInstance, path: 'path'}; beforeEach(() => { parentInstance = { value: {}, schema: new StringSchema(), beforeChange: spy(), afterChange: spy(), }; parent = {instance: parentInstance, path: 'path'}; }); describe('constructor()', () => { it('should be instantiatable with options', () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema()}}); expect(schema).to.have.property('options').that.eql({schemaMap: {foo: {options: {}}}}); }); }); describe('create()', () => { // note: the constructor wraps setObject() (see above) it('should throw a SanitizationError if value is not an object', () => { const schema = new ObjectSchema({schemaMap: {}}); expect(() => schema.create(42)) .to.throw(SanitizationError, 'Value is not an object.'); }); it('should return an undefined for undefined input', () => { const schema = new ObjectSchema({schemaMap: {}}); expect(schema.create(undefined)).to.equal(undefined); }); it('should return an empty object for undefined if required and defaults', () => { const schema = new ObjectSchema({schemaMap: {}, required: true}); expect(schema.create(undefined, {defaults: true, parent})) .to.have.property('value').that.eql({}); }); it('should return an instance with an object for undefined if required and defaults', () => { const schema = new ObjectSchema({ required: true, schemaMap: {foo: new StringSchema({default: 'bar'})}, }); expect(schema.create(undefined, {defaults: true})) .to.have.property('value').that.eql({foo: 'bar'}); }); it('should return an instance with an object', () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema()}}); expect(schema.create({foo: 'bar'})) .to.have.property('value').that.eql({foo: 'bar'}); }); describe('proxy', () => { interface IProxyInner { bar?: string; } interface IProxyOuter { foo?: string; nested?: IProxyInner; } const schema = new ObjectSchema<IProxyOuter>({ schemaMap: { foo: new StringSchema(), nested: new ObjectSchema<IProxyInner>({ schemaMap: {bar: new StringSchema()}, }), }, }); it('should intercept assigning a value to a key', () => { const instance = schema.create({}, {parent}); const obj = instance.value; obj.foo = 'bar'; expect(instance.value).to.eql({foo: 'bar'}); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith(['path'], {foo: 'bar'}, instance); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith(['path'], {foo: 'bar'}, instance); }); it('should intercept deleting a key', () => { const instance = schema.create({foo: 'bar'}, {parent}); const obj = instance.value; delete obj.foo; expect(instance.value).to.eql({}); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith(['path'], {foo: undefined}, instance); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith(['path'], {foo: undefined}, instance); }); it('should intercept assigning a value to a nested key', () => { const instance = schema.create({nested: {}}, {parent}); const obj = instance.value; obj.nested.bar = 'baz'; expect(instance.value).to.eql({nested: {bar: 'baz'}}); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith(['path', 'nested'], {bar: 'baz'}, instance.instanceMap.nested); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith(['path', 'nested'], {bar: 'baz'}, instance.instanceMap.nested); }); it('should intercept assigning a value to a nested key', () => { const instance = schema.create({nested: {bar: 'baz'}}, {parent}); const obj = instance.value; delete obj.nested.bar; expect(instance.value).to.eql({nested: {}}); expect(parentInstance.beforeChange).to.be.calledOnce.and .calledWith(['path', 'nested'], {bar: undefined}, instance.instanceMap.nested); expect(parentInstance.afterChange).to.be.calledOnce.and .calledWith(['path', 'nested'], {bar: undefined}, instance.instanceMap.nested); }); }); }); describe('populate()', () => { // note: OctoObject.populate() wraps populateObject() (see above) it('should throw if populateReference is not an object', async () => { const schema = new ObjectSchema<IOuter>({schemaMap: outerSchemaMap}); await expect(schema.populate(schema.create({}), true)) .to.be.rejectedWith('populateReference must be an object'); }); it('should populate a reference', async () => { const schema = new ObjectSchema<IOuter>({schemaMap: outerSchemaMap}); const instance = schema.create({foo: '0xACAB', bar: 'test'}); const result = await schema.populate(instance, {foo: true}); expect(result).to.equal(instance.value); expect(result).to.eql({foo: {id: '0xACAB'}, bar: 'test'}); expect(result.foo).to.be.an.instanceOf(ReferencedModel); }); }); describe('toObject()', () => { // note: OctoObject.toObject() wraps toObject() (see above) it('should return an object', () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema()}}); const instance = schema.create({foo: 'bar'}); expect(schema.toObject(instance)).to.eql({foo: 'bar'}).and.not.equal(instance.value); }); }); describe('validate()', () => { it('should throw a ValidationError if nested value is required but undefined', async () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema({required: true})}}); await expect(schema.validate(schema.create({}))) .to.be.rejectedWith(ValidationError, 'Key foo is required.'); }); it('should throw a ValidationError if nested validation fails', async () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema({enum: ['bar']})}}); await expect(schema.validate(schema.create({foo: 'baz'}))) .to.be.rejectedWith(ValidationError, 'String not in enum: bar.'); }); it('should run custom validator', async () => { const schema = new ObjectSchema<{foo: string}>({ schemaMap: {foo: new StringSchema()}, validate: async instance => { if (instance.value.foo === 'bar') { throw new ValidationError('bar is not allowed.'); } }, }); await schema.validate(schema.create({foo: 'baz'})); await expect(schema.validate(schema.create({foo: 'bar'}))) .to.be.rejectedWith(ValidationError, 'bar is not allowed.'); }); it('should validate an object', async () => { const schema = new ObjectSchema({schemaMap: {foo: new StringSchema()}}); await schema.validate(schema.create({foo: 'bar'})); }); }); });