"use strict"; const expect = require("chai").expect; const dm = require("../src"); /* FIXME: Disallow null/nothing/undefined in model definitions, as they make no semantic sense? But allow them for eg. function guards. */ /* FIXME: Test passing an already-guarded collection into a guarded collection factory - either stand-alone or in the context of a model? */ let Event = dm.createType("Event", { description: dm.string() }); let Identity = dm.createType("Identity", { label: dm.string(), nickname: dm.string({ matches: /^[a-z0-9_-]+$/ }), emailAddress: dm.string({ matches: /@/ }), xmppAddress: dm.string({ matches: /@/ }).optional(), notes: dm.string().default("(none)") }); let User = dm.createType("User", { name: dm.string(), isActive: dm.boolean(), registrationDate: dm.instanceOf(Date), misc: dm.either( dm.string(), dm.number() ), // undefinedValue: dm.undefined(), // nullValue: dm.null(), // nothingValue: dm.nothing(), age: dm.number({ minimum: 0 }), luckyNumbers: dm.setOf(dm.number({ minimum: 1, maximum: 42 })), events: dm.arrayOf(Event), identities: dm.setOf(Identity), mainIdentity: Identity, alternateUser: dm.self().optional(), codeWords: dm.mapOf(dm.string(), dm.string()) }); function generateUserData(props = {}) { return Object.assign({}, { name: "Sven Slootweg", isActive: true, registrationDate: new Date(), age: 26, luckyNumbers: new Set([13, 42]), misc: 42, // nullValue: null, // undefinedValue: undefined, // nothingValue: undefined, events: [ Event({ description: "The user was created" }) ], identities: new Set([ Identity({ label: "primary", nickname: "joepie91", emailAddress: "admin@cryto.net", xmppAddress: "joepie91@neko.im", notes: "Main nickname" }), Identity({ label: "bot", nickname: "botpie91", emailAddress: "botpie91@cryto.net" }) ]), mainIdentity: Identity({ label: "primary", nickname: "joepie91", emailAddress: "admin@cryto.net", xmppAddress: "joepie91@neko.im", notes: "Main nickname" }), codeWords: new Map([ ["foo", "Foo"], ["bar", "Bar"] ]) }, props); } function compareObject(reference, obj) { if (typeof reference === "object") { for (let [key, value] of Object.entries(reference)) { if (value instanceof Set) { let objValues = Array.from(obj[key]); let referenceValues = Array.from(value); referenceValues.forEach((item, i) => { compareObject(item, objValues[i]); }); } else if (value instanceof Map) { for (let [mapKey, item] of value.entries()) { compareObject(item, obj[key].get(mapKey)); } } else if (Array.isArray(value)) { value.forEach((item, i) => { compareObject(item, obj[key][i]); }); } else if (typeof reference === "object") { compareObject(value, obj[key]); } else { expect(obj[key]).to.equal(value); } } } else { expect(obj).to.equal(reference); } } describe("models", () => { describe("valid cases", () => { let userInstance; let date = new Date(); it("should succeed at creating valid objects", () => { userInstance = User(generateUserData({ registrationDate: date })); }); it("should have the correct properties", () => { compareObject({ name: "Sven Slootweg", isActive: true, registrationDate: date, age: 26, luckyNumbers: new Set([13, 42]), misc: 42, events: [{ description: "The user was created" }], identities: new Set([{ label: "primary", nickname: "joepie91", emailAddress: "admin@cryto.net", xmppAddress: "joepie91@neko.im", notes: "Main nickname" }, { label: "bot", nickname: "botpie91", emailAddress: "botpie91@cryto.net", notes: "(none)" }]), mainIdentity: { label: "primary", nickname: "joepie91", emailAddress: "admin@cryto.net", xmppAddress: "joepie91@neko.im", notes: "Main nickname" }, codeWords: new Map([ ["foo", "Foo"], ["bar", "Bar"] ]) }, userInstance); }); it("should allow extraneous properties", () => { let instance = User(generateUserData({ extraneousProperty: 42 })); expect(instance.extraneousProperty).to.equal(42); }); it("should allow a null-typed value for nothingValue as well", () => { User(generateUserData({ nothingValue: null })); }); it("should allow a passing validation constraint", () => { let Model = dm.createType("Model", { value: dm.string().validate((value) => { return true; }) }); Model({ value: "foo" }); }); // TODO: Tests for allowing NaN, Infinity, -Infinity, etc. }); describe("failure cases", () => { describe("required properties", () => { it("should fail on a missing required property", () => { expect(() => { let data = generateUserData(); delete data.name; User(data); }).to.throw("Value is required for property 'name'"); }); it("should fail on a required property being null", () => { expect(() => { User(generateUserData({ name: null })); }).to.throw("Value is required for property 'name'"); }); it("should fail on a required property being undefined", () => { expect(() => { User(generateUserData({ name: undefined })); }).to.throw("Value is required for property 'name'"); }); }); describe("top-level properties", () => { it("should require a string-typed name", () => { expect(() => { User(generateUserData({ name: 42 })); }).to.throw("Expected a string, got a number instead"); }); it("should require a boolean-typed isActive", () => { expect(() => { User(generateUserData({ isActive: 42 })); }).to.throw("Expected a boolean, got a number instead"); }); it("should require a number-typed age", () => { expect(() => { User(generateUserData({ age: "foo" })); }).to.throw("Expected a number, got a string instead"); }); it("should require either a number-typed or a string-typed misc value", () => { expect(() => { User(generateUserData({ misc: false })); }).to.throw("Expected one of (string, number), got a boolean instead"); }); it("should require a Date-typed registrationDate", () => { expect(() => { User(generateUserData({ registrationDate: 42 })); }).to.throw("Expected an instance of Date, got a number instead"); }); it("should require a Set-typed luckyNumbers", () => { expect(() => { User(generateUserData({ luckyNumbers: 42 })); }).to.throw("Expected a Set or a Set, got a number instead"); }); it("should require a Map-typed codeWords", () => { expect(() => { User(generateUserData({ codeWords: 42 })); }).to.throw("Expected a Map or a Map, got a number instead"); }); }); describe("collections and nested instances", () => { /* TODO: Do we need separate tests for guarded Maps/Sets? */ it("should require Event-typed values for its events array", () => { expect(() => { User(generateUserData({ events: [ 42, 41, 40 ] })); }).to.throw("Expected an instance of Event, got a number instead"); }); it("should require Identity-typed values for its identities Set", () => { expect(() => { User(generateUserData({ identities: new Set([ 42, 41, 40 ]) })); }).to.throw("Expected an instance of Identity, got a number instead"); }); it("should require string-typed keys for its codeWords Map", () => { expect(() => { User(generateUserData({ codeWords: new Map([ [42, "foo"], ["bar", "baz"] ]) })); }).to.throw("Expected a string, got a number instead"); }); it("should require string-typed values for its codeWords Map", () => { expect(() => { User(generateUserData({ codeWords: new Map([ ["foo", 42], ["bar", "baz"] ]) })); }).to.throw("Expected a string, got a number instead"); }); it("should reject an instance of the wrong custom type", () => { expect(() => { User(generateUserData({ mainIdentity: User(generateUserData()) })); }).to.throw("Expected an instance of Identity, got an instance of User instead"); }); it("should reject an instance of the wrong type for a 'self' rule", () => { expect(() => { User(generateUserData({ alternateUser: Identity({ label: "bot", nickname: "botpie91", emailAddress: "botpie91@cryto.net" }) })); }).to.throw("Expected an instance of User, got an instance of Identity instead"); }); it("should also validate values for a nested instance", () => { expect(() => { User(generateUserData({ mainIdentity: Identity({ label: "bot", nickname: {}, emailAddress: "botpie91@cryto.net" }) })); }).to.throw("Expected a string, got an object instead"); }); it("should also validate values for nested instances in a Set", () => { expect(() => { User(generateUserData({ identities: new Set([ Identity({ label: "bot", nickname: {}, emailAddress: "botpie91@cryto.net" }) ]) })); }).to.throw("Expected a string, got an object instead"); }); }); describe("type options", () => { describe("numbers", () => { it("should detect violation of a minimum number", () => { expect(() => { User(generateUserData({ age: -1 })); }).to.throw("Value for property 'age' must be at least 0"); }); it("should detect violation of a numeric range, at the lower bound", () => { expect(() => { User(generateUserData({ luckyNumbers: new Set([13, -1]) })); }).to.throw("Value for property '' must be at least 1"); }); it("should detect violation of a numeric range, at the upper bound", () => { expect(() => { User(generateUserData({ luckyNumbers: new Set([13, 2000]) })); }).to.throw("Value for property '' cannot be higher than 42"); }); it("should reject NaN", () => { expect(() => { User(generateUserData({ age: NaN })); }).to.throw("Specified value for property 'age' is NaN, but this is not allowed"); }); it("should reject positive Infinity", () => { expect(() => { User(generateUserData({ age: Infinity })); }).to.throw("Specified value for property 'age' is an infinite value, but this is not allowed"); }); it("should reject negative Infinity", () => { expect(() => { User(generateUserData({ age: -Infinity })); }).to.throw("Specified value for property 'age' is an infinite value, but this is not allowed"); }); }); describe("strings", () => { it("should reject a string that fails the specified 'matches' regex", () => { expect(() => { User(generateUserData({ mainIdentity: Identity({ label: "bot", nickname: "botpie91", emailAddress: "not-an-email-address" }) })); }).to.throw("Value for property 'emailAddress' failed `matches` condition"); }); }); /* TODO: Function guards */ }); describe("additional constraints", () => { it("should reject a failing validation constraint", () => { let Model = dm.createType("Model", { value: dm.string().validate((value) => { return new dm.ValidationError("Value failed nonsense validation rule"); }) }); expect(() => { Model({ value: "foo" }); }).to.throw("Value failed nonsense validation rule"); }); }); }); });