diff --git a/addon/adapters/application.js b/addon/adapters/application.js index cc8957b..720d92f 100644 --- a/addon/adapters/application.js +++ b/addon/adapters/application.js @@ -42,21 +42,30 @@ export default Ember.Object.extend(FetchMixin, Evented, { Find resource(s) using an id or a using a query `{id: '', query: {}}` @method find - @param {Object|String} options use a string for a single id or an object + @param {Object|String|Number} options use a string for a single id or an object. @return {Promise} */ find(options) { - if (typeof options === 'string') { - return this.findOne(options); - } else if (typeof options === 'object') { - if (options.id) { - return this.findOne(options.id, options.query); + // Collect id and query from options (if given). + // Ensure id is String conform JSONAPI specs. + // findOne when id is given, otherwise findQuery. + let id, query; + + if (options !== undefined) { + if (typeof options === 'object') { + query = options; // default + + if (options.hasOwnProperty('id')) { + id = options.id.toString(); + query = options.query; + } } else { - return this.findQuery(options); + id = options.toString(); } - } else { - return this.findQuery(); } + + // this works even for id 0 since it is cast to string. + return id ? this.findOne(id, query) : this.findQuery(query); }, /** @@ -312,7 +321,7 @@ export default Ember.Object.extend(FetchMixin, Evented, { */ _payloadForRelationship(resource, relationship, id) { let data = resource.get(['relationships', relationship, 'data'].join('.')); - let resourceObject = { type: pluralize(relationship), id: id }; + let resourceObject = { type: pluralize(relationship), id: id.toString() }; return { data: (Array.isArray(data)) ? [resourceObject] : resourceObject }; }, diff --git a/addon/models/resource.js b/addon/models/resource.js index 909adb1..73f97bd 100644 --- a/addon/models/resource.js +++ b/addon/models/resource.js @@ -116,7 +116,7 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { @method toString */ toString() { - let type = this.get('type') || 'null'; + let type = singularize(this.get('type')) || 'null'; let id = this.get('id') || 'null'; return `[JSONAPIResource|${type}:${id}]`; }, @@ -189,6 +189,7 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { @param {String} id */ addRelationship(related, id) { + if (id !== undefined) { id = id.toString(); } // ensure String id. let key = ['relationships', related, 'data'].join('.'); let data = this.get(key); let type = pluralize(related); @@ -229,6 +230,8 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { @param {String} id */ removeRelationship(related, id) { + if (id !== undefined) { id = id.toString(); } // ensure String ids. + let relation = this.get('relationships.' + related); if (Array.isArray(relation.data)) { for (let i = 0; i < relation.data.length; i++) { @@ -415,8 +418,14 @@ Resource.reopenClass({ for (let i = 0; i < attrs.length; i++) { prototype[attrs[i]] = {}; } + const instance = this._super(prototype); if (properties) { + if (properties.hasOwnProperty('id')) { + // JSONAPI ids MUST be strings. + properties.id = properties.id.toString(); + } + instance.setProperties(properties); } let type = singularize(instance.get('type')); diff --git a/tests/dummy/app/models/author.js b/tests/dummy/app/models/author.js index 0e1572b..6155df3 100644 --- a/tests/dummy/app/models/author.js +++ b/tests/dummy/app/models/author.js @@ -3,7 +3,7 @@ import Resource from './resource'; import { attr, hasMany } from 'ember-jsonapi-resources/models/resource'; export default Resource.extend({ - type: 'author', + type: 'authors', service: Ember.inject.service('authors'), name: attr('string'), diff --git a/tests/dummy/app/models/comment.js b/tests/dummy/app/models/comment.js index feac9e6..50be0a0 100644 --- a/tests/dummy/app/models/comment.js +++ b/tests/dummy/app/models/comment.js @@ -3,7 +3,7 @@ import Resource from './resource'; import { attr, hasOne } from 'ember-jsonapi-resources/models/resource'; export default Resource.extend({ - type: 'comment', + type: 'comments', service: Ember.inject.service('comments'), body: attr('string'), diff --git a/tests/dummy/app/models/commenter.js b/tests/dummy/app/models/commenter.js index 3dfdbd4..b7bf5e3 100644 --- a/tests/dummy/app/models/commenter.js +++ b/tests/dummy/app/models/commenter.js @@ -3,7 +3,7 @@ import Resource from './resource'; import { attr, hasMany } from 'ember-jsonapi-resources/models/resource'; export default Resource.extend({ - type: 'commenter', + type: 'commenters', service: Ember.inject.service('commenters'), name: attr('string'), diff --git a/tests/dummy/app/models/employee.js b/tests/dummy/app/models/employee.js index 98ea9cd..a24bfee 100644 --- a/tests/dummy/app/models/employee.js +++ b/tests/dummy/app/models/employee.js @@ -1,12 +1,8 @@ -import Ember from 'ember'; -import Resource from './resource'; -import { attr, hasMany } from 'ember-jsonapi-resources/models/resource'; +import PersonResource from './person'; +import { hasOne, hasMany } from 'ember-jsonapi-resources/models/resource'; -export default Resource.extend({ +export default PersonResource.extend({ type: 'employees', - service: Ember.inject.service('employees'), - - name: attr('string'), - - pictures: hasMany('pictures') + pictures: hasMany('pictures'), + supervisor: hasOne({resource: 'supervisor', type: 'employees'}) }); diff --git a/tests/dummy/app/models/person.js b/tests/dummy/app/models/person.js new file mode 100644 index 0000000..26187e3 --- /dev/null +++ b/tests/dummy/app/models/person.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; +import Resource from './resource'; +import { attr } from 'ember-jsonapi-resources/models/resource'; + +export default Resource.extend({ + type: 'people', + service: Ember.inject.service('people'), + name: attr() // can use any value type for an attribute +}); diff --git a/tests/dummy/app/models/post.js b/tests/dummy/app/models/post.js index a2f8b39..01045f9 100644 --- a/tests/dummy/app/models/post.js +++ b/tests/dummy/app/models/post.js @@ -3,7 +3,7 @@ import Resource from './resource'; import { attr, hasOne, hasMany } from 'ember-jsonapi-resources/models/resource'; export default Resource.extend({ - type: 'post', + type: 'posts', service: Ember.inject.service('posts'), title: attr('string'), diff --git a/tests/dummy/app/models/supervisor.js b/tests/dummy/app/models/supervisor.js new file mode 100644 index 0000000..3291a60 --- /dev/null +++ b/tests/dummy/app/models/supervisor.js @@ -0,0 +1,7 @@ +import EmployeeResource from './person'; +import { hasMany } from 'ember-jsonapi-resources/models/resource'; + +export default EmployeeResource.extend({ + type: 'supervisors', + directReports: hasMany('employees') +}); diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 6a185a0..8c04527 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -22,6 +22,7 @@ module.exports = function(environment) { API_PATH: 'api/v1', }, contentSecurityPolicy: { + 'script-src': "'self' 'unsafe-inline' 'unsafe-eval' localhost:49152", 'connect-src': "'self' api.pixelhandler.com localhost:3000 0.0.0.0:3000", 'img-src': "'self' www.gravatar.com" } diff --git a/tests/helpers/resources.js b/tests/helpers/resources.js index ccc023a..3102221 100644 --- a/tests/helpers/resources.js +++ b/tests/helpers/resources.js @@ -1,51 +1,24 @@ -import Resource from 'ember-jsonapi-resources/models/resource'; -import { attr, hasOne, hasMany } from 'ember-jsonapi-resources/models/resource'; import Ember from 'ember'; import RSVP from 'rsvp'; -export const Post = Resource.extend({ - type: 'posts', - title: attr('string'), - excerpt: attr('string'), - "updated-at": attr('date'), - "created-at": attr('date'), - author: hasOne('author'), - comments: hasMany('comments') -}); +import PostResource from 'dummy/models/post'; +import AuthorResource from 'dummy/models/author'; +import CommentResource from 'dummy/models/comment'; +import CommenterResource from 'dummy/models/commenter'; +import PersonResource from 'dummy/models/person'; +import EmployeeResource from 'dummy/models/employee'; +import SupervisorResource from 'dummy/models/supervisor'; +// Even though unused, keep PictureResource here for clarity. (shut up jshint!) +import PictureResource from 'dummy/models/picture'; // jshint ignore:line -export const Author = Resource.extend({ - type: 'authors', - name: attr('string'), - posts: hasMany('posts') -}); - -export const Comment = Resource.extend({ - type: 'comments', - body: attr('string'), - commenter: hasOne('commenter'), - post: hasOne('post') -}); - -export const Commenter = Resource.extend({ - type: 'commenters', - name: attr('string'), - comments: hasMany('comments') -}); - -export const Person = Resource.extend({ - type: 'people', - name: attr() // can use any value type for an attribute -}); - -export const Employee = Person.extend({ - type: 'employees', - supervisor: hasOne({ resource: 'supervisor', type: 'people' }) -}); - -export const Supervisor = Employee.extend({ - type: 'supervisors', - directReports: hasMany({ resource: 'employees', type: 'people' }) -}); +// Remove the service from resources. We're using mock services. +export const Post = PostResource.extend({service: null}); +export const Author = AuthorResource.extend({service: null}); +export const Comment = CommentResource.extend({service: null}); +export const Commenter = CommenterResource.extend({service: null}); +export const Person = PersonResource.extend({service: null}); +export const Employee = EmployeeResource.extend({service: null}); +export const Supervisor = SupervisorResource.extend({service: null}); export function setup() { let opts = { instantiate: false, singleton: false }; diff --git a/tests/unit/adapters/application-test.js b/tests/unit/adapters/application-test.js index 54ac8e2..63c1852 100644 --- a/tests/unit/adapters/application-test.js +++ b/tests/unit/adapters/application-test.js @@ -29,123 +29,247 @@ moduleFor('adapter:application', 'Unit | Adapter | application', { }); test('#find calls #findOne when options arg is a string', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject(); sandbox.stub(adapter, 'findOne', function () { return RSVP.Promise.resolve(null); }); let promise = adapter.find('1'); + + assert.ok(typeof promise.then === 'function', 'returns a thenable'); + promise.then(() => { + assert.ok(adapter.findOne.calledOnce, 'findOne called'); + assert.ok(adapter.findOne.calledWith('1'), 'findOne called with "1"'); + done(); + }); +}); + +test('#find casts options arg of type number to string', function (assert) { + assert.expect(3); + const done = assert.async(); + + const adapter = this.subject(); + sandbox.stub(adapter, 'findOne', function () { return RSVP.Promise.resolve(null); }); + let id = 0; + let promise = adapter.find(id); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.findOne.calledOnce, 'findOne called'); - assert.ok(adapter.findOne.calledWith('1'), 'findOne called with "1"'); + promise.then(() => { + assert.ok(adapter.findOne.calledOnce, 'findOne called'); + assert.ok(adapter.findOne.calledWith(id.toString()), 'findOne called with number id cast to string'); + done(); + }); }); test('#find calls #findOne when options arg is an object having an id property', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject(); sandbox.stub(adapter, 'findOne', function () { return RSVP.Promise.resolve(null); }); - let options = { id: '1', query: {sort: '-date'} }; + let options = {id: '1', query: {sort: '-date'}}; let promise = adapter.find(options); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.findOne.calledOnce, 'findOne called'); - assert.ok(adapter.findOne.calledWith(options.id, options.query), 'findOne called with `"1"` and query `{"sort": "-date"}`'); + promise.then(() => { + assert.ok(adapter.findOne.calledOnce, 'findOne called'); + assert.ok( + adapter.findOne.calledWith('1', options.query), + 'findOne called with `"1"` and query `{"sort": "-date"}`' + ); + done(); + }); }); -test('#find calls #findQuery when options arg is undefined', function(assert) { +test('#find casts options.id to string before calling #findOne', function (assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject(); - sandbox.stub(adapter, 'findQuery', function () { return RSVP.Promise.resolve(null); }); - let promise = adapter.find(undefined); + sandbox.stub(adapter, 'findOne', function () { return RSVP.Promise.resolve(null); }); + let options = {id: 0, query: {sort: '-date'}}; + let promise = adapter.find(options); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.findQuery.calledOnce, 'findQuery called'); + promise.then(() => { + assert.ok(adapter.findOne.calledOnce, 'findOne called'); + assert.ok(adapter.findOne.calledWith(options.id.toString()), 'findOne called with number id cast to string'); + done(); + }); + }); +test('#find calls #findQuery when options arg is an object without an id property', function(assert) { + assert.expect(3); + const done = assert.async(); -test('#find calls #findQuery with options object (that has no id property)', function(assert) { const adapter = this.subject(); sandbox.stub(adapter, 'findQuery', function () { return RSVP.Promise.resolve(null); }); - let options = { query: {sort: '-date'} }; + let options = {query: {sort: '-date'}}; let promise = adapter.find(options); + + assert.ok(typeof promise.then === 'function', 'returns a thenable'); + promise.then(() => { + assert.ok(adapter.findQuery.calledOnce, 'findQuery called'); + assert.ok( + adapter.findQuery.calledWith(options), + 'findQuery called with query `{"sort": "-date"}`' + ); + done(); + }); +}); + +test('#find calls #findQuery when options arg is undefined', function(assert) { + assert.expect(3); + const done = assert.async(); + + const adapter = this.subject(); + sandbox.stub(adapter, 'findQuery', function () { return RSVP.Promise.resolve(null); }); + let promise = adapter.find(undefined); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.findQuery.calledOnce, 'findQuery called'); - assert.ok(adapter.findQuery.calledWith(options), 'findQuery called with query `{"sort": "-date"}`'); + promise.then(() => { + assert.ok(adapter.findQuery.calledOnce, 'findQuery called'); + assert.ok(adapter.findQuery.calledWith(undefined), 'findQuery called with undefined'); + done(); + }); }); test('#findOne calls #fetch with url and options object with method:GET', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let promise = adapter.findOne('1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetch.calledOnce, 'fetch called'); - assert.ok(adapter.fetch.calledWith('/posts/1', { method: 'GET' }), 'fetch called with url and method:GET'); + promise.then(() => { + assert.ok(adapter.fetch.calledOnce, 'fetch called'); + assert.ok( + adapter.fetch.calledWith('/posts/1', { method: 'GET' }), + 'fetch called with url and method:GET' + ); + done(); + }); }); test('#findQuery calls #fetch url and options object with method:GET', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let promise = adapter.findQuery(); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetch.calledOnce, 'fetch called'); - assert.ok(adapter.fetch.calledWith('/posts', { method: 'GET' }), 'fetch called with url and method:GET'); + promise.then(() => { + assert.ok(adapter.fetch.calledOnce, 'fetch called'); + assert.ok( + adapter.fetch.calledWith('/posts', {method: 'GET'}), + 'fetch called with url and method:GET' + ); + done(); + }); }); test('#findQuery calls #fetch url including a query', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let promise = adapter.findQuery({ query: { sort:'-desc' } }); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetch.calledOnce, 'fetch called'); - assert.ok(adapter.fetch.calledWith('/posts?sort=-desc', { method: 'GET' }), 'fetch called with url?query and method:GET'); + promise.then(() => { + assert.ok(adapter.fetch.calledOnce, 'fetch called'); + assert.ok( + adapter.fetch.calledWith('/posts?sort=-desc', {method: 'GET'}), + 'fetch called with url?query and method:GET' + ); + done(); + }); }); test('#findRelated', function(assert) { + assert.expect(3); + const done = assert.async(); + + // Resource to findRelated on. + let resource = this.container.lookup('model:post').create(postMock.data); + let relatedUrl = resource.get('relationships.author.links.related'); + const adapter = this.subject({type: 'posts', url: '/posts'}); + + // We expect the authors service to be used. this.registry.register('service:authors', Adapter.extend({type: 'authors', url: '/authors'})); - let resource = this.container.lookup('model:post').create(postMock.data); - let url = resource.get( ['relationships', 'author', 'links', 'related'].join('.') ); - const adapter = this.subject({type: 'posts', url: '/posts'}); let service = this.container.lookup('service:authors'); - let related = this.container.lookup('model:author').create(authorMock.data); sandbox.stub(service, 'fetch', function () { return RSVP.Promise.resolve(related); }); - let promise = adapter.findRelated('author', url); + + // The related resource we expect to find. + let related = this.container.lookup('model:author').create(authorMock.data); + + let promise = adapter.findRelated('author', relatedUrl); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(service.fetch.calledOnce, 'authors service#fetch method called'); - let expectURL = 'http://api.pixelhandler.com/api/v1/posts/1/author'; - assert.ok(service.fetch.calledWith(expectURL, { method: 'GET' }), 'url for relation passed to service#fetch'); + promise.then(() => { + assert.ok(service.fetch.calledOnce, 'authors service#fetch method called'); + assert.ok( + service.fetch.calledWith(relatedUrl, {method: 'GET'}), + 'url for relation passed to service#fetch' + ); + done(); + }); }); -test('#findRelated can be called with optional type for the resource', function (assert) { +test('#findRelated is called with optional type for the resource', function (assert) { assert.expect(4); const done = assert.async(); - let PersonAdapter = Adapter.extend({type: 'people', url: '/people'}); - PersonAdapter.reopenClass({ isServiceFactory: true }); - this.registry.register('service:people', PersonAdapter.extend()); - let service = this.container.lookup('service:people'); - let supervisor = this.container.lookup('model:employee').create({ + let supervisor = this.container.lookup('model:supervisor').create({ type: 'supervisors', - id: 1000000, - name: 'The Boss', + id: '1000000', + attributes: { + name: 'The Boss', + }, relationships: { employees: { links: { - related: 'http://locahost:3000/api/v1/supervisor/1000000/employees' + related: 'http://api.pixelhandler.com/api/v1/supervisors/1000000/employees' } } } }); + + let EmployeeAdapter = Adapter.extend({type: 'employees', url: '/employees'}); + this.registry.register('service:supervisors', EmployeeAdapter.extend({ + cacheLookup: function() { + return supervisor; + } + })); + EmployeeAdapter.reopenClass({ isServiceFactory: true }); + this.registry.register('service:employees', EmployeeAdapter.extend()); + let service = this.container.lookup('service:employees'); let stub = sandbox.stub(service, 'findRelated', function () { return RSVP.Promise.resolve(supervisor); }); - let resource = this.container.lookup('model:employee').create({ + let employee = this.container.lookup('model:employee').create({ type: 'employees', - id: 1000001, - name: 'The Special', + id: '1000001', + attributes: { + name: 'The Special', + }, relationships: { supervisor: { links: { - related: 'http://locahost:3000/api/v1/employees/1000001/supervisor' + related: 'http://api.pixelhandler.com/api/v1/employees/1000001/supervisor' } } } }); - let url = resource.get( ['relationships', 'supervisor', 'links', 'related'].join('.') ); - resource.get('supervisor').then(function() { - assert.ok(stub.calledOnce, 'people service findRelated method called once'); + let url = employee.get('relationships.supervisor.links.related'); + employee.get('supervisor').then(() => { + assert.ok(stub.calledOnce, 'employees service findRelated method called once'); assert.equal(stub.firstCall.args[0].resource, 'supervisor', 'findRelated called with supervisor resource'); - assert.equal(stub.firstCall.args[0].type, 'people', 'findRelated called with people type'); + assert.equal(stub.firstCall.args[0].type, 'employees', 'findRelated called with employees type'); assert.equal(stub.firstCall.args[1], url, 'findRelated called with url, ' + url); done(); }); @@ -154,28 +278,38 @@ test('#findRelated can be called with optional type for the resource', function test('#createResource', function(assert) { assert.expect(6); let done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); + // create new resource (without id, which "server" (response) assigns). let postFactory = this.container.lookup('model:post'); - let data = JSON.parse(JSON.stringify(postMock.data)); + let data = JSON.parse(JSON.stringify(postMock.data)); // clones postMock.data delete data.id; let newResource = postFactory.create(data); + assert.equal(newResource.get('id'), null, 'new resource does not have an id'); + adapter.serializer = { serialize: function () { return data; } }; let persistedResource = postFactory.create(postMock.data); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(persistedResource); }); let promise = adapter.createResource(newResource); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetch.calledOnce, '#fetch method called'); - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith('/posts', { method: 'POST', body: JSON.stringify(data) }), msg); - promise.then(function(resp) { - assert.ok(resp === newResource, 'response is the same resource instance sent as an arg'); - assert.equal(resp.get('id'), postMock.data.id, 'new resource now has an id'); + promise.then((resp) => { + assert.ok(adapter.fetch.calledOnce, '#fetch method called'); + assert.ok( + adapter.fetch.calledWith('/posts', {method: 'POST', body: JSON.stringify(data)}), + '#fetch called with url and options with data' + ); + assert.equal(newResource.get('id'), postMock.data.id, 'new resource now has an id'); + assert.deepEqual(resp, newResource, 'response is the same resource instance sent as arg'); done(); }); }); test('#updateResource', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); let payload = { data: { @@ -191,153 +325,279 @@ test('#updateResource', function(assert) { let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.updateResource(resource); assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetch.calledOnce, '#fetch method called'); - let selfURL = 'http://api.pixelhandler.com/api/v1/posts/1'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(selfURL, { method: 'PATCH', body: JSON.stringify(payload), update: true }), msg); + promise.then(() => { + assert.ok(adapter.fetch.calledOnce, '#fetch method called'); + assert.ok( + adapter.fetch.calledWith( + postMock.data.links.self, + {method: 'PATCH', body: JSON.stringify(payload), update: true} + ), + '#fetch called with url and options with data' + ); + done(); + }); }); test('when serializer returns null (nothing changed) #updateResource return promise is resolved with null', function(assert) { + assert.expect(3); const done = assert.async(); + let adapter = this.subject({type: 'posts', url: '/posts'}); adapter.serializer = { serializeChanged: function () { return null; } }; sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.updateResource(resource); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function(resolution) { - assert.equal(resolution, null, 'null returned instead of promise'); - assert.ok(!adapter.fetch.calledOnce, '#fetch method NOT called'); + promise.then((resolution) => { + assert.equal(resolution, null, 'null returned instead of '); + assert.ok(!adapter.fetch.called, '#fetch method NOT called'); done(); }); }); test('#createRelationship (to-many)', function(assert) { + assert.expect(2); const done = assert.async(); + mockServices.call(this); let adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.createRelationship(resource, 'comments', '1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/comments'; - let jsonBody = '{"data":[{"type":"comments","id":"1"}]}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'POST', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.comments.links.self, + {method: 'POST', body: jsonBody} + ), + '#fetch called with url and options with data' + ); done(); }); }); test('#createRelationship (to-one)', function(assert) { + assert.expect(2); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); mockServices.call(this); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.createRelationship(resource, 'author', '1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/author'; - let jsonBody = '{"data":{"type":"authors","id":"1"}}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'POST', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: {type: 'authors', id: '1'}}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.author.links.self, + {method: 'POST', body: jsonBody} + ), + '#fetch called with url and options with data' + ); + done(); + }); }); test('#deleteRelationship (to-many)', function(assert) { + assert.expect(2); const done = assert.async(); + mockServices.call(this); let adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.deleteRelationship(resource, 'comments', '1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/comments'; - let jsonBody = '{"data":[{"type":"comments","id":"1"}]}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'DELETE', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.comments.links.self, + {method: 'DELETE', body: jsonBody} + ), + '#fetch called with url and options with data' + ); done(); }); }); test('#deleteRelationship (to-one)', function(assert) { - const adapter = this.subject({type: 'posts', url: '/posts'}); + assert.expect(2); + const done = assert.async(); + mockServices.call(this); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.deleteRelationship(resource, 'author', '1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/author'; - let jsonBody = '{"data":{"type":"authors","id":"1"}}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'DELETE', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: {type: 'authors', id: '1'}}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.author.links.self, + {method: 'DELETE', body: jsonBody} + ), + '#fetch called with url and options with data' + ); + done(); + }); }); test('#patchRelationship (to-many)', function(assert) { + assert.expect(2); const done = assert.async(); + mockServices.call(this); let adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); resource.addRelationship('comments', '1'); let promise = adapter.patchRelationship(resource, 'comments'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/comments'; - let jsonBody = '{"data":[{"type":"comments","id":"1"}]}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'PATCH', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.comments.links.self, + {method: 'PATCH', body: jsonBody} + ), + '#fetch called with url and options with data' + ); done(); }); }); test('#patchRelationship (to-one)', function(assert) { - const adapter = this.subject({type: 'posts', url: '/posts'}); + assert.expect(2); + const done = assert.async(); + mockServices.call(this); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); resource.addRelationship('author', '1'); let promise = adapter.patchRelationship(resource, 'author'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - let relationURL = 'http://api.pixelhandler.com/api/v1/posts/1/relationships/author'; - let jsonBody = '{"data":{"type":"authors","id":"1"}}'; - let msg = '#fetch called with url and options with data'; - assert.ok(adapter.fetch.calledWith(relationURL, { method: 'PATCH', body: jsonBody }), msg); + promise.then(() => { + let jsonBody = JSON.stringify({data: {type: 'authors', id: '1'}}); + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.author.links.self, + {method: 'PATCH', body: jsonBody} + ), + '#fetch called with url and options with data' + ); + done(); + }); }); +// Even though create- and deleteRelationship both use _payloadForRelationship, +// which does the casting of id to String, we test them seperately to ensure this +// is always tested, even when internals change. +test('createRelationship casts id to string', function (assert) { + assert.expect(2); + const done = assert.async(); + + mockServices.call(this); + const adapter = this.subject({type: 'posts', url: '/posts'}); + sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); + let resource = this.container.lookup('model:post').create(postMock.data); + let createPromise = adapter.createRelationship(resource, 'comments', 1); + let deletePromise = adapter.deleteRelationship(resource, 'comments', 1); + + let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); + createPromise.then(() => { + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.comments.links.self, + {method: 'POST', body: jsonBody} + ), + '#createRelationship casts id to String' + ); + return deletePromise; + }).then(() => { + assert.ok( + adapter.fetch.calledWith( + postMock.data.relationships.comments.links.self, + {method: 'DELETE', body: jsonBody} + ), + '#deleteRelationship casts id to String' + ); + done(); + }); +}); + + test('#deleteResource can be called with a string as the id for the resource', function(assert) { + assert.expect(2); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let promise = adapter.deleteResource('1'); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - let msg = '#fetch called with url'; - assert.ok(adapter.fetch.calledWith('/posts/1', { method: 'DELETE' }), msg); + promise.then(() => { + assert.ok( + adapter.fetch.calledWith('/posts/1', {method: 'DELETE'}), + '#fetch called with url' + ); + done(); + }); }); test('#deleteResource can be called with a resource having a self link, and calls resource#destroy', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); sandbox.stub(resource, 'destroy', function () {}); let promise = adapter.deleteResource(resource); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(resource.destroy.calledOnce, 'resource#destroy method called'); - let selfURL = 'http://api.pixelhandler.com/api/v1/posts/1'; - let msg = '#fetch called with url'; - assert.ok(adapter.fetch.calledWith(selfURL, { method: 'DELETE' }), msg); + promise.then(() => { + assert.ok(resource.destroy.calledOnce, 'resource#destroy method called'); + assert.ok( + adapter.fetch.calledWith(postMock.data.links.self, {method: 'DELETE'}), + '#fetch called with url' + ); + done(); + }); }); test('when called with resource argument, #deleteResource calls #cacheRemove', function(assert) { + assert.expect(1); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); sandbox.stub(adapter, 'cacheRemove', function () {}); - Ember.run(function() { + Ember.run(() => { adapter.deleteResource(resource); }); + assert.ok(adapter.cacheRemove.calledOnce, 'adapter#cacheRemove called'); + done(); }); test('#fetch calls #fetchURL to customize if needed', function(assert) { + assert.expect(2); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetchUrl', function () {}); sandbox.stub(window, 'fetch', function () { @@ -346,14 +606,20 @@ test('#fetch calls #fetchURL to customize if needed', function(assert) { "text": function() { return RSVP.Promise.resolve(''); } }); }); - let promise = adapter.fetch('/posts', { method: 'PATCH', body: 'json string here' }); + let url = '/posts'; + let promise = adapter.fetch(url, {method: 'PATCH', body: 'json string here'}); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.ok(adapter.fetchUrl.calledWith('/posts'), '#fetchUrl called with url'); + promise.then(() => { + assert.ok(adapter.fetchUrl.calledWith(url), '#fetchUrl called with url'); + done(); + }); }); test('#fetch calls #fetchOptions checking if the request is an update, if true skips call to deserialize', function(assert) { - const done = assert.async(); assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetchUrl', function () {}); sandbox.stub(window, 'fetch', function () { @@ -366,24 +632,26 @@ test('#fetch calls #fetchOptions checking if the request is an update, if true s }); sandbox.stub(adapter, 'cacheResource', function () {}); adapter.serializer = { deserialize: sandbox.spy(), transformAttributes: sandbox.spy() }; - let promise = adapter.fetch('/posts', { method: 'PATCH', body: 'json string here', update: true }); + let promise = adapter.fetch('/posts', {method: 'PATCH', body: 'json string here', update: true}); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { - assert.equal(adapter.serializer.deserialize.callCount, 0, '#deserialize method NOT called'); - assert.equal(adapter.serializer.transformAttributes.callCount, 1, '#transformAttributes method called'); + promise.then(() => { + assert.ok(!adapter.serializer.deserialize.called, '#deserialize method NOT called'); + assert.ok(adapter.serializer.transformAttributes.calledOnce, '#transformAttributes method called once'); done(); }); }); test('#fetchUrl', function(assert) { const adapter = this.subject(); - let url = adapter.fetchUrl('/posts'); - assert.equal(url, '/posts', 'returns url'); + let url = '/posts'; + assert.equal(adapter.fetchUrl(url), url, 'returns url'); }); test('#cacheResource called after successful fetch', function(assert) { assert.expect(2); const done = assert.async(); + const adapter = this.subject(); sandbox.stub(adapter, 'cacheResource', function () {}); adapter.serializer = { @@ -398,9 +666,10 @@ test('#cacheResource called after successful fetch', function(assert) { } }); }); - let promise = adapter.fetch('/posts/1', { method: 'GET' }); + let promise = adapter.fetch('/posts/1', {method: 'GET'}); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { + promise.then(() => { assert.ok(adapter.cacheResource.calledOnce, '#cacheResource method called'); done(); }); @@ -409,6 +678,7 @@ test('#cacheResource called after successful fetch', function(assert) { test('#cacheUpdate called after #updateResource success', function(assert) { assert.expect(2); const done = assert.async(); + const adapter = this.subject(); sandbox.stub(adapter, 'cacheUpdate', function () {}); sandbox.stub(window, 'fetch', function () { @@ -434,9 +704,10 @@ test('#cacheUpdate called after #updateResource success', function(assert) { }; let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.updateResource(resource); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { - assert.ok(adapter.cacheUpdate.calledOnce, '#cacheResource method called'); + promise.then(() => { + assert.ok(adapter.cacheUpdate.calledOnce, '#cacheUpdate method called'); done(); }); }); @@ -444,6 +715,7 @@ test('#cacheUpdate called after #updateResource success', function(assert) { test('serializer#deserializeIncluded called after successful fetch', function(assert) { assert.expect(2); const done = assert.async(); + const adapter = this.subject(); adapter.serializer = { deserialize: function () { return postMock.data; }, @@ -459,7 +731,7 @@ test('serializer#deserializeIncluded called after successful fetch', function(as }); let promise = adapter.fetch('/posts/1', { method: 'GET' }); assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { + promise.then(() => { assert.ok(adapter.serializer.deserializeIncluded.calledOnce, '#deserializeIncluded method called'); done(); }); @@ -469,6 +741,7 @@ test('serializer#deserializeIncluded called after successful fetch', function(as test('#fetch handles 5xx (ServerError) response status', function(assert) { assert.expect(3); const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(window, 'fetch', function () { return RSVP.Promise.resolve({ @@ -478,9 +751,10 @@ test('#fetch handles 5xx (ServerError) response status', function(assert) { } }); }); - let promise = adapter.fetch('/posts', { method: 'POST', body: 'json string here' }); + let promise = adapter.fetch('/posts', {method: 'POST', body: 'json string here'}); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.catch(function(error) { + promise.catch((error) => { assert.equal(error.name, 'ServerError', '5xx response throws a custom error'); assert.equal(error.code, 500, 'error code 500'); done(); @@ -490,28 +764,33 @@ test('#fetch handles 5xx (ServerError) response status', function(assert) { test('#fetch handles 4xx (Client Error) response status', function(assert) { assert.expect(5); const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); + const mockError = {errors: [{status: 404, title: 'I am an error'}]}; sandbox.stub(adapter, 'fetchUrl', function () {}); sandbox.stub(window, 'fetch', function () { return RSVP.Promise.resolve({ "status": 404, "text": function() { - return RSVP.Promise.resolve('{ "errors": [ { "status": 404 } ] }'); + return RSVP.Promise.resolve(JSON.stringify(mockError)); } }); }); let promise = adapter.fetch('/posts', { method: 'POST', body: 'json string here' }); assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.catch(function(error) { + promise.catch((error) => { assert.ok(error.name, 'Client Error', '4xx response throws a custom error'); - assert.ok(Array.isArray(error.errors), '4xx error includes errors'); - assert.equal(error.errors[0].status, 404, '404 error status is in errors list'); - assert.equal(error.code, 404, 'error code 404'); + assert.equal(error.code, 404, 'error code 404 from response status'); + assert.ok(Array.isArray(error.errors), 'response includes errors from `text`'); + assert.deepEqual(error.errors, mockError.errors, 'response errors object intact'); done(); }); }); test('#fetch handles 204 (Success, no content) response status w/o calling deserialize/cacheResource', function(assert) { + assert.expect(3); + const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(adapter, 'fetchUrl', function () {}); sandbox.stub(window, 'fetch', function () { @@ -521,16 +800,21 @@ test('#fetch handles 204 (Success, no content) response status w/o calling deser }); }); sandbox.stub(adapter, 'cacheResource', function () {}); - adapter.serializer = { deserialize: sandbox.spy(), deserializeIncluded: Ember.K }; - let promise = adapter.fetch('/posts', { method: 'PATCH', body: 'json string here' }); + adapter.serializer = {deserialize: sandbox.spy(), deserializeIncluded: Ember.K}; + let promise = adapter.fetch('/posts', {method: 'PATCH', body: 'json string here'}); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - assert.equal(adapter.cacheResource.callCount, 0, '#cacheResource method NOT called'); - assert.equal(adapter.serializer.deserialize.callCount, 0, '#deserialize method NOT called'); + promise.then(() => { + assert.ok(!adapter.cacheResource.called, '#cacheResource method NOT called'); + assert.ok(!adapter.serializer.deserialize.called, '#deserialize method NOT called'); + done(); + }); }); test('#fetch handles 200 (Success) response status', function(assert) { assert.expect(3); const done = assert.async(); + const adapter = this.subject({type: 'posts', url: '/posts'}); sandbox.stub(window, 'fetch', function () { return RSVP.Promise.resolve({ @@ -541,8 +825,9 @@ test('#fetch handles 200 (Success) response status', function(assert) { sandbox.stub(adapter, 'cacheResource', function () {}); adapter.serializer = { deserialize: sandbox.spy(), deserializeIncluded: Ember.K }; let promise = adapter.fetch('/posts/1', { method: 'GET' }); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); - promise.then(function() { + promise.then(() => { assert.ok(adapter.cacheResource.calledOnce, '#cacheResource method called'); assert.ok(adapter.serializer.deserialize.calledOnce, '#deserialize method called'); done(); @@ -553,24 +838,38 @@ test('it uses the authorization mixin to define the property authorizationCreden const credential = 'supersecrettokenthatnobodycancrack'; window.localStorage.setItem('AuthorizationHeader', credential); const adapter = this.subject(); - let msg = 'authorizationCredential property reads localStorage["AuthorizationHeader"] value'; - assert.equal(adapter.get('authorizationCredential'), credential, msg); + + assert.equal( + adapter.get('authorizationCredential'), + credential, + 'authorizationCredential property reads localStorage["AuthorizationHeader"] value' + ); }); test('#fetchAuthorizationHeader sets Authorization option for #fetch', function(assert) { const adapter = this.subject({}); let credential = 'supersecrettokenthatnobodycancrack'; adapter.set('authorizationCredential', credential); - let option = { headers: {} }; + let option = {headers: {}}; adapter.fetchAuthorizationHeader(option); - assert.equal(option.headers['Authorization'], credential, 'Authorization header set to' + credential); + + assert.equal( + option.headers['Authorization'], + credential, + 'Authorization header set to' + credential + ); }); test('#fetchAuthorizationHeader uses an option passed in by caller', function(assert) { const adapter = this.subject(); - let option = { headers: {"Authorization": "secretToken"} }; + let option = {headers: {"Authorization": "secretToken"}}; adapter.fetchAuthorizationHeader(option); - assert.equal(option.headers['Authorization'], "secretToken", 'Authorization header set to "secretToken"'); + + assert.equal( + option.headers['Authorization'], + "secretToken", + 'Authorization header set to "secretToken"' + ); }); test('re-opening AuthorizationMixin can customize the settings for Authorization credentials', function(assert) { diff --git a/tests/unit/mixins/resource-operations-test.js b/tests/unit/mixins/resource-operations-test.js index c89f152..1f0cc47 100644 --- a/tests/unit/mixins/resource-operations-test.js +++ b/tests/unit/mixins/resource-operations-test.js @@ -113,6 +113,7 @@ test('deleteRelationship for to-many relation', function(assert) { ); }); + test('updateRelationship for to-one relation', function(assert) { this.sandbox.stub(this.subject, '_updateRelationshipsData'); diff --git a/tests/unit/models/resource-test.js b/tests/unit/models/resource-test.js index a15c7ad..d6ba535 100644 --- a/tests/unit/models/resource-test.js +++ b/tests/unit/models/resource-test.js @@ -1,6 +1,6 @@ import { moduleFor, test } from 'ember-qunit'; -import Ember from 'ember'; import RSVP from 'rsvp'; +import Ember from 'ember'; import Resource from 'ember-jsonapi-resources/models/resource'; import { attr } from 'ember-jsonapi-resources/models/resource'; import { setup, teardown, mockServices } from 'dummy/tests/helpers/resources'; @@ -33,11 +33,19 @@ test('it creates an instance, default flag for isNew is false', function(assert) assert.equal(resource.get('isNew'), false, 'default value for isNew flag set to `false`'); }); +test('in creating instances, ids are cast to string', function (assert) { + let id = 1; + let post = this.container.lookup('model:post').create({ + id: id, attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} + }); + assert.strictEqual(post.get('id'), id.toString(), 'new instance id cast to string'); +}); + test('#toString method', function(assert) { let resource = this.subject(); let stringified = resource.toString(); assert.equal(stringified, '[JSONAPIResource|resource:null]', 'resource.toString() is ' + stringified); - resource.setProperties({id: '1', type: 'post'}); + resource.setProperties({id: '1', type: 'posts'}); stringified = resource.toString(); assert.equal(stringified, '[JSONAPIResource|post:1]', 'resource.toString() is ' + stringified); }); @@ -173,25 +181,47 @@ test('#didUpdateResource does nothing if json argument has an id that does not m }); test('#addRelationship', function(assert) { - let post = this.container.lookup('model:post').create({ - id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); - post.addRelationship('author', '2'); - let authorRelation = '{"author":{"links":{},"data":{"type":"authors","id":"2"}},"comments":{"links":{},"data":[]}}'; - assert.equal(JSON.stringify(post.get('relationships')), authorRelation, 'added relationship for author'); + // create resource with relation from json payload. let comment = this.container.lookup('model:comment').create({ id: '4', attributes: {body: 'Wyatt become a deputy too.' }, relationships: { commenter: { data: { type: 'commenter', id: '3' } } } }); - let commenterRelation = '{"commenter":{"data":{"type":"commenter","id":"3"},"links":{}},"post":{"links":{},"data":null}}'; - assert.equal(JSON.stringify(comment.get('relationships')), commenterRelation, 'added commenter relationship to comment'); + let commenterRelation = {links: {}, data: {type: 'commenter', id: '3'}}; + assert.deepEqual(comment.get('relationships').commenter, + commenterRelation, + 'created comment with commenter relationship from json payload'); + // create resource and add relationships through .addRelationship() + // make sure both relationships exist after all manipulations. + let post = this.container.lookup('model:post').create({ + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} + }); + post.addRelationship('author', '2'); + let authorRelation = {links: {}, data: {type: 'authors', id: '2'}}; post.addRelationship('comments', '4'); - let postRelations = '{"author":{"links":{},"data":{"type":"authors","id":"2"}},"comments":{"links":{},"data":[{"type":"comments","id":"4"}]}}'; - assert.equal(JSON.stringify(post.get('relationships')), postRelations, 'added relationship for comment'); + let commentsRelation = {links: {}, data: [{type: 'comments', id: '4'}]}; + + assert.deepEqual(post.get('relationships').author, + authorRelation, + 'added author relationship to post'); + assert.deepEqual(post.get('relationships').comments, + commentsRelation, + 'added relationship for comment to post'); +}); + +test('#addRelationship cast id to string', function (assert) { + let post = this.container.lookup('model:post').create({ + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} + }); + post.addRelationship('author', 1); + let authorRelation = {links: {}, data: {type: 'authors', id: '1'}}; + assert.deepEqual(post.get('relationships').author, + authorRelation, + 'add relationship with id of type number gets converted to string'); }); test('#removeRelationship', function(assert) { + // set up models and their relations through create with json payload. let post = this.container.lookup('model:post').create({ id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, relationships: { @@ -214,54 +244,117 @@ test('#removeRelationship', function(assert) { let comment = this.container.lookup('model:comment').create({ id: '4', attributes: { body: 'Wyatt become a deputy too.' }, relationships: { - commenter: { data: { type: 'commenter', id: '3' }, links: { related: ''} }, + commenter: { data: { type: 'commenters', id: '3' }, links: { related: ''} }, post: { data: { type: 'posts', id: '1' }, links: { related: ''} } } }); - let authorRelations = '{"posts":{"data":[{"type":"posts","id":"1"}],"links":{"related":""}}}'; - assert.equal(JSON.stringify(author.get('relationships')), authorRelations, 'author relations have a post'); - - let postRelations = '{"author":{"data":{"type":"authors","id":"2"},"links":{"related":""}},"comments":{"data":[{"type":"comments","id":"4"}],"links":{"related":""}}}'; - assert.equal(JSON.stringify(post.get('relationships')), postRelations, 'author relations have a post'); - - let commentRelations = '{"commenter":{"data":{"type":"commenter","id":"3"},"links":{"related":""}},"post":{"data":{"type":"posts","id":"1"},"links":{"related":""}}}'; - assert.equal(JSON.stringify(comment.get('relationships')), commentRelations, 'comment relations have a commenter'); - - let commenterRelations = '{"comments":{"data":[{"type":"comments","id":"4"}],"links":{"related":""}}}'; - assert.equal(JSON.stringify(commenter.get('relationships')), commenterRelations, 'commenter relations have a comment'); + // Test for correct representation of relationships. + let authorPostsRelation = {data: [{type: 'posts', id: '1'}], links: {related: ''}}; + assert.deepEqual(author.get('relationships.posts'), + authorPostsRelation, + 'author relations have a post (hasMany)'); + + let postAuthorRelation = {data: {type: 'authors', id: '2'}, links: {related: ''}}; + let postCommentsRelation = {data: [{type: 'comments', id: '4'}], links: {related: ''}}; + assert.deepEqual(post.get('relationships.author'), + postAuthorRelation, + 'post relations have an author (hasOne)'); + assert.deepEqual(post.get('relationships.comments'), + postCommentsRelation, + 'post relations have a comment (hasMany)'); + + let commentCommenterRelation = {data: {type: 'commenters', id: '3'}, links: {related: ''}}; + let commentPostRelation = {data: {type: 'posts', id: '1'}, links: {related: ''}}; + assert.deepEqual(comment.get('relationships.commenter'), + commentCommenterRelation, + 'comment relations have a commenter (hasOne)'); + assert.deepEqual(comment.get('relationships.post'), + commentPostRelation, + 'comment relations have a post (hasOne)'); + + let commenterCommentsRelation = {data: [{type: 'comments', id: '4'}], links: {related: ''}}; + assert.deepEqual(commenter.get('relationships.comments'), + commenterCommentsRelation, + 'commenter relations have a comment (hasMany)'); + + // Remove relationships and test for correct representation of relationships. post.removeRelationship('author', '2'); - let postAuthorRelation = '{"data":null,"links":{"related":""}}'; - assert.equal(JSON.stringify(post.get('relationships.author')), postAuthorRelation, 'removed author from post, author relation ok'); - let postCommentsRelation = '{"data":[{"type":"comments","id":"4"}],"links":{"related":""}}'; - assert.equal(JSON.stringify(post.get('relationships.comments')), postCommentsRelation, 'removed author from post, comments relation ok'); + // author relationship must still exist, but empty (hasOne == null) + postAuthorRelation.data = null; + assert.deepEqual(post.get('relationships.author'), + postAuthorRelation, + 'removed author from post, author relation now empty'); + // relationship to comments must be unchanged. + assert.deepEqual(post.get('relationships.comments'), + postCommentsRelation, + 'removed author from post, comments relation unchanged'); post.removeRelationship('comments', '4'); - postAuthorRelation = '{"data":null,"links":{"related":""}}'; - assert.equal(JSON.stringify(post.get('relationships.author')), postAuthorRelation, 'removed comment from post, author relation ok'); - postCommentsRelation = '{"data":[],"links":{"related":""}}'; - assert.equal(JSON.stringify(post.get('relationships.comments')), postCommentsRelation, 'removed comment from post, comments relation ok'); + // comments relationship must still exist, but empty (hasMany == empty array) + postCommentsRelation.data = []; + // author relationship must be unchanged. + assert.deepEqual(post.get('relationships.comments'), + postCommentsRelation, + 'removed comment from post, comments relation now empty'); + assert.deepEqual(post.get('relationships.author'), + postAuthorRelation, + 'removed comment from post, author relation unchanged'); author.removeRelationship('posts', '1'); - authorRelations = '{"posts":{"data":[],"links":{"related":""}}}'; - assert.equal(JSON.stringify(author.get('relationships')), authorRelations, 'removed a post from author'); + // posts relation must still exist, but empty (hasMany == empty array) + authorPostsRelation.data = []; + assert.deepEqual(author.get('relationships.posts'), + authorPostsRelation, + 'removed a post from author, posts relation now empty'); comment.removeRelationship('commenter', '3'); - let commentCommenterRelations = '{"data":null,"links":{"related":""}}'; - assert.equal(JSON.stringify(comment.get('relationships.commenter')), commentCommenterRelations, 'removed a commenter from comment, commenter relation ok'); - let commentPostRelations = '{"data":{"type":"posts","id":"1"},"links":{"related":""}}'; - assert.equal(JSON.stringify(comment.get('relationships.post')), commentPostRelations, 'removed a commenter from comment, post relation ok'); + // comment relation must still exist, but empty (hasOne == null) + commentCommenterRelation.data = null; + assert.deepEqual(comment.get('relationships.commenter'), + commentCommenterRelation, + 'removed a commenter from comment, commenter relation now empty'); + assert.deepEqual(comment.get('relationships.post'), + commentPostRelation, + 'removed a commenter from comment, post relation unchanged'); comment.removeRelationship('post', '1'); - commentCommenterRelations = '{"data":null,"links":{"related":""}}'; - assert.equal(JSON.stringify(comment.get('relationships.commenter')), commentCommenterRelations, 'removed a post from comment, commenter relation ok'); - commentPostRelations = '{"data":null,"links":{"related":""}}'; - assert.equal(JSON.stringify(comment.get('relationships.post')), commentPostRelations, 'removed a post from comment, post relation ok'); + commentPostRelation.data = null; + assert.deepEqual(comment.get('relationships.post'), + commentPostRelation, + 'removed a post from comment, post relation now empty'); + assert.deepEqual(comment.get('relationships.commenter'), + commentCommenterRelation, + 'removed a post from comment, commenter relation unchanged'); commenter.removeRelationship('comments', '4'); - commenterRelations = '{"comments":{"data":[],"links":{"related":""}}}'; - assert.equal(JSON.stringify(commenter.get('relationships')), commenterRelations, 'removed a comment from commenter'); + // comments relation must still exist, but empty (hasMany == empty array) + commenterCommentsRelation.data = []; + assert.deepEqual(commenter.get('relationships.comments'), + commenterCommentsRelation, + 'removed a comment from commenter, comments relation now empty'); +}); + +test('#removeRelationship casts id to string', function (assert) { + // set up model and its relations through create with json payload. + let post = this.container.lookup('model:post').create({ + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, + relationships: { + comments: { + data: [ + {type: 'comments', id: '4'}, + {type: 'comments', id: '5'}, + ], + links: {related: ''} + } + } + }); + let postCommentsRelation = {data: [{type: 'comments', id: '4'}], links: {related: ''}}; + post.removeRelationship('comments', 5); + assert.deepEqual(post.get('relationships.comments'), + postCommentsRelation, + 'comment relationship removed using number as id'); }); test('#addRelationships', function(assert) { @@ -296,49 +389,6 @@ test('#removeRelationships', function(assert) { assert.equal(author, null, 'removed author'); }); -test('#updateRelationship, from resource-operations mixin', function(assert) { - let serviceOp = this.sandbox.spy(function() { - return RSVP.Promise.resolve(null); - }); - let post = this.container.lookup('model:post').create({ - id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, - relationships: { - author: { data: { type: 'authors', id: '2' }, links: { related: 'url-here'} }, - comments: { data: [{ type: 'comments', id: '4' }], links: { related: 'url-here'} } - }, - // mock service - service: { patchRelationship: serviceOp } - }); - let author = post.get('relationships.author.data'); - let comments = post.get('relationships.comments.data'); - assert.equal(author.id, 2, 'post has author id 2'); - - post.updateRelationship('comments', ['4', '5']); - comments = post.get('relationships.comments.data'); - assert.ok(serviceOp.calledOnce, 'service#patchRelationship called once'); - assert.equal(comments.length, 2, 'post has 2 comments'); - - post.updateRelationship('comments', ['1', '2', '3', '4']); - comments = post.get('relationships.comments.data'); - assert.equal(comments.length, 5, 'post has 5 comments'); - - post.updateRelationship('comments', ['1', '2']); - comments = post.get('relationships.comments.data'); - assert.equal(comments.length, 2, 'post has 2 comments'); - - post.updateRelationship('comments', []); - comments = post.get('relationships.comments.data'); - assert.equal(comments.length, 0, 'post has 0 comments'); - - post.updateRelationship('author', '1'); - author = post.get('relationships.author.data'); - assert.equal(author.id, 1, 'author id changed to 1'); - - post.updateRelationship('author', null); - author = post.get('relationships.author.data'); - assert.equal(author, null, 'author removed'); -}); - test('#didResolveProxyRelation', function(assert) { let post = this.container.lookup('model:post').create({ id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, @@ -411,3 +461,48 @@ test('#isCacheExpired is false when local timestamp plus cacheDuration is less t }); assert.equal(resource.get('isCacheExpired'), false, 'cache duration is in the future'); }); + +// TODO: Rewrite this test in tests/unit/mixins/resource-operations-test.js +test('#updateRelationship, from resource-operations mixin', function(assert) { + let serviceOp = this.sandbox.spy(function() { + return RSVP.Promise.resolve(null); + }); + let post = this.container.lookup('model:post').create({ + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, + relationships: { + author: { data: { type: 'authors', id: '2' }, links: { related: 'url-here'} }, + comments: { data: [{ type: 'comments', id: '4' }], links: { related: 'url-here'} } + }, + // mock service + service: { patchRelationship: serviceOp } + }); + let author = post.get('relationships.author.data'); + let comments = post.get('relationships.comments.data'); + assert.equal(author.id, 2, 'post has author id 2'); + + post.updateRelationship('comments', ['4', '5']); + comments = post.get('relationships.comments.data'); + assert.ok(serviceOp.calledOnce, 'service#patchRelationship called once'); + assert.equal(comments.length, 2, 'post has 2 comments'); + + post.updateRelationship('comments', ['1', '2', '3', '4']); + comments = post.get('relationships.comments.data'); + assert.equal(comments.length, 5, 'post has 5 comments'); + + post.updateRelationship('comments', ['1', '2']); + comments = post.get('relationships.comments.data'); + assert.equal(comments.length, 2, 'post has 2 comments'); + + post.updateRelationship('comments', []); + comments = post.get('relationships.comments.data'); + assert.equal(comments.length, 0, 'post has 0 comments'); + + post.updateRelationship('author', '1'); + author = post.get('relationships.author.data'); + assert.equal(author.id, 1, 'author id changed to 1'); + + post.updateRelationship('author', null); + author = post.get('relationships.author.data'); + assert.equal(author, null, 'author removed'); +}); + diff --git a/tests/unit/serializers/application-test.js b/tests/unit/serializers/application-test.js index bd33f28..584cbb9 100644 --- a/tests/unit/serializers/application-test.js +++ b/tests/unit/serializers/application-test.js @@ -132,7 +132,7 @@ test('#deserializeResource', function(assert) { assert.equal(resource.get('type'), postMock.data.type, 'type present in resource'); assert.equal(resource.get('title'), postMock.data.attributes.title, 'title present in resource'); assert.equal(resource.get('excerpt'), postMock.data.attributes.excerpt, 'excerpt present in resource'); - assert.equal(resource.toString(), '[JSONAPIResource|posts:1]'); + assert.equal(resource.toString(), '[JSONAPIResource|post:1]'); }); test('#deserializeIncluded', function(assert) {