cartography
Version:
Object to Object mapper
366 lines (317 loc) • 13.6 kB
JavaScript
const assert = require('assert');
const cartography = require('../index');
describe('cartography', function () {
const { map, mapArray, from, same, filters, isCartographyError, CartographyError } = cartography;
const testSchema = {
id: same(),
name: from('userName', filters.defaults(0), filters.isString),
base: {
color: from('color.definition', filters.isString, (v) => v.toUpperCase()),
type() {
return 'default';
},
time(input) {
return `${input.day} - ${input.month}`;
},
},
failure: same(
filters.optional,
filters.assert(
(v) => !v,
(v) => `${v} should be falsy`
),
() => 'hello'
),
};
const anInput = () => ({
id: 123,
userName: 'HappyLand',
color: {
definition: '#7ff',
},
day: 'Mon',
month: 'Aug',
});
const theExpectedOutput = () => ({
id: 123,
name: 'HappyLand',
base: {
color: '#7FF',
type: 'default',
time: 'Mon - Aug',
},
});
describe('basic behaviour', function () {
it('should translate a basic Object', () =>
assert.deepStrictEqual(map(anInput(), testSchema), theExpectedOutput()));
it('should return null rather than an empty object', () => assert(!map({}, { unused: from('nothing') })));
it('should reject strings as schema definitions', () =>
assert.throws(() => map({}, { name: 'name' }), /invalid schema for `name`/));
it('should interrupt the chain if a defaulted value is not provided', function () {
const input = anInput();
delete input.userName;
const output = map(input, testSchema);
assert.strictEqual(output.name, 0);
});
it('should follow the filter chain if an optional attribute is provided', function () {
const input = anInput();
input.failure = false;
const expectedOutput = theExpectedOutput();
expectedOutput.failure = 'hello';
assert.deepStrictEqual(map(input, testSchema), expectedOutput);
input.failure = 'Whooop';
assert.throws(() => map(input, testSchema), /Whooop should be falsy/);
});
it('should handle undeclared nested objects', () =>
assert.deepStrictEqual(map({}, { a: from('a.b.c.d') }), undefined));
it('should be unfazed by undefined input', function () {
assert.strictEqual(map(undefined, undefined), undefined);
assert.strictEqual(map(undefined, {}), undefined);
assert.strictEqual(map(undefined, { a: same(filters.optional) }), undefined);
});
});
describe('array behaviour', function () {
it('should translate an array', () => assert.deepStrictEqual(mapArray([1], [filters.isNumber, (v) => v * 2]), [2]));
it('should translate an array with array filters', () =>
assert.deepStrictEqual(
mapArray([1, 2], [filters.isNumber, (v) => v * 2], [(array) => array.filter((v) => v % 2 === 0)]),
[4]
));
it('should translate an array of objects', () =>
assert.deepStrictEqual(mapArray([anInput()], testSchema), [theExpectedOutput()]));
it('should be able to handle undefined result', () =>
assert.strictEqual(mapArray([], testSchema, [() => undefined, filters.optional]), undefined));
});
describe('error reporting', function () {
it('should produce a descriptive error', function () {
const input = {
id: 123,
userName: 456,
};
try {
map(input, testSchema);
} catch (err) {
assert(/userName: must be a string/.test(err));
assert(err instanceof CartographyError);
assert(isCartographyError(err));
return;
}
throw new Error('no error produced');
});
it('should let normal errors pass', function () {
const schema = {
id: same(function () {
throw new Error('BLAAAH!');
}),
};
try {
map({}, schema);
} catch (err) {
assert(!(err instanceof CartographyError));
assert(err instanceof Error);
assert(/BLAAAH/.test(err));
return;
}
throw new Error('no error produced');
});
it('should produce a well-formatted error for nested attributes', function () {
const input = {
id: '111',
location: {
type: 'high noise',
colors: [0, 11, '3'],
},
};
const schema = {
id: same(),
address: from(
'location',
filters.object({
type: same(),
colors: same(filters.array(filters.isNumber)),
})
),
};
assert.throws(() => map(input, schema), /location.colors\[2\]: must be a number/);
});
});
describe('helpers', function () {
const f1 = () => 1;
const f2 = () => 2;
const f3 = () => 3;
describe('isCartographyError()', () =>
it('should work at least', function () {
assert(isCartographyError(new CartographyError()));
assert(!isCartographyError(new Error()));
}));
describe('same()', function () {
it('should return a flat array', () => assert.deepStrictEqual(same(f3, [[f2], f1]), [f3, f2, f1]));
it('should produce an error if a filter is invalid', () =>
assert.throws(() => same({}), /filter must be function or Array/));
});
describe('from()', function () {
it('should return a flat array', () => assert.deepStrictEqual(from('meh', [f1, f2], f3), ['meh', f1, f2, f3]));
it('should produce an error if a filter is invalid', () =>
assert.throws(() => from('meh', {}), /filter must be function or Array/));
it('should produce an error if path is invalid', () => assert.throws(() => from(function () {}), /string/));
});
});
describe('default filters', function () {
// Some filters are actualy filter factories.
// For testing purposes it's ok to monkey-patch `filters` with a product of theirs:
filters.isArrayOfInts = filters.array(filters.isInteger);
filters.isVowel = filters.isOneOf(['a', 'e', 'i', 'o', 'u']);
filters.defaultsHi = filters.defaults('Hi!');
const filterTestsByFilterName = {
required: [
{ input: '', output: '', verb: 'pass an empty string' },
{ input: null, error: /required/, verb: 'fail null' },
{ input: undefined, error: /required/, verb: 'reject undefined' },
],
parseJSON: [
{ input: '{"A":1, "b":"2"}', output: { A: 1, b: '2' }, verb: 'pass valid JSON' },
{ input: '{"A":1, "b":\'2\'}', error: /invalid JSON/, verb: 'reject malformed JSON' },
],
isString: [
{ input: 'b', output: 'b', verb: 'pass a string' },
{ input: 1, error: /string/, verb: 'reject a non-string' },
],
isNumber: [
{ input: -0.33, output: -0.33, verb: 'pass a number' },
{ input: NaN, error: /number/, verb: 'reject NaN' },
{ input: '1', error: /number/, verb: 'reject a string that looks like a number' },
],
isInteger: [
{ input: -3, output: -3, verb: 'pass a number' },
{ input: -0.33, error: /integer/, verb: 'reject a decimal number' },
{ input: NaN, error: /number/, verb: 'reject NaN' },
{ input: '1', error: /number/, verb: 'reject a string that looks like a number' },
],
isEmail: [
{ input: 'name@test.com', output: 'name@test.com', verb: 'pass an email' },
{ input: 'name.extra@test.com', output: 'name.extra@test.com', verb: 'pass an email with period' },
{ input: 'name+extra@test.com', output: 'name+extra@test.com', verb: 'pass an email with +' },
{ input: 'name@sub.test.com', output: 'name@sub.test.com', verb: 'pass an email with subdomain' },
{ input: 'name@sub.test.qwe', output: 'name@sub.test.qwe', verb: 'pass an email any domain' },
{ input: 'name@[203.203.203.203]', output: 'name@[203.203.203.203]', verb: 'pass an email with ip address' },
{ input: null, error: /must be a valid email address/, verb: 'reject null' },
{ input: undefined, error: /must be a valid email address/, verb: 'reject undefined' },
{ input: '', error: /must be a valid email address/, verb: 'reject empty string' },
{ input: 'just_a_string', error: /must be a valid email address/, verb: 'reject just a string' },
{
input: 'name@[203.203.203.2031]',
error: /must be a valid email address/,
verb: 'reject an email with invalid ip address',
},
{
input: 'name@203.203.203.203',
error: /must be a valid email address/,
verb: 'reject an email with ip address without brackets',
},
{
input: 'name@test',
error: /must be a valid email address/,
verb: 'reject an email with no domain extension',
},
{
input: 'name@test++.com',
error: /must be a valid email address/,
verb: 'reject an email with invalid domain',
},
],
isUrl: [
{ input: 'http://test.com', output: 'http://test.com', verb: 'pass a http url' },
{ input: 'https://test.com', output: 'https://test.com', verb: 'pass a https url' },
{ input: 'https://www.test.com', output: 'https://www.test.com', verb: 'pass a https url with www' },
{ input: 'https://sub.test.com', output: 'https://sub.test.com', verb: 'pass an url with subdomain' },
{ input: 'https://test.com/', output: 'https://test.com/', verb: 'pass an url with trailing slash' },
{
input: 'https://www.test.com.au',
output: 'https://www.test.com.au',
verb: 'pass an url with double extenstion',
},
{ input: 'https://test.com:3000', output: 'https://test.com:3000', verb: 'pass an url with port' },
{ input: 'https://203.203.203.203', output: 'https://203.203.203.203', verb: 'pass an url ip address' },
{ input: 'https://test.com/#id', output: 'https://test.com/#id', verb: 'pass an url with anchor' },
{ input: 'https://test.com/#!id', output: 'https://test.com/#!id', verb: 'pass an url with hashbang' },
{
input: 'https://test.com/path/subpath',
output: 'https://test.com/path/subpath',
verb: 'pass an url with path',
},
{
input: 'https://test.com/path/subpath?query=string',
output: 'https://test.com/path/subpath?query=string',
verb: 'pass an url with path and query string',
},
{
input: 'https://test.com/path/subpath?query=string#id',
output: 'https://test.com/path/subpath?query=string#id',
verb: 'pass an url with path, query string and anchor',
},
{
input: 'https://test.com/path/subpath?query=some%20string',
output: 'https://test.com/path/subpath?query=some%20string',
verb: 'pass an url with query string (encoded)',
},
{ input: null, error: /must be a valid URL/, verb: 'reject null' },
{ input: undefined, error: /must be a valid URL/, verb: 'reject undefined' },
{ input: '', error: /must be a valid URL/, verb: 'reject empty string' },
{ input: 'just_a_string', error: /must be a valid URL/, verb: 'reject just a string' },
{
input: 'https://test.com/path/subpath?query=some string',
error: /must be a valid URL/,
verb: 'reject an url with query string (not encoded)',
},
],
isArrayOfInts: [
{ input: {}, error: /Array/, verb: 'reject Objects' },
{ input() {}, error: /Array/, verb: 'reject functions' },
{ input: '', error: /Array/, verb: 'reject strings' },
{ input: 1, error: /Array/, verb: 'reject numbers' },
{ input: [], output: [], verb: 'pass an empty Array' },
{ input: [1, 2, 3.1, 4, 5], error: /\[2\]/, verb: 'reject an array with a non-integer' },
{ input: [1, 2, 3, 4, 5], output: [1, 2, 3, 4, 5], verb: 'accept an array of ints' },
],
isVowel: [
{ input: 'b', error: /value/, verb: 'rejects a consonant' },
{ input: 'e', output: 'e', verb: 'accepts an italian vowel' },
{ input: 'u', output: 'u', verb: 'accepts an italian vowel' },
],
defaultsHi: [
{ input: undefined, error: /./, verb: 'overrides undefined' },
{ input: null, error: /./, verb: 'overrides null' },
{ input: '', output: '', verb: "passes ''" },
{ input: 0, output: 0, verb: 'passes 0' },
{ input: {}, output: {}, verb: 'passes {}' },
{ input: 11, output: 11, verb: 'passes 11' },
],
};
// describe ->
return (() => {
const result = [];
for (let filterName in filterTestsByFilterName) {
const filterTests = filterTestsByFilterName[filterName];
result.push(
((name, tests) =>
describe(`${name}()`, () =>
// it ->
Array.from(tests).map((filterTest) =>
((test) =>
it(`should ${test.verb}`, function () {
if (test.error != null) {
return assert.throws(() => filters[name](test.input), test.error);
} else {
return assert.deepStrictEqual(
filters[name](test.input),
test.output != null ? test.output : test.input
);
}
}))(filterTest)
)))(filterName, filterTests)
);
}
return result;
})();
});
});