From c5bcb4045feaef604de596691cd058afa6452c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Fri, 31 Aug 2018 13:53:14 -0400 Subject: [PATCH 1/7] Refactor method for getting related fields --- model/fetch.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/model/fetch.js b/model/fetch.js index ac170b8..0e0a6d8 100644 --- a/model/fetch.js +++ b/model/fetch.js @@ -27,7 +27,7 @@ function build_request({ config, object }) { method: 'get', url: `${config.base_url}${object.endpoint}/${object.id}`, } - const include_param = get_include_param({ object, related: config.related }); + const include_param = get_related_fields({ object, related: config.related }).join(','); if (include_param) { request.url += `?include=${include_param}`; @@ -36,39 +36,39 @@ function build_request({ config, object }) { return request; } -function get_include_param({ object, related }) { - let include_param = ''; +function get_related_fields({ object, related }) { + let related_fields = []; if (!related) { - return include_param; + return related_fields; } switch (typeof related) { case 'string': - include_param += calculate_related_paths({ object, prop: related }); + related_fields.push(calculate_related_paths({ object, prop: related })); break; case 'object': if (Array.isArray(related)) { - include_param += related - .map(item => get_include_param({ object, related: item })) + related_fields = related_fields.concat(related + .map(item => get_related_fields({ object, related: item })) + .map(item => item[0]) .filter(item => item) - .join(','); + ) } // @TODO: Support non-iterable objects (for nested relationships?) break; case 'boolean': - if (related) { - include_param += Object.keys(object.__maps) - .map(item => get_include_param({ object, related: item })) - .filter(item => item) - .join(','); - } + related_fields = related_fields.concat(Object.keys(object.__maps) + .map(item => get_related_fields({ object, related: item })) + .map(item => item[0]) + .filter(item => item) + ) break; } - return include_param; + return related_fields; } function calculate_related_paths({ object, prop }) { From f1f4ef80d167dcfceb431312d33b95efb02f8ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Fri, 31 Aug 2018 16:22:10 -0400 Subject: [PATCH 2/7] Implement parallel requests This is missing test data for the field and relationship endpoints, and will likely require a refactor of test data to break things apart. --- model/fetch.js | 84 +++++++++++++++++++++++++++++++++++++++------ test/model/fetch.js | 36 +++++++++++++++++++ 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/model/fetch.js b/model/fetch.js index 0e0a6d8..cbc8c5f 100644 --- a/model/fetch.js +++ b/model/fetch.js @@ -5,16 +5,37 @@ module.exports = fetch; function fetch(options) { const object = this; const config = _.merge({}, object.__config, options); - const request = build_request({ config, object }); const axios = object.__axios; + const main_request = axios(build_request_options({ config, object })) + .then(res => res.data); + let requests = [ main_request ]; - return axios(request).then(response => { - const related = (response.data.included || []).reduce((result, item) => { - result[item.id] = item; - return result; - }, {}); + if (config.parallel_relationships) { + // Concatenate related requests. + requests = requests.concat(build_related_requests({ axios, config, object })); + } - object.hydrate({ data: response.data.data, related }); + return Promise.all(requests).then(responses => { + const main = responses.shift(); + const data = main.data; + const related = {} + + if (config.parallel_relationships) { + responses.forEach(response => { + if (response.__relationship) { + data.relationships[response.__relationship] = response.data.data; + } else if (response.__related_object) { + const item = response.data.data; + related[item.id] = item; + } + }); + } else { + main.included.forEach(item => { + related[item.id] = item; + }); + } + + object.hydrate({ data, related }); object.__saved = true; object.__new = false; @@ -22,15 +43,20 @@ function fetch(options) { }); } -function build_request({ config, object }) { +function build_request_options({ config, object }) { const request = { method: 'get', url: `${config.base_url}${object.endpoint}/${object.id}`, } - const include_param = get_related_fields({ object, related: config.related }).join(','); - if (include_param) { - request.url += `?include=${include_param}`; + // If relationships are not being fetched in parallel, try to include them + // via query string param. + if (!config.parallel_relationships) { + const include_param = get_related_fields({ object, related: config.related }).join(','); + + if (include_param) { + request.url += `?include=${include_param}`; + } } return request; @@ -81,3 +107,39 @@ function calculate_related_paths({ object, prop }) { return directive.replace(/^relationships\./, ''); } + +/** + * Construct an array of parallel requests to fetch related objects and the + * relationships themselves. + * + * @return Array - An array of promises. + */ +function build_related_requests({ axios, config, object }) { + return get_related_fields({ object, related: config.related }) + + // Reduce the array of related fields into an array of requests; + // two requests per related field: + // - fetching the related object + // - fetching the relationship itself (sometimes this includes metadata + // about the relationship) + .reduce((arr, item) => { + + // Request relationship data. + const rel_req = build_request_options({ config, object }); + rel_req.url += `/relationships/${item}`; + arr.push(axios(rel_req).then(response => { + response.__relationship = item; + return response; + })); + + // Request related object data. + const obj_req = build_request_options({ config, object }); + obj_req.url += `/${item}`; + arr.push(axios(obj_req).then(response => { + response.__related_object = item; + return response; + })); + + return arr; + }, []); +} diff --git a/test/model/fetch.js b/test/model/fetch.js index c3aefcb..0625e5f 100644 --- a/test/model/fetch.js +++ b/test/model/fetch.js @@ -95,6 +95,42 @@ describe('fetch() method', () => { }); }); + it('should optionally make relationship requests in parallel', () => { + return new Post({ id }).fetch({ related: true, parallel_relationships: true }).then(post => { + expect(axios).to.have.callCount(7); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/author', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/relationships/author', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/body', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/relationships/body', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/category', + }); + expect(axios).to.be.calledWith({ + method: 'get', + url: 'https://some.contrived.url/posts/1234/relationships/category', + }); + }); + }); + + it('should properly populate relationships requested in parallel'); + it('should use the model’s default if set', () => { const PostWithRelated = require('../fixtures/models/Post')({ axios, From 27dda6a6c6b0a73b5f6eed697a0f3475d085bf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Fri, 31 Aug 2018 16:22:41 -0400 Subject: [PATCH 3/7] Fix typo in test description --- test/model/fetch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/model/fetch.js b/test/model/fetch.js index 0625e5f..ed7880d 100644 --- a/test/model/fetch.js +++ b/test/model/fetch.js @@ -43,7 +43,7 @@ describe('fetch() method', () => { }).then(done).catch(done); }); - it('should update the current object with it fetches', () => { + it('should update the current object with the data it fetches', () => { post = new Post({ id }); post.fetch().then(new_post => { From 8f1b8b046c65fcb54bf044182119e677cdee7007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Sat, 1 Sep 2018 15:19:28 -0400 Subject: [PATCH 4/7] Move test post data --- test/fixtures/{ => data}/post.json | 0 test/map.spec.js | 2 +- test/model/destroy.js | 2 +- test/model/fetch.js | 2 +- test/model/relationships.js | 2 +- test/model/toObject.js | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename test/fixtures/{ => data}/post.json (100%) diff --git a/test/fixtures/post.json b/test/fixtures/data/post.json similarity index 100% rename from test/fixtures/post.json rename to test/fixtures/data/post.json diff --git a/test/map.spec.js b/test/map.spec.js index e55d764..548ed6c 100644 --- a/test/map.spec.js +++ b/test/map.spec.js @@ -1,6 +1,6 @@ require('should'); const config = require('./fixtures/config'); -const raw_post = require('./fixtures/post'); +const raw_post = require('./fixtures/data/post'); const map = require('../map'); const _ = require('lodash'); diff --git a/test/model/destroy.js b/test/model/destroy.js index 1719788..c59fdff 100644 --- a/test/model/destroy.js +++ b/test/model/destroy.js @@ -4,7 +4,7 @@ const sinon = require('sinon'); chai.use(require('sinon-chai')); const expect = chai.expect; const Model = require('../../model'); -const raw_data = require('../fixtures/post.json'); +const raw_data = require('../fixtures/data/post.json'); describe('destroy() method', () => { let axios, base_url, Thing, thing; diff --git a/test/model/fetch.js b/test/model/fetch.js index ed7880d..ed48147 100644 --- a/test/model/fetch.js +++ b/test/model/fetch.js @@ -11,7 +11,7 @@ describe('fetch() method', () => { id = '1234'; axios = sinon.spy(request => { - const data = _.cloneDeep(require('../fixtures/post.json')); + const data = _.cloneDeep(require('../fixtures/data/post.json')); return Promise.resolve({ status: 200, diff --git a/test/model/relationships.js b/test/model/relationships.js index fc1be9d..7a5914e 100644 --- a/test/model/relationships.js +++ b/test/model/relationships.js @@ -4,7 +4,7 @@ const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); -const raw_json = require('../fixtures/post.json'); +const raw_json = require('../fixtures/data/post.json'); describe('relationships', () => { let axios, Image, Paragraph, Person, Post, post, Role; diff --git a/test/model/toObject.js b/test/model/toObject.js index f8fc99b..28f2984 100644 --- a/test/model/toObject.js +++ b/test/model/toObject.js @@ -4,7 +4,7 @@ const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); -const raw_json = require('../fixtures/post.json'); +const raw_json = require('../fixtures/data/post.json'); /* eslint-disable no-unused-vars */ describe('to_object() method', () => { From 712003c49013b8b8a040b5a97537f9cd100bad50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Sat, 1 Sep 2018 22:59:09 -0400 Subject: [PATCH 5/7] Implement api mock --- package.json | 1 + test/fixtures/api.js | 96 ++++++++++++++++ test/fixtures/data/blockquotes/104.js | 12 ++ test/fixtures/data/images/102.js | 16 +++ test/fixtures/data/paragraphs/101.js | 7 ++ test/fixtures/data/paragraphs/103.js | 7 ++ test/fixtures/data/people/201.js | 25 +++++ test/fixtures/data/people/202.js | 28 +++++ test/fixtures/data/post.json | 156 -------------------------- test/fixtures/data/posts/1.js | 37 ++++++ test/fixtures/data/roles/401.js | 10 ++ test/fixtures/data/roles/402.js | 10 ++ test/fixtures/data/taxonomies/301.js | 10 ++ 13 files changed, 259 insertions(+), 156 deletions(-) create mode 100644 test/fixtures/api.js create mode 100644 test/fixtures/data/blockquotes/104.js create mode 100644 test/fixtures/data/images/102.js create mode 100644 test/fixtures/data/paragraphs/101.js create mode 100644 test/fixtures/data/paragraphs/103.js create mode 100644 test/fixtures/data/people/201.js create mode 100644 test/fixtures/data/people/202.js delete mode 100644 test/fixtures/data/post.json create mode 100644 test/fixtures/data/posts/1.js create mode 100644 test/fixtures/data/roles/401.js create mode 100644 test/fixtures/data/roles/402.js create mode 100644 test/fixtures/data/taxonomies/301.js diff --git a/package.json b/package.json index b64f664..3ff709d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "chai": "^4.1.2", "eslint": "^4.19.1", "mocha": "^5.1.1", + "pluralize": "^7.0.0", "pre-push": "^0.1.1", "should": "^13.2.1", "sinon": "^4.5.0", diff --git a/test/fixtures/api.js b/test/fixtures/api.js new file mode 100644 index 0000000..8271304 --- /dev/null +++ b/test/fixtures/api.js @@ -0,0 +1,96 @@ +const _ = require('lodash'); +const pluralize = require('pluralize'); +const url = require('url'); +const base = './data'; + +module.exports = req => { + return new Promise((resolve, reject) => { + const request_url = url.parse(req.url, true, true); + const { type, id, relationship, field } = parse_path(request_url.pathname); + const main_data = require(`${base}/${type}/${id}`); + let data; + let included = []; + + if (relationship && field) { + data = _.get(main_data, `relationships.${field}`, {}).data || null; + } else if (field) { + data = get_path_relationship({ field, main_data }); + } else { + data = main_data; + included = get_query_param_relationships({ data, included, request_url }); + } + + if (data instanceof Error) { + return reject(data); + } + + return resolve(JSON.stringify({ data, included })); + }) +} + +function parse_path(path) { + // Make array of path segments, filtering out empty segments. + const segments = path.split('/').filter(seg => seg); + const relationship = segments[2] === 'relationships' && segments[3]; + + return { + type: segments[0], + id: segments[1], + relationship, + field: relationship ? segments[3] : segments[2], + } +} + +function get_relationships({ data, key }) { + return _.get(data, `relationships.${key}`); +} + +function get_query_param_relationships({ data, included, request_url }) { + if (_.get(request_url, 'query.include')) { + const query_fields = request_url.query.include.split(','); + return _.flattenDeep(included.concat(query_fields + .map(key => get_relationships({ data, key })) + .filter(relationship => relationship) + .map(relationship => { + if (Array.isArray(relationship.data)) { + return relationship.data.map(item => { + try { + return require(`${base}/${pluralize.plural(item.type)}/${item.id}`); + } catch (e) { + console.error(e.message); + return null; + } + }).filter(item => item); + } else { + try { + return require(`${base}/${pluralize.plural(relationship.data.type)}/${relationship.data.id}`); + } catch (e) { + console.error(e.message); + return null; + } + } + }).filter(item => item) + )); + } +} + +function get_path_relationship({ field, main_data }) { + const relationship = _.get(main_data, `relationships.${field}`); + if (Array.isArray(relationship.data)) { + return relationship.data.map(item => { + try { + return require(`${base}/${pluralize.plural(item.type)}/${item.id}`); + } catch (e) { + console.error(e.message); + return null; + } + }).filter(item => item); + } else { + try { + return require(`${base}/${pluralize.plural(relationship.data.type)}/${relationship.data.id}`); + } catch (e) { + console.error(e.message); + return null; + } + } +} diff --git a/test/fixtures/data/blockquotes/104.js b/test/fixtures/data/blockquotes/104.js new file mode 100644 index 0000000..5bd5a1b --- /dev/null +++ b/test/fixtures/data/blockquotes/104.js @@ -0,0 +1,12 @@ +module.exports = { + type: 'blockquote', + id: '104', + attributes: { + text: 'It matters not how strait the gate, how charged with punishments the scroll, I am the master of my fate, I am the captain of my soul.', + source: { + name: 'William Ernest Henley', + url: 'https://www.poetryfoundation.org/poems/51642/invictus', + description: null, + }, + }, +} diff --git a/test/fixtures/data/images/102.js b/test/fixtures/data/images/102.js new file mode 100644 index 0000000..7745609 --- /dev/null +++ b/test/fixtures/data/images/102.js @@ -0,0 +1,16 @@ +module.exports = { + type: 'image', + id: '102', + attributes: { + src: '/path/to/image.jpg', + alt: 'You do provide ALT values, right?', + }, + relationships: { + author: { + data: { + type: 'person', + id: '202', + }, + }, + }, +} diff --git a/test/fixtures/data/paragraphs/101.js b/test/fixtures/data/paragraphs/101.js new file mode 100644 index 0000000..d447ef5 --- /dev/null +++ b/test/fixtures/data/paragraphs/101.js @@ -0,0 +1,7 @@ +module.exports = { + type: 'paragraph', + id: '101', + attributes: { + text: 'Et id animi optio voluptatem sunt voluptas dolorem. Et neque quasi aliquid quia soluta enim quia deserunt. Eum fugit est non accusamus ut nisi recusandae veniam. Quia vero excepturi minima. Et reiciendis voluptas error vel rerum omnis ipsum quia.', + }, +} diff --git a/test/fixtures/data/paragraphs/103.js b/test/fixtures/data/paragraphs/103.js new file mode 100644 index 0000000..7ef5761 --- /dev/null +++ b/test/fixtures/data/paragraphs/103.js @@ -0,0 +1,7 @@ +module.exports = { + type: 'paragraph', + id: '103', + attributes: { + text: 'Quia sed repellat id cum. Aperiam reprehenderit amet minima ut dolorem non nostrum placeat. Culpa esse id dolorum ducimus. Est quae nemo et rerum sapiente nam inventore.', + }, +} diff --git a/test/fixtures/data/people/201.js b/test/fixtures/data/people/201.js new file mode 100644 index 0000000..fce5798 --- /dev/null +++ b/test/fixtures/data/people/201.js @@ -0,0 +1,25 @@ +module.exports = { + type: 'person', + id: '201', + attributes: { + name: 'Testy McTestface', + biography: 'It’s turtles all the way down, man.', + path: { + alias: '/authors/testy-mctestface', + }, + }, + relationships: { + topics: { + data: [{ + type: 'taxonomy', + id: '301', + }], + }, + roles: { + data: [{ + type: 'role', + id: '401', + }], + }, + }, +} diff --git a/test/fixtures/data/people/202.js b/test/fixtures/data/people/202.js new file mode 100644 index 0000000..e524683 --- /dev/null +++ b/test/fixtures/data/people/202.js @@ -0,0 +1,28 @@ +module.exports = { + type: 'person', + id: '202', + attributes: { + name: 'Foto McFotoface', + biography: 'It’s film gran all the way down, friend.', + path: { + alias: '/authors/foto-mcfotoface', + }, + }, + relationships: { + topics: { + data: [{ + type: 'taxonomy', + id: '301', + }], + }, + roles: { + data: [{ + type: 'role', + id: '401', + },{ + type: 'rule', + id: '402', + }], + }, + }, +} diff --git a/test/fixtures/data/post.json b/test/fixtures/data/post.json deleted file mode 100644 index 4b93576..0000000 --- a/test/fixtures/data/post.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "data": { - "type": "post", - "id": "1", - "attributes": { - "title": "Your run of the mill post.", - "sub_title": "Or: That time I couldn’t decide between two titles." - }, - "relationships": { - "author": { - "data": { - "type": "person", - "id": "201" - } - }, - "body": { - "data": [{ - "type": "paragraph", - "id": "101" - },{ - "type": "image", - "id": "102" - },{ - "type": "paragraph", - "id": "103" - },{ - "type": "blockquote", - "id": "104" - }] - }, - "category": { - "data": [{ - "type": "taxonomy", - "id": "301" - }] - } - } - }, - "included": [{ - "type": "paragraph", - "id": "101", - "attributes": { - "text": "Et id animi optio voluptatem sunt voluptas dolorem. Et neque quasi aliquid quia soluta enim quia deserunt. Eum fugit est non accusamus ut nisi recusandae veniam. Quia vero excepturi minima. Et reiciendis voluptas error vel rerum omnis ipsum quia." - } - },{ - "type": "image", - "id": "102", - "attributes": { - "src": "/path/to/image.jpg", - "alt": "You do provide ALT values, right?" - }, - "relationships": { - "author": { - "data": { - "type": "person", - "id": "202" - } - } - } - },{ - "type": "paragraph", - "id": "103", - "attributes": { - "text": "Quia sed repellat id cum. Aperiam reprehenderit amet minima ut dolorem non nostrum placeat. Culpa esse id dolorum ducimus. Est quae nemo et rerum sapiente nam inventore." - } - },{ - "type": "blockquote", - "id": "104", - "attributes": { - "text": "It matters not how strait the gate, how charged with punishments the scroll, I am the master of my fate, I am the captain of my soul.", - "source": { - "name": "William Ernest Henley", - "url": "https://www.poetryfoundation.org/poems/51642/invictus", - "description": null - } - } - },{ - "type": "person", - "id": "201", - "attributes": { - "name": "Testy McTestface", - "biography": "It’s turtles all the way down, man.", - "path": { - "alias": "/authors/testy-mctestface" - } - }, - "relationships": { - "topics": { - "data": [{ - "type": "taxonomy", - "id": "301" - }] - }, - "roles": { - "data": [{ - "type": "role", - "id": "401" - }] - } - } - },{ - "type": "person", - "id": "202", - "attributes": { - "name": "Foto McFotoface", - "biography": "It’s film gran all the way down, friend.", - "path": { - "alias": "/authors/foto-mcfotoface" - } - }, - "relationships": { - "topics": { - "data": [{ - "type": "taxonomy", - "id": "301" - }] - }, - "roles": { - "data": [{ - "type": "role", - "id": "401" - },{ - "type": "rule", - "id": "402" - }] - } - } - },{ - "type": "taxonomy", - "id": "301", - "attributes": { - "name": "Test-driven Development", - "path": { - "alias": "/topics/test-driven-development" - } - } - },{ - "type": "role", - "id": "401", - "attributes": { - "name": "Writer", - "path": { - "alias": "/writers" - } - } - },{ - "type": "role", - "id": "402", - "attributes": { - "name": "Photographer", - "path": { - "alias": "/photographers" - } - } - }] -} diff --git a/test/fixtures/data/posts/1.js b/test/fixtures/data/posts/1.js new file mode 100644 index 0000000..d76e1a8 --- /dev/null +++ b/test/fixtures/data/posts/1.js @@ -0,0 +1,37 @@ +module.exports = { + type: 'post', + id: '1', + attributes: { + title: 'Your run of the mill post.', + sub_title: 'Or: That time I couldn’t decide between two titles.', + }, + relationships: { + author: { + data: { + type: 'person', + id: '201', + }, + }, + body: { + data: [{ + type: 'paragraph', + id: '101', + },{ + type: 'image', + id: '102', + },{ + type: 'paragraph', + id: '103', + },{ + type: 'blockquote', + id: '104', + }], + }, + category: { + data: [{ + type: 'taxonomy', + id: '301', + }], + }, + }, +} diff --git a/test/fixtures/data/roles/401.js b/test/fixtures/data/roles/401.js new file mode 100644 index 0000000..e10cf53 --- /dev/null +++ b/test/fixtures/data/roles/401.js @@ -0,0 +1,10 @@ +module.exports = { + type: 'role', + id: '401', + attributes: { + name: 'Writer', + path: { + alias: '/writers', + }, + }, +} diff --git a/test/fixtures/data/roles/402.js b/test/fixtures/data/roles/402.js new file mode 100644 index 0000000..6028173 --- /dev/null +++ b/test/fixtures/data/roles/402.js @@ -0,0 +1,10 @@ +module.exports = { + type: 'role', + id: '402', + attributes: { + name: 'Photographer', + path: { + alias: '/photographers', + }, + }, +} diff --git a/test/fixtures/data/taxonomies/301.js b/test/fixtures/data/taxonomies/301.js new file mode 100644 index 0000000..c6c8eb9 --- /dev/null +++ b/test/fixtures/data/taxonomies/301.js @@ -0,0 +1,10 @@ +module.exports = { + type: 'taxonomy', + id: '301', + attributes: { + name: 'Test-driven Development', + path: { + alias: '/topics/test-driven-development', + }, + }, +} From d203d95fdf50bfd061816ecc9752a29ff3d42112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Sat, 1 Sep 2018 22:59:46 -0400 Subject: [PATCH 6/7] Harden included and set null as fallback --- model/fetch.js | 2 +- model/get.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/model/fetch.js b/model/fetch.js index cbc8c5f..e9575f7 100644 --- a/model/fetch.js +++ b/model/fetch.js @@ -30,7 +30,7 @@ function fetch(options) { } }); } else { - main.included.forEach(item => { + (main.included || []).forEach(item => { related[item.id] = item; }); } diff --git a/model/get.js b/model/get.js index 9f16926..bdf92da 100644 --- a/model/get.js +++ b/model/get.js @@ -11,15 +11,17 @@ function get({ object, prop }) { switch (type) { case 'attributes': - result = _.get(object, `__data.${map}`); + result = _.get(object, `__data.${map}`, null); break; case 'relationships': { const reference = _.get(object, `__data.${map}.data`); const related = _.get(object, '__related'); if (Array.isArray(reference)) { result = reference.map(ref => get_related({ parent: object, reference: ref, related })); - } else { + } else if (reference) { result = get_related({ parent: object, reference, related }); + } else { + result = null; } break; } From 6e02914be3b4adb29eb1d8066723c72f32f3d885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20G=C3=B3mez-Arzola?= Date: Sat, 1 Sep 2018 23:12:59 -0400 Subject: [PATCH 7/7] Use api mock in tests And remove unused tests --- test/config.spec.js | 15 +-- test/fixtures/config/index.js | 16 ++- test/fixtures/config/options.js | 3 + test/fixtures/config/schema/blockquote.js | 15 --- test/fixtures/config/schema/image.js | 4 - test/fixtures/config/schema/index.js | 9 -- test/fixtures/config/schema/paragraph.js | 4 - test/fixtures/config/schema/person.js | 6 -- test/fixtures/config/schema/person_sparse.js | 1 - test/fixtures/config/schema/post.js | 8 -- test/fixtures/config/schema/taxonomy.js | 5 - test/fixtures/models/Person.js | 8 +- test/map.spec.js | 105 ------------------- test/model/destroy.js | 31 +++--- test/model/fetch.js | 51 ++++----- test/model/relationships.js | 51 +++++---- test/model/save.js | 1 + test/model/toObject.js | 64 +++++------ test/qs.spec.js | 25 ----- 19 files changed, 133 insertions(+), 289 deletions(-) create mode 100644 test/fixtures/config/options.js delete mode 100644 test/fixtures/config/schema/blockquote.js delete mode 100644 test/fixtures/config/schema/image.js delete mode 100644 test/fixtures/config/schema/index.js delete mode 100644 test/fixtures/config/schema/paragraph.js delete mode 100644 test/fixtures/config/schema/person.js delete mode 100644 test/fixtures/config/schema/person_sparse.js delete mode 100644 test/fixtures/config/schema/post.js delete mode 100644 test/fixtures/config/schema/taxonomy.js delete mode 100644 test/map.spec.js delete mode 100644 test/qs.spec.js diff --git a/test/config.spec.js b/test/config.spec.js index 83bb465..fbe61bb 100644 --- a/test/config.spec.js +++ b/test/config.spec.js @@ -2,20 +2,11 @@ const expect = require('chai').expect; const config = require('../config'); +const config_object = require('./fixtures/config')(); +const options = require('./fixtures/config/options'); +const symbol = Symbol.for('Jsonmonger.config'); describe('config() method', () => { - let config_object, options, symbol; - - before(() => { - options = { - base_url: 'https://some.contrived.url', - } - - config_object = config(options); - - symbol = Symbol.for('Jsonmonger.config'); - }); - it('should create a global symbol', () => { expect(config_object).to.deep.equal(options); expect(config_object).to.deep.equal(global[symbol]); diff --git a/test/fixtures/config/index.js b/test/fixtures/config/index.js index a9c2713..d15a300 100644 --- a/test/fixtures/config/index.js +++ b/test/fixtures/config/index.js @@ -1,3 +1,15 @@ -module.exports = { - schema: require('./schema'), +const config = require('../../../config'); +const options = require('./options'); + +module.exports = () => { + try { + return config(options); + } catch (e) { + console.log(e.message); + if (e.message !== 'Jsonmonger Error: Global configuration cannot be set more than once.') { + throw e; + } else { + return options; + } + } } diff --git a/test/fixtures/config/options.js b/test/fixtures/config/options.js new file mode 100644 index 0000000..8831c74 --- /dev/null +++ b/test/fixtures/config/options.js @@ -0,0 +1,3 @@ +module.exports = { + base_url: 'https://some.contrived.url', +} diff --git a/test/fixtures/config/schema/blockquote.js b/test/fixtures/config/schema/blockquote.js deleted file mode 100644 index e531412..0000000 --- a/test/fixtures/config/schema/blockquote.js +++ /dev/null @@ -1,15 +0,0 @@ -const _ = require('lodash'); - -module.exports = { - type: 'quotation', - value: 'attributes.text', - citation: { - name: 'attributes.source.name', - link: { - url: 'attributes.source.url', - title: ({ object }) => { - return _.get(object, 'attributes.source.description') || 'A fallback link title.'; - }, - }, - }, -} diff --git a/test/fixtures/config/schema/image.js b/test/fixtures/config/schema/image.js deleted file mode 100644 index b7f1e29..0000000 --- a/test/fixtures/config/schema/image.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - alt: 'attributes.alt', - src: 'attributes.src', -} diff --git a/test/fixtures/config/schema/index.js b/test/fixtures/config/schema/index.js deleted file mode 100644 index 7c22935..0000000 --- a/test/fixtures/config/schema/index.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - blockquote: require('./blockquote'), - image: require('./image'), - paragraph: require('./paragraph'), - person: require('./person'), - person_sparse: require('./person_sparse'), - post: require('./post'), - taxonomy: require('./taxonomy'), -} diff --git a/test/fixtures/config/schema/paragraph.js b/test/fixtures/config/schema/paragraph.js deleted file mode 100644 index 9d677c6..0000000 --- a/test/fixtures/config/schema/paragraph.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - type: 'text', - value: 'attributes.text', -} diff --git a/test/fixtures/config/schema/person.js b/test/fixtures/config/schema/person.js deleted file mode 100644 index 710eb9f..0000000 --- a/test/fixtures/config/schema/person.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - name: 'attributes.name', - url: 'attributes.path.alias', - bio: 'attributes.biography', - topics: 'relationships.topics', -} diff --git a/test/fixtures/config/schema/person_sparse.js b/test/fixtures/config/schema/person_sparse.js deleted file mode 100644 index ffad146..0000000 --- a/test/fixtures/config/schema/person_sparse.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = (({ name, url }) => ({ name, url }))(require('./person')); diff --git a/test/fixtures/config/schema/post.js b/test/fixtures/config/schema/post.js deleted file mode 100644 index b30a681..0000000 --- a/test/fixtures/config/schema/post.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - title: 'attributes.title', - author: 'relationships.author', - body: 'relationships.body', - meta: { - subtitle: 'attributes.sub_title', - }, -} diff --git a/test/fixtures/config/schema/taxonomy.js b/test/fixtures/config/schema/taxonomy.js deleted file mode 100644 index 3a67e67..0000000 --- a/test/fixtures/config/schema/taxonomy.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - type: 'topic', - label: 'attributes.name', - url: 'attributes.path.alias', -} diff --git a/test/fixtures/models/Person.js b/test/fixtures/models/Person.js index 2845773..0f66d37 100644 --- a/test/fixtures/models/Person.js +++ b/test/fixtures/models/Person.js @@ -6,23 +6,23 @@ module.exports = ({ axios } = {}) => new Model({ fullName: 'attributes.name', firstName: function (value) { if (value) { - const names = this.fullName.split(' '); + const names = (this.fullName || '').split(' '); names[0] = value; this.fullName = names.join(' '); return value; } else { - return this.fullName.split(' ')[0]; + return (this.fullName || '').split(' ')[0] || null; } }, lastName: function (value) { if (value) { - const names = this.fullName.split(' '); + const names = (this.fullName || '').split(' '); const lastName = value.split(' '); names.splice(1, lastName.length, ...lastName); this.fullName = names.join(' '); return value; } else { - return this.fullName.split(' ').slice(1).join(' '); + return (this.fullName || '').split(' ').slice(1).join(' ') || null; } }, bio: 'attributes.biography', diff --git a/test/map.spec.js b/test/map.spec.js deleted file mode 100644 index 548ed6c..0000000 --- a/test/map.spec.js +++ /dev/null @@ -1,105 +0,0 @@ -require('should'); -const config = require('./fixtures/config'); -const raw_post = require('./fixtures/data/post'); -const map = require('../map'); -const _ = require('lodash'); - -describe('Jsonmonger#map', () => { - let result; - - before(() => { - result = map(Object.assign({ config }, raw_post)); - }); - - it('should map an object according to its schema', () => { - result.type.should.equal('post'); - result.title.should.equal(raw_post.data.attributes.title); - result.meta.subtitle.should.equal(raw_post.data.attributes.sub_title); - result.body.should.have.length(4); - result.author.should.be.instanceOf(Object); - }); - - it('should use schemas to map related objects', () => { - const body = result.body; - const included = raw_post.included; - - body.should.be.instanceOf(Array); - body.should.deepEqual([{ - type: 'text', - value: included[0].attributes.text, - },{ - type: 'image', - src: included[1].attributes.src, - alt: included[1].attributes.alt, - },{ - type: 'text', - value: included[2].attributes.text, - },{ - type: 'quotation', - value: included[3].attributes.text, - citation: { - name: included[3].attributes.source.name, - link: { - url: included[3].attributes.source.url, - title: 'A fallback link title.', - }, - }, - }]); - - const author = result.author; - - author.should.be.instanceOf(Object); - author.should.deepEqual({ - type: 'person', - name: included[4].attributes.name, - bio: included[4].attributes.biography, - url: included[4].attributes.path.alias, - topics: [{ - type: 'topic', - label: included[6].attributes.name, - url: included[6].attributes.path.alias, - }], - }); - }); - - it('should use related objects as-is when no schema is available for them', () => { - const amended_post = _.cloneDeep(raw_post); - - // Add a body object with a video reference. - amended_post.data.relationships.body.data.push({ - type: 'video', - id: '105', - }); - - // Add a video objet tothe included array. - amended_post.included.push({ - type: 'video', - id: '105', - attributes: { - url: 'https://www.somevideoservice.com/contrived/path.mp4', - closed_captions: 'https://www.somevideoservice.com/contrived/path.txt', - thumbnail: 'https://www.somevideoservice.com/contrived/path.jpg', - }, - }); - - const amended_result = map(Object.assign({ config }, amended_post)); - const body = amended_result.body; - const included = amended_post.included; - - body[4].should.deepEqual(included[included.length - 1]); - }); - - it('should use an alternate schema, if provided', () => { - const alt_config = _.cloneDeep(config); - _.set(alt_config, 'schema.post.__author_schema', 'person_sparse'); - const alt_result = map(Object.assign({ config: alt_config }, raw_post)); - - alt_result.author.should.deepEqual({ - type: 'person', - name: raw_post.included[4].attributes.name, - url: raw_post.included[4].attributes.path.alias, - }); - }); - - it('should attempt to fetch missing related objects'); -}); diff --git a/test/model/destroy.js b/test/model/destroy.js index c59fdff..007db02 100644 --- a/test/model/destroy.js +++ b/test/model/destroy.js @@ -4,19 +4,20 @@ const sinon = require('sinon'); chai.use(require('sinon-chai')); const expect = chai.expect; const Model = require('../../model'); -const raw_data = require('../fixtures/data/post.json'); +const api = require('../fixtures/api'); +require('../fixtures/config')(); describe('destroy() method', () => { - let axios, base_url, Thing, thing; + let axios, base_url, raw_data, Thing, thing; before(() => { axios = sinon.spy(request => { if (request.method === 'get') { - const data = _.cloneDeep(raw_data); - - return Promise.resolve({ - status: 200, - data, + return api(request).then(result => { + return { + status: 200, + data: JSON.parse(result), + } }); } else { return Promise.resolve({ @@ -28,12 +29,16 @@ describe('destroy() method', () => { base_url = global[Symbol.for('Jsonmonger.config')].base_url; Thing = new Model({ - type: 'thing', - endpoint: '/things', + type: 'post', + endpoint: '/posts', name: 'attributes.title', }, { axios }); - return new Thing({ id: '1' }).fetch().then(result => { + return api({ url: '/posts/1' }).then(data => { + raw_data = JSON.parse(data); + }).then(() => { + return new Thing({ id: '1' }).fetch() + }).then(result => { thing = result; return thing.destroy(); }); @@ -41,10 +46,10 @@ describe('destroy() method', () => { it('should request to destroy an existing record', () => { expect(axios).to.be.calledTwice; - expect(axios).to.be.calledWith({ + expect(axios.getCalls()[1].args).to.deep.equal([{ method: 'delete', - url: `${base_url}/things/1`, - }); + url: `${base_url}/posts/1`, + }]); }); it('should reset the model as new', () => { diff --git a/test/model/fetch.js b/test/model/fetch.js index ed48147..f380f62 100644 --- a/test/model/fetch.js +++ b/test/model/fetch.js @@ -1,21 +1,22 @@ -const _ = require('lodash'); const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); +const api = require('../fixtures/api'); +require('../fixtures/config')(); describe('fetch() method', () => { let axios, base_url, Post, post, id; before(() => { - id = '1234'; + id = '1'; axios = sinon.spy(request => { - const data = _.cloneDeep(require('../fixtures/data/post.json')); - - return Promise.resolve({ - status: 200, - data, + return api(request).then(result => { + return { + status: 200, + data: JSON.parse(result), + }; }); }); @@ -33,24 +34,24 @@ describe('fetch() method', () => { expect(post.fetch()).to.be.instanceOf(Promise); }); - it('should request a specific record', done => { - new Post({ id }).fetch().then(post => { + it('should request a specific record', () => { + return new Post({ id }).fetch().then(post => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: `${base_url}/posts/1234`, + url: `${base_url}/posts/1`, }); - }).then(done).catch(done); + }); }); it('should update the current object with the data it fetches', () => { post = new Post({ id }); - post.fetch().then(new_post => { + return post.fetch().then(new_post => { // While the promise does return the object itself, we want to be sure // that the original `post` object is also updated. expect(post).to.deep.equal(new_post); - expect(post.__data).to.deep.equal(require('../fixtures/post.json').data); + expect(post.__data).to.deep.equal(require('../fixtures/data/posts/1')); }); }); @@ -59,7 +60,7 @@ describe('fetch() method', () => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234', + url: 'https://some.contrived.url/posts/1', }); }); }); @@ -69,7 +70,7 @@ describe('fetch() method', () => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234?include=author', + url: 'https://some.contrived.url/posts/1?include=author', }); }); }); @@ -80,7 +81,7 @@ describe('fetch() method', () => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234?include=author,body', + url: 'https://some.contrived.url/posts/1?include=author,body', }); }); }); @@ -90,7 +91,7 @@ describe('fetch() method', () => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234?include=author,body,category', + url: 'https://some.contrived.url/posts/1?include=author,body,category', }); }); }); @@ -100,31 +101,31 @@ describe('fetch() method', () => { expect(axios).to.have.callCount(7); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234', + url: 'https://some.contrived.url/posts/1', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/author', + url: 'https://some.contrived.url/posts/1/author', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/relationships/author', + url: 'https://some.contrived.url/posts/1/relationships/author', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/body', + url: 'https://some.contrived.url/posts/1/body', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/relationships/body', + url: 'https://some.contrived.url/posts/1/relationships/body', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/category', + url: 'https://some.contrived.url/posts/1/category', }); expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234/relationships/category', + url: 'https://some.contrived.url/posts/1/relationships/category', }); }); }); @@ -141,7 +142,7 @@ describe('fetch() method', () => { expect(axios).to.be.calledOnce; expect(axios).to.be.calledWith({ method: 'get', - url: 'https://some.contrived.url/posts/1234?include=author,category', + url: 'https://some.contrived.url/posts/1?include=author,category', }); }); }); diff --git a/test/model/relationships.js b/test/model/relationships.js index 7a5914e..a9bf84c 100644 --- a/test/model/relationships.js +++ b/test/model/relationships.js @@ -1,20 +1,19 @@ -const _ = require('lodash'); const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); - -const raw_json = require('../fixtures/data/post.json'); +const api = require('../fixtures/api'); +require('../fixtures/config')(); describe('relationships', () => { - let axios, Image, Paragraph, Person, Post, post, Role; - before(done => { + let axios, Image, Paragraph, Person, Post, post, raw_json; //, Role; + before(() => { axios = sinon.spy(request => { - const data = _.cloneDeep(raw_json); - - return Promise.resolve({ - status: 200, - data, + return api(request).then(result => { + return { + status: 200, + data: JSON.parse(result), + } }); }); @@ -22,11 +21,15 @@ describe('relationships', () => { Paragraph = require('../fixtures/models/Paragraph')({ axios }); Person = require('../fixtures/models/Person')({ axios }); Post = require('../fixtures/models/Post')({ axios }); - Role = require('../fixtures/models/Role')({ axios }); + // Role = require('../fixtures/models/Role')({ axios }); - new Post({ id: 1 }).fetch().then(result => { + return api({ url: '/posts/1?include=author,body' }).then(data => { + raw_json = JSON.parse(data); + }).then(() => { + return new Post({ id: 1 }).fetch({ related: true }); + }).then(result => { post = result; - }).then(done).catch(done); + }); }); afterEach(() => axios.resetHistory()); @@ -34,10 +37,12 @@ describe('relationships', () => { it('should load relationships as models', () => { expect(post.author).to.be.instanceOf(Person); - expect(post.author.roles).to.be.instanceOf(Array); - post.author.roles.forEach(role => { - expect(role).to.be.instanceOf(Role); - }); + // Nested relationships can be checked when the new test api and .fetch() + // support them. + // expect(post.author.roles).to.be.instanceOf(Array); + // post.author.roles.forEach(role => { + // expect(role).to.be.instanceOf(Role); + // }); expect(post.body).to.be.instanceOf(Array); post.body.forEach(block => { @@ -46,7 +51,9 @@ describe('relationships', () => { expectedModel = Paragraph; } else if (block.type === 'image') { expectedModel = Image; - expect(block.credit).to.be.instanceOf(Person); + // Nested relationships can be checked when the new test api and + // .fetch() support them. + // expect(block.credit).to.be.instanceOf(Person); } else if (block.type === 'blockquote') { // We’re not defining a dedicated Blockquote model, so we expect it // to return the raw data. @@ -59,9 +66,11 @@ describe('relationships', () => { it('should store a reference to the related record’s immediate parent in the tree', () => { expect(post.author.__parent).to.deep.equal(post); - post.author.roles.forEach(role => { - expect(role.__parent).to.deep.equal(post.author); - }); + // Nested relationships can be checked when the new test api and .fetch() + // support them. + // post.author.roles.forEach(role => { + // expect(role.__parent).to.deep.equal(post.author); + // }); }); it('should load raw related data when a model is not available', () => { diff --git a/test/model/save.js b/test/model/save.js index d0da6e3..198bb5e 100644 --- a/test/model/save.js +++ b/test/model/save.js @@ -3,6 +3,7 @@ const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); +require('../fixtures/config')(); const Model = require('../../model'); diff --git a/test/model/toObject.js b/test/model/toObject.js index 28f2984..f56c763 100644 --- a/test/model/toObject.js +++ b/test/model/toObject.js @@ -1,21 +1,20 @@ -const _ = require('lodash'); const chai = require('chai'); const expect = chai.expect; const sinon = require('sinon'); chai.use(require('sinon-chai')); - -const raw_json = require('../fixtures/data/post.json'); +const api = require('../fixtures/api'); +require('../fixtures/config')(); /* eslint-disable no-unused-vars */ describe('to_object() method', () => { - let axios, Image, Paragraph, Person, Post, post, Role; - before(done => { + let axios, Image, Paragraph, Person, Post, post, raw_json, Role; + before(() => { axios = sinon.spy(request => { - const data = _.cloneDeep(raw_json); - - return Promise.resolve({ - status: 200, - data, + return api(request).then(result => { + return { + status: 200, + data: JSON.parse(result), + } }); }); @@ -23,11 +22,15 @@ describe('to_object() method', () => { Paragraph = require('../fixtures/models/Paragraph')({ axios }); Person = require('../fixtures/models/Person')({ axios }); Post = require('../fixtures/models/Post')({ axios }); - Role = require('../fixtures/models/Role')({ axios }); + // Role = require('../fixtures/models/Role')({ axios }); - new Post({ id: 1 }).fetch({ related: true }).then(result => { + return api({ url: '/posts/1?include=author,body' }).then(data => { + raw_json = JSON.parse(data); + }).then(() => { + return new Post({ id: 1 }).fetch({ related: true }); + }).then(result => { post = result; - }).then(done).catch(done); + }); }); afterEach(() => axios.resetHistory()); @@ -45,8 +48,8 @@ describe('to_object() method', () => { alias: '/authors/testy-mctestface', roles: [ { - name: 'Writer', - url: '/writers', + id: '401', + type: 'role', }, ], }, @@ -58,21 +61,22 @@ describe('to_object() method', () => { url: '/path/to/image.jpg', alt: 'You do provide ALT values, right?', credit: { - fullName: 'Foto McFotoface', - firstName: 'Foto', - lastName: 'McFotoface', - bio: 'It’s film gran all the way down, friend.', - alias: '/authors/foto-mcfotoface', - roles: [ - { - name: 'Writer', - url: '/writers', - }, - { - name: 'Photographer', - url: '/photographers', - }, - ], + fullName: null, + firstName: null, + lastName: null, + bio: null, + alias: null, + roles: null, + /* roles: [ + * { + * name: 'Writer', + * url: '/writers', + * }, + * { + * name: 'Photographer', + * url: '/photographers', + * }, + * ], */ }, }, { diff --git a/test/qs.spec.js b/test/qs.spec.js deleted file mode 100644 index fb6c421..0000000 --- a/test/qs.spec.js +++ /dev/null @@ -1,25 +0,0 @@ -require('should'); -const config = require('./fixtures/config'); -const qs = require('../qs'); - -describe('Jsonmonger#qs', () => { - let result; - before(() => { - result = qs({ config, type: 'post' }); - }); - - it('should return a string with query parameters', () => { - result.should.equal('?field[post]=title,sub_title&include=author,body'); - }); - - it('should include virtual key paths, if provided', () => { - config.schema.post.contrived_virtual = function contrived_virtual() {} - config.schema.post.contrived_virtual.__qs = [ - 'attributes.contrived_attribute', - 'relationships.contrived_relationship', - ]; - - const contrived_result = qs({ config, type: 'post' }); - contrived_result.should.equal('?field[post]=title,sub_title,contrived_attribute&include=author,body,contrived_relationship'); - }); -});