diff --git a/addon/adapters/application.js b/addon/adapters/application.js index 489faf0..9330c8f 100644 --- a/addon/adapters/application.js +++ b/addon/adapters/application.js @@ -182,14 +182,21 @@ export default Ember.Object.extend(FetchMixin, Evented, { @method updateResource @param {Resource} resource instance to serialize the changed attributes + @param {Array} includeRelationships (optional) list of {String} relationships + to opt-into an update @return {Promise} resolves with PATCH response or `null` if nothing to update */ - updateResource(resource) { + updateResource(resource, includeRelationships = false) { let url = resource.get('links.self') || this.get('url') + '/' + resource.get('id'); - const json = this.serializer.serializeChanged(resource); - if (!json) { + let json = this.serializer.serializeChanged(resource); + let relationships = this.serializer.serializeRelationships(resource, includeRelationships); + if ((includeRelationships && + ((!json && !relationships) || (!json && relationships.length === 0))) || + (!includeRelationships && !json)) { return RSVP.Promise.resolve(null); } + json = json || { data: { id: resource.get('id'), type: resource.get('type') } }; + json.data.relationships = relationships; return this.fetch(url, { method: 'PATCH', body: JSON.stringify(json), @@ -233,14 +240,14 @@ export default Ember.Object.extend(FetchMixin, Evented, { @method createRelationship @param {Resource} resource instance, has URLs via it's relationships property - @param {String} relationship name (plural) to find the url from the resource instance + @param {String} relationship name @param {String} id of the related resource @return {Promise} */ createRelationship(resource, relationship, id) { return this.fetch(this._urlForRelationship(resource, relationship), { method: 'POST', - body: JSON.stringify(this._payloadForRelationship(resource, relationship, id)) + body: JSON.stringify(this.serializer.serializeRelationship(resource, relationship, id)) }); }, @@ -274,13 +281,13 @@ export default Ember.Object.extend(FetchMixin, Evented, { @method patchRelationship @param {Resource} resource instance, has URLs via it's relationships property - @param {String} relationship name (plural) to find the url from the resource instance + @param {String} relationship @return {Promise} */ patchRelationship(resource, relationship) { return this.fetch(this._urlForRelationship(resource, relationship), { method: 'PATCH', - body: JSON.stringify(this._payloadForRelationship(resource, relationship)) + body: JSON.stringify(this.serializer.serializeRelationship(resource, relationship)) }); }, @@ -303,14 +310,14 @@ export default Ember.Object.extend(FetchMixin, Evented, { @method deleteRelationship @param {Resource} resource instance, has URLs via it's relationships property - @param {String} relationship name (plural) to find the url from the resource instance + @param {String} relationship name @param {String} id of the related resource @return {Promise} */ deleteRelationship(resource, relationship, id) { return this.fetch(this._urlForRelationship(resource, relationship), { method: 'DELETE', - body: JSON.stringify(this._payloadForRelationship(resource, relationship, id)) + body: JSON.stringify(this.serializer.serializeRelationship(resource, relationship, id)) }); }, @@ -318,32 +325,15 @@ export default Ember.Object.extend(FetchMixin, Evented, { @method _urlForRelationship @private @param {Resource} resource instance, has URLs via it's relationships property - @param {String} relationship name (plural) to find the url from the resource instance + @param {String} relationship name @return {String} url */ _urlForRelationship(resource, relationship) { - let meta = resource.constructor.metaForProperty(relationship); + let meta = resource.relationMetadata(relationship); let url = resource.get(['relationships', meta.relation, 'links', 'self'].join('.')); return url || [this.get('url'), resource.get('id'), 'relationships', relationship].join('/'); }, - /** - @method _payloadForRelationship - @private - @param {Resource} resource instance, has URLs via it's relationships property - @param {String} relationship name (plural) to find the url from the resource instance - @param {String} id the id for the related resource or undefined current relationship data - @return {Object} payload - */ - _payloadForRelationship(resource, relationship, id) { - // actual resource type of this relationship is found in related-proxy's meta. - let meta = resource.constructor.metaForProperty(relationship); - let data = resource.get(['relationships', meta.relation, 'data'].join('.')); - if (id === undefined) { return {data: data}; } - let resourceObject = { type: pluralize(meta.type), id: id.toString() }; - return { data: (Array.isArray(data)) ? [resourceObject] : resourceObject }; - }, - /** Fetches data using Fetch API or XMLHttpRequest diff --git a/addon/models/resource.js b/addon/models/resource.js index 05e51ce..534310d 100644 --- a/addon/models/resource.js +++ b/addon/models/resource.js @@ -102,6 +102,15 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { */ _attributes: null, + /** + Hash of relationships that were changed + + @private + @property _relationships + @type Object + */ + _relationships: null, + /** Flag for new instance, e.g. not persisted @@ -192,7 +201,7 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { if (id !== undefined) { id = id.toString(); } // ensure String id. // actual resource type of this relationship is found in related-proxy's meta. - let meta = this.constructor.metaForProperty(related); + let meta = this.relationMetadata(related); let key = ['relationships', meta.relation, 'data'].join('.'); let data = this.get(key); let type = pluralize(meta.type); @@ -200,6 +209,7 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { let owner = (typeof getOwner === 'function') ? getOwner(this) : this.container; let resource = owner.lookup(`service:${type}`).cacheLookup(id); if (Array.isArray(data)) { + this._relationAdded(related, identifier); data.push(identifier); if (resource) { let resources = this.get(related); @@ -208,6 +218,8 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { } } } else { + let previous = (data && data.id) ? { type: type, id: data.id } : null; + this._relationAdded(related, identifier, previous); data = identifier; if (resource) { this.set(`${meta.relation}.content`, resource); @@ -216,6 +228,35 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { return this.set(key, data); }, + /** + Track additions of relationships using a resource identifier objects: + + ```js + { relation {String}, type {String}, kind {String} }` + ``` + + @private + @method _relationAdded + @param {String} relation name of a related resource + @param {Object} identifier, a resource identifier object `{type: String, id: String}` + @param {Object|Array} previous, resource identifier object or array of identifiers + */ + _relationAdded(relation, identifier, previous) { + let meta = this.relationMetadata(relation); + setupRelationshipTracking.call(this, relation, meta.kind); + let ref = this._relationships[relation]; + if (meta && meta.kind === 'hasOne') { + ref.changed = identifier; + ref.previous = ref.previous || previous; + } else if (meta && meta.kind === 'hasMany') { + let id = identifier.id; + ref.removals = Ember.A(ref.removals.rejectBy('id', id)); + if (!ref.added.findBy('id', id)) { + ref.added.push({type: pluralize(relation), id: id}); + } + } + }, + /** Removes resource identifier object of the relationship data. Also, sets the `content` of the related (computed property's) proxy object to `null`. @@ -234,12 +275,12 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { */ 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++) { if (relation.data[i].id === id) { relation.data.splice(i, 1); + this._relationRemoved(related, id); break; } } @@ -249,11 +290,37 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { resources.removeAt(idx); } } else if (typeof relation === 'object') { + if (relation.data.type === pluralize(related) && relation.data.id === id) { + this._relationRemoved(related, id); + } relation.data = null; this.set(`${related}.content`, null); } }, + /** + Track removals of relationships + + @private + @method _relationRemoved + @param {String} relation - resource name + @param {String} id + */ + _relationRemoved(relation, id) { + let ref = this._relationships[relation] = this._relationships[relation] || {}; + let meta = this.relationMetadata(relation); + setupRelationshipTracking.call(this, relation, meta.kind); + if (meta.kind === 'hasOne') { + ref.changed = null; + ref.previous = ref.previous || this.get('relationships.' + relation).data; + } else if (meta.kind === 'hasMany') { + ref.added = Ember.A(ref.added.rejectBy('id', id)); + if (!ref.removals.findBy('id', id)) { + ref.removals.pushObject({ type: pluralize(relation), id: id }); + } + } + }, + /** @method changedAttributes @return {Object} the changed attributes @@ -291,11 +358,36 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { }).volatile(), /** - Revert to previous attributes + @method previousAttributes + @return {Object} the previous attributes + */ + changedRelationships: computed('_relationships', { + get() { + let relationships = Object.keys(this._relationships).filter( (relation) => { + let ref = this._relationships[relation]; + return !!ref.previous || (ref.removals && ref.removals.length) || + (ref.added && ref.added.length); + }); + return Ember.A(relationships); + } + }).volatile(), + + /** + Rollback changes to attributes and relationships @method rollback */ rollback() { + this.rollbackAttributes(); + this.rollbackRelationships(); + }, + + /** + Revert to previous attributes + + @method rollbackAttributes + */ + rollbackAttributes() { let attrs = this.get('previousAttributes'); for (let prop in attrs) { if (attrs.hasOwnProperty(prop)) { @@ -320,6 +412,65 @@ const Resource = Ember.Object.extend(ResourceOperationsMixin, { } }, + /** + Revert to previous relationships + + @method rollbackRelationships + */ + rollbackRelationships() { + let relations = this.get('changedRelationships'); + if (relations && relations.length > 0) { + relations.forEach((relation) => { + let ref = this._relationships[relation]; + let meta = this.relationMetadata(relation); + if (meta && meta.kind === 'hasOne') { + if (ref.changed && ref.changed.id && ref.previous && ref.previous.id) { + this.addRelationship(relation, ref.previous.id); + } + } else if (meta && meta.kind === 'hasMany') { + let added = ref.added.mapBy('id'); + let removed = ref.removals.mapBy('id'); + added.forEach( (id) => { + this.removeRelationship(relation, id); + }); + removed.forEach( (id) => { + this.addRelationship(relation, id); + }); + } + }); + } + this._resetRelationships(); + }, + + /** + Reset tracked relationship changes + + @private + @method _resetRelationships + */ + _resetRelationships() { + for (let attr in this._relationships) { + if (this._relationships.hasOwnProperty(attr)) { + delete this._relationships[attr]; + } + } + }, + + /** + @method relationMetadata + @param {String} property name of a related resource + @return {Object|undefined} `{ relation {String}, type {String}, kind {String} }` + */ + relationMetadata(property) { + let meta; + try { + meta = this.constructor.metaForProperty(property); + } catch (e) { + meta = this.get('content').constructor.metaForProperty(property); + } + return meta; + }, + /** Sets all payload properties on the resource and resets private _attributes used for changed/previous tracking @@ -418,7 +569,7 @@ Resource.reopenClass({ create(properties) { properties = properties || {}; const prototype = {}; - const attrs = Ember.String.w('_attributes attributes links meta relationships'); + const attrs = Ember.String.w('_attributes attributes links meta relationships _relationships'); for (let i = 0; i < attrs.length; i++) { prototype[attrs[i]] = {}; } @@ -468,11 +619,8 @@ function useComputedPropsMetaToSetupRelationships(owner, factory, instance) { try { let meta = factory.metaForProperty(prop); if (meta && meta.kind) { - if (meta.kind === 'hasOne') { - setupRelationship.call(instance, meta.relation); - } else if (meta.kind === 'hasMany') { - setupRelationship.call(instance, meta.relation, Ember.A([])); - } + setupRelationship.call(instance, meta.relation, meta.kind); + setupRelationshipTracking.call(instance, meta.relation, meta.kind); } } catch (e) { return; // metaForProperty has an assertion that may throw @@ -480,15 +628,32 @@ function useComputedPropsMetaToSetupRelationships(owner, factory, instance) { }); } -function setupRelationship(relation, data = null) { - if (!this.relationships[relation]) { - this.relationships[relation] = { links: {}, data: data }; +function setupRelationship(relation, kind) { + let ref = this.relationships[relation]; + if (!ref) { + ref = this.relationships[relation] = { links: {}, data: null }; } - if (!this.relationships[relation].links) { - this.relationships[relation].links = {}; + if (!ref.links) { + ref.links = {}; } - if (!this.relationships[relation].data) { - this.relationships[relation].data = data; + if (!ref.data) { + if (kind === 'hasOne') { + ref.data = null; + } else if (kind === 'hasMany') { + ref.data = Ember.A([]); + } + } +} + +function setupRelationshipTracking(relation, kind) { + this._relationships[relation] = this._relationships[relation] || {}; + let ref = this._relationships[relation]; + if (kind === 'hasOne') { + ref.changed = ref.changed || null; + ref.previous = ref.previous || null; + } else if (kind === 'hasMany') { + ref.added = ref.added || Ember.A([]); + ref.removals = ref.removals || Ember.A([]); } } diff --git a/addon/serializers/application.js b/addon/serializers/application.js index 033dc62..0f516cd 100644 --- a/addon/serializers/application.js +++ b/addon/serializers/application.js @@ -101,6 +101,65 @@ export default Ember.Object.extend({ return serialized; }, + /** + @method serializeRelationships + @param {Resource} resource with relationships to serialize + @param {Array} relationships list of {String} relationship properties + @return {Object} the serialized `relationship` node for the JSON payload + */ + serializeRelationships(resource, relationships) { + if (!relationships || relationships.length === 0) { + return null; + } + let relations = Object.keys(resource.get('relationships')); + relations = this._intersection(relations, relationships); + relationships = {}; + relations.forEach((relationship) => { + relationships[relationship] = this.serializeRelationship(resource, relationship); + }); + return relationships; + }, + + /** + @method serializeRelationship + @param {Resource} resource instance, has URLs via it's relationships property + @param {String} relationship name + @param {String|undefined} id (optional) of the related resource + @return {Object} payload + */ + serializeRelationship(resource, relationship, id) { + resource = resource.get('content') || resource; + // The actual resource type of this relationship is found in related-proxy's meta. + let meta = resource.relationMetadata(relationship); + let data = resource.get(['relationships', meta.relation, 'data'].join('.')); + if (id === undefined) { + return { data: data }; + } + let resourceObject = { type: pluralize(meta.type), id: id.toString() }; + return { data: (Array.isArray(data)) ? [resourceObject] : resourceObject }; + }, + + /** + @private + @method _intersection + @param {Array} first + @param {Array} second + @return {Array} + */ + _intersection(first, second) { + if (!Array.isArray(first) || !Array.isArray(second)) { + return []; + } + if (second.length > first.length) { + let tmp = second; + second = first; + first = tmp; + } + return first.filter( (item) => { + return (second.indexOf(item) !== -1); + }); + }, + /** Deserialize response objects from the request payload diff --git a/addon/services/store.js b/addon/services/store.js index c37eeee..ca7538a 100644 --- a/addon/services/store.js +++ b/addon/services/store.js @@ -59,11 +59,13 @@ export default Ember.Service.extend({ @method updateResource @param {String} type the entity or resource name will be pluralized @param {Resource} resource instance to serialize the changed attributes + @param {Array} includeRelationships (optional) list of {String} relationships + to opt-into an update @return {Promise} */ - updateResource(type, resource) { + updateResource(type, resource, includeRelationships = false) { let service = this._service(type); - return service.updateResource(resource); + return service.updateResource(resource, includeRelationships); }, /** diff --git a/bower.json b/bower.json index eb82a05..608e607 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "ember-jsonapi-resources", "dependencies": { - "ember": "~2.8.0", + "ember": "~2.8.1", "ember-cli-shims": "0.1.1", "fetch": "~0.10.1", "es6-promise": "~3.0.2" diff --git a/tests/unit/adapters/application-test.js b/tests/unit/adapters/application-test.js index 7367218..b5d3cb7 100644 --- a/tests/unit/adapters/application-test.js +++ b/tests/unit/adapters/application-test.js @@ -279,9 +279,9 @@ test('#findRelated is called with optional type for the resource', function (ass let supervisor = this.container.lookup('model:supervisor').create(supervisorMock.data); let employee = this.container.lookup('model:employee').create(employeeMock.data); - let SupervisorAdapter = Adapter.extend({type: 'supervisors', url: '/supervisors'}); + let SupervisorAdapter = Adapter.extend({ type: 'supervisors', url: '/supervisors' }); SupervisorAdapter.reopenClass({isServiceFactory: true}); - let EmployeeAdapter = Adapter.extend({type: 'employees', url: '/employees'}); + let EmployeeAdapter = Adapter.extend({ type: 'employees', url: '/employees' }); EmployeeAdapter.reopenClass({isServiceFactory: true}); this.registry.register('service:employees', EmployeeAdapter.extend({ @@ -335,11 +335,11 @@ test('#createResource', function(assert) { }); }); -test('#updateResource', function(assert) { +test('#updateResource updates changed attributes', function(assert) { assert.expect(3); const done = assert.async(); - const adapter = this.subject({type: 'posts', url: '/posts'}); + const adapter = this.subject({ type: 'posts', url: '/posts' }); let payload = { data: { type: postMock.data.type, @@ -349,7 +349,7 @@ test('#updateResource', function(assert) { } } }; - adapter.serializer = { serializeChanged: function () { return payload; } }; + adapter.serializer = mockSerializer({ changed: payload }); 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); @@ -359,7 +359,7 @@ test('#updateResource', function(assert) { assert.ok( adapter.fetch.calledWith( postMock.data.links.self, - {method: 'PATCH', body: JSON.stringify(payload), update: true} + { method: 'PATCH', body: JSON.stringify(payload), update: true } ), '#fetch called with url and options with data' ); @@ -367,12 +367,59 @@ test('#updateResource', function(assert) { }); }); +test('#updateResource updates (optional) relationships', function(assert) { + assert.expect(3); + const done = assert.async(); + let adapter = this.subject({ type: 'posts', url: '/posts' }); + sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); + + let author = this.container.lookup('model:comment').create(postMock.included[0]); + author.set('id', '2'); + this.registry.register('service:authors', adapter.constructor.extend({ + cacheLookup: function () { return author; } + })); + let comment = this.container.lookup('model:comment').create(postMock.included[1]); + comment.set('id', '3'); + this.registry.register('service:comments', adapter.constructor.extend({ + cacheLookup: function () { return comment; } + })); + let payload = { + data: { + id: '1', + type: 'posts', + relationships: { + author: { data: { type: 'authors', id: '2' } }, + comments: { data: [{ type: 'comments', id: '3' }] } + } + } + }; + adapter.serializer = mockSerializer({ relationships: payload.data.relationships }); + + let resource = this.container.lookup('model:post').create(postMock.data); + resource.addRelationship('author', '2'); + resource.addRelationship('comments', '3'); + + let promise = adapter.updateResource(resource, ['author', 'comments']); + assert.ok(typeof promise.then === 'function', 'returns a thenable'); + 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 relationships 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; } }; + adapter.serializer = mockSerializer(); 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); @@ -388,16 +435,17 @@ test('when serializer returns null (nothing changed) #updateResource return prom test('#createRelationship (to-many)', function(assert) { assert.expect(2); const done = assert.async(); - mockServices.call(this); let adapter = this.subject({type: 'posts', url: '/posts'}); + let payload = {data: [{type: 'comments', id: '1'}]}; + adapter.serializer = mockSerializer({ relationship: payload }); 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(() => { - let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); + let jsonBody = JSON.stringify(payload); assert.ok( adapter.fetch.calledWith( postMock.data.relationships.comments.links.self, @@ -413,7 +461,9 @@ test('#createRelationship (to-one)', function(assert) { assert.expect(2); const done = assert.async(); - const adapter = this.subject({type: 'posts', url: '/posts'}); + let adapter = this.subject({type: 'posts', url: '/posts'}); + let mockRelationSerialized = { data: { type: 'authors', id: '1'} }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); mockServices.call(this); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); @@ -421,11 +471,10 @@ test('#createRelationship (to-one)', function(assert) { assert.ok(typeof promise.then === 'function', 'returns a thenable'); 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} + {method: 'POST', body: JSON.stringify(mockRelationSerialized)} ), '#fetch called with url and options with data' ); @@ -439,17 +488,18 @@ test('#createRelationship uses optional resource type', function (assert) { mockServices.call(this); let adapter = this.subject({type: 'supervisors', url: '/supervisors'}); + let mockRelationSerialized = { data: [{ type: 'employees', id: '1' }] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:supervisor').create(supervisorMock.data); let promise = adapter.createRelationship(resource, 'directReports', '1'); assert.ok(typeof promise.then === 'function', 'returns a thenable'); promise.then(() => { - let jsonBody = JSON.stringify({data: [{type: 'employees', id: '1'}]}); assert.ok( adapter.fetch.calledWith( supervisorMock.data.relationships['direct-reports'].links.self, - {method: 'POST', body: jsonBody} + { method: 'POST', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -462,18 +512,19 @@ test('#deleteRelationship (to-many)', function(assert) { const done = assert.async(); mockServices.call(this); - let adapter = this.subject({type: 'posts', url: '/posts'}); + let adapter = this.subject({ type: 'posts', url: '/posts' }); + let mockRelationSerialized = { data: [{ type: 'comments', id: '1' }] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); 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(() => { - let jsonBody = JSON.stringify({data: [{type: 'comments', id: '1'}]}); assert.ok( adapter.fetch.calledWith( postMock.data.relationships.comments.links.self, - {method: 'DELETE', body: jsonBody} + { method: 'DELETE', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -487,17 +538,18 @@ test('#deleteRelationship (to-one)', function(assert) { mockServices.call(this); const adapter = this.subject({type: 'posts', url: '/posts'}); + let mockRelationSerialized = { data: { type: 'authors', id: '1' } }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); 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'); 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} + { method: 'DELETE', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -511,17 +563,18 @@ test('#deleteRelationship uses optional resource type', function (assert) { mockServices.call(this); let adapter = this.subject({type: 'supervisors', url: '/supervisors'}); + let mockRelationSerialized = { data: [{ type: 'employees', id: '1' }] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:supervisor').create(supervisorMock.data); let promise = adapter.deleteRelationship(resource, 'directReports', '1'); assert.ok(typeof promise.then === 'function', 'returns a thenable'); promise.then(() => { - let jsonBody = JSON.stringify({data: [{type: 'employees', id: '1'}]}); assert.ok( adapter.fetch.calledWith( supervisorMock.data.relationships['direct-reports'].links.self, - {method: 'DELETE', body: jsonBody} + { method: 'DELETE', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -535,6 +588,8 @@ test('#patchRelationship (to-many)', function(assert) { mockServices.call(this); let adapter = this.subject({type: 'posts', url: '/posts'}); + let mockRelationSerialized = { data: [{ type: 'comments', id: '1' }] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); resource.addRelationship('comments', '1'); @@ -542,11 +597,10 @@ test('#patchRelationship (to-many)', function(assert) { assert.ok(typeof promise.then === 'function', 'returns a thenable'); 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} + { method: 'PATCH', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -560,6 +614,8 @@ test('#patchRelationship (to-one)', function(assert) { mockServices.call(this); const adapter = this.subject({type: 'posts', url: '/posts'}); + let mockRelationSerialized = { data: { type: 'authors', id: '1' } }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:post').create(postMock.data); resource.addRelationship('author', '1'); @@ -567,11 +623,10 @@ test('#patchRelationship (to-one)', function(assert) { assert.ok(typeof promise.then === 'function', 'returns a thenable'); 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} + { method: 'PATCH', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -585,6 +640,8 @@ test('#patchRelationship uses optional resource type', function (assert) { mockServices.call(this); let adapter = this.subject({type: 'supervisors', url: '/supervisors'}); + let mockRelationSerialized = { data: [{type: 'employees', id: '1'}] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); sandbox.stub(adapter, 'fetch', function () { return RSVP.Promise.resolve(null); }); let resource = this.container.lookup('model:supervisor').create(supervisorMock.data); resource.addRelationship('directReports', '1'); @@ -592,11 +649,10 @@ test('#patchRelationship uses optional resource type', function (assert) { assert.ok(typeof promise.then === 'function', 'returns a thenable'); promise.then(() => { - let jsonBody = JSON.stringify({data: [{type: 'employees', id: '1'}]}); assert.ok( adapter.fetch.calledWith( supervisorMock.data.relationships['direct-reports'].links.self, - {method: 'PATCH', body: jsonBody} + { method: 'PATCH', body: JSON.stringify(mockRelationSerialized) } ), '#fetch called with url and options with data' ); @@ -614,17 +670,18 @@ test('createRelationship casts id to string', function (assert) { mockServices.call(this); const adapter = this.subject({type: 'posts', url: '/posts'}); + let mockRelationSerialized = { data: [{type: 'comments', id: '1'}] }; + adapter.serializer = mockSerializer({ relationship: mockRelationSerialized }); 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} + { method: 'POST', body: JSON.stringify(mockRelationSerialized) } ), '#createRelationship casts id to String' ); @@ -633,7 +690,7 @@ test('createRelationship casts id to string', function (assert) { assert.ok( adapter.fetch.calledWith( postMock.data.relationships.comments.links.self, - {method: 'DELETE', body: jsonBody} + { method: 'DELETE', body: JSON.stringify(mockRelationSerialized) } ), '#deleteRelationship casts id to String' ); @@ -801,10 +858,7 @@ test('#cacheUpdate called after #updateResource success', function(assert) { } } }; - adapter.serializer = { - serializeChanged: function () { return payload; }, - transformAttributes: function(json) { return json; } - }; + adapter.serializer = mockSerializer({ changed: payload }); let resource = this.container.lookup('model:post').create(postMock.data); let promise = adapter.updateResource(resource); @@ -1006,3 +1060,15 @@ test('re-opening AuthorizationMixin can customize the settings for Authorization }); assert.equal(adapter.get('authorizationCredential'), 'Bearer SecretToken'); }); + +function mockSerializer(mock = {}) { + mock.changed = mock.changed || null; + mock.relationships = mock.relationships || {}; + mock.relationship = mock.relationship || null; + return { + serializeChanged: function () { return mock.changed; }, + serializeRelationships: function () { return mock.relationships; }, + serializeRelationship: function () { return mock.relationship; }, + transformAttributes: function(json) { return json; } + }; +} diff --git a/tests/unit/models/resource-test.js b/tests/unit/models/resource-test.js index f904ef5..8973121 100644 --- a/tests/unit/models/resource-test.js +++ b/tests/unit/models/resource-test.js @@ -31,14 +31,17 @@ test('it creates an instance', function (assert) { let resource = this.subject(); assert.ok(!!resource); }); + test('creating an instance WITHOUT id has flag for isNew set to true', function(assert) { let resource = this.subject(); assert.equal(resource.get('isNew'), true, 'without id, default value for isNew flag set to `true`'); }); + test('creating an instance WITH id has flag for isNew set to false', function(assert) { let resource = this.subject({id: 1}); assert.equal(resource.get('isNew'), false, 'without id, default value for isNew flag set to `false`'); }); + test('creating an instance allows isNew regardless of id/defaults', function (assert) { let notIsNewResource = this.subject({isNew: false}); let yesIsNewResource = this.subject({id: 1, isNew: true}); @@ -46,11 +49,10 @@ test('creating an instance allows isNew regardless of id/defaults', function (as assert.equal(notIsNewResource.get('isNew'), false, 'without id, isNew property is honored'); assert.equal(yesIsNewResource.get('isNew'), true, 'with id, isNew property is honored'); }); + 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.'} - }); + let post = createPost.call(this); assert.strictEqual(post.get('id'), id.toString(), 'new instance id cast to string'); }); @@ -106,9 +108,7 @@ test('it needs a reference to an injected service object', function(assert) { }); test('attr() uses the attributes hash for computed model attributes', function(assert) { - let post = this.container.lookup('model:post').create({ - id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); + let post = createPost.call(this); assert.equal(post.get('title'), 'Wyatt Earp', 'name is set to "Wyatt Earp"'); assert.equal(post.get('excerpt'), 'Was a gambler.', 'excerpt is set to "Was a gambler."'); @@ -141,10 +141,7 @@ test('attr() helper creates a computed property using a unique (protected) attri }); test('#changedAttributes', function(assert) { - let post = this.container.lookup('model:post').create({ - id: 1, - attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); + let post = createPost.call(this); assert.equal(post.get('excerpt'), 'Was a gambler.', 'excerpt is set "Was a gambler."'); post.set('excerpt', 'Became a deputy.'); assert.equal(post.get('excerpt'), 'Became a deputy.', 'excerpt is set to "Became a deputy."'); @@ -155,10 +152,7 @@ test('#changedAttributes', function(assert) { }); test('#previousAttributes', function(assert) { - let post = this.container.lookup('model:post').create({ - id: '1', - attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); + let post = createPost.call(this); assert.equal(post.get('excerpt'), 'Was a gambler.', 'excerpt is set to "Was a gambler."'); post.set('excerpt', 'Became a deputy.'); assert.equal(post.get('excerpt'), 'Became a deputy.', 'excerpt is set to "Became a deputy."'); @@ -168,11 +162,8 @@ test('#previousAttributes', function(assert) { assert.equal(previous.excerpt, 'Was a gambler.', 'previous excerpt value is "Was a gambler."'); }); -test('#rollback resets attributes based on #previousAttributes', function(assert) { - let post = this.container.lookup('model:post').create({ - id: '1', - attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); +test('#rollbackAttributes resets attributes based on #previousAttributes', function(assert) { + let post = createPost.call(this); assert.equal(post.get('excerpt'), 'Was a gambler.', 'excerpt is set to "Was a gambler."'); post.set('excerpt', 'Became a deputy.'); assert.equal(post.get('excerpt'), 'Became a deputy.', 'excerpt is set to "Became a deputy."'); @@ -180,13 +171,57 @@ test('#rollback resets attributes based on #previousAttributes', function(assert assert.equal(previous.excerpt, 'Was a gambler.', 'previous excerpt value is "Was a gambler."'); assert.equal(Object.keys(previous).length, 1, 'previous attribues have one change tracked'); - post.rollback(); + post.rollbackAttributes(); previous = post.get('previousAttributes'); assert.equal(post.get('excerpt'), 'Was a gambler.', 'excerpt is set to "Was a gambler."'); assert.equal(Object.keys(previous).length, 0, 'previous attribues are empty'); }); +test('#rollbackRelationships resets relationships', function(assert) { + let post = createPostWithRelationships.call(this); + let ogAuthorId = post.get('relationships.author.data.id'); + let relationships = post.get('relationships'); + + post.addRelationship('author', '5'); + assert.notEqual(relationships.author.id, ogAuthorId, 'author changed'); + + assert.equal(relationships.comments.data.length, 1, 'one comment'); + post.removeRelationships('comments', ['3']); + assert.equal(relationships.comments.data.length, 0, 'no comments'); + + let changes = post.get('changedRelationships'); + assert.equal(changes.length, 2, 'two relationships were changed'); + + post.rollbackRelationships(); + + changes = post.get('changedRelationships'); + assert.equal(changes.length, 0, 'zero relationships were changed'); + relationships = post.get('relationships'); + assert.equal(relationships.author.data.id, ogAuthorId, 'author rolled back'); + assert.equal(relationships.comments.data.length, 1, 'one comment'); + assert.equal(relationships.comments.data[0].id, '3', 'comment rolled back'); +}); + +test('#rollback resets attributes and relationships', function(assert){ + let post = createPostWithRelationships.call(this); + post.set('excerpt', 'Became a deputy.'); + let previous = post.get('previousAttributes'); + assert.equal(Object.keys(previous).length, 1, 'previous attribues have one change tracked'); + + post.addRelationship('author', '5'); + post.removeRelationships('comments', ['3']); + let changes = post.get('changedRelationships'); + assert.equal(changes.length, 2, 'two relationships were changed'); + + post.rollback(); + previous = post.get('previousAttributes'); + changes = post.get('changedRelationships'); + + assert.equal(Object.keys(previous).length, 0, 'attribues rolled back'); + assert.equal(changes.length, 0, 'relationships rolled back'); +}); + test('#didUpdateResource empties the resource _attributes hash when resource id matches json arg id value', function(assert) { let post = this.container.lookup('model:post').create({ id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} @@ -207,6 +242,26 @@ test('#didUpdateResource does nothing if json argument has an id that does not m assert.equal(Object.keys(post.get('_attributes')).length, 1, 'one changed attribute still present after didUpdateResource called'); }); +test('#relationMetadata', function(assert) { + let post = this.container.lookup('model:post').create({ + id: '1', attributes: { title: 'Wyatt Earp', excerpt: 'Was a gambler.' }, + relationships: { + author: { data: { type: 'authors', id: '1' } }, + comments: [ + { data: { type: 'comments', id: '1' } } + ] + } + }); + let metaData = post.relationMetadata('author'); + assert.ok(metaData.kind, 'hasOne', 'meta kind is hasOne'); + assert.ok(metaData.relation, 'author', 'meta relation is author'); + assert.ok(metaData.type, 'author', 'meta type is author'); + metaData = post.relationMetadata('comments'); + assert.ok(metaData.kind, 'hasMany', 'meta kind is hasMany'); + assert.ok(metaData.relation, 'comments', 'meta relation is comments'); + assert.ok(metaData.type, 'comments', 'meta type is comments'); +}); + test('#addRelationship', function(assert) { // create resource with relation from json payload. let comment = this.container.lookup('model:comment').create({ @@ -247,43 +302,77 @@ test('#addRelationship cast id to string', function (assert) { '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. +test('#addRelationship tracks relationships changes', function(assert) { 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: ''} }, - comments: { data: [{ type: 'comments', id: '4' }], links: { related: ''} } - } + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} + }); + + post.addRelationship('author', '1'); + assert.equal(post._relationships.author.previous, null, 'sets previous to null'); + assert.ok(post._relationships.author.changed, 'has reference for changed relation'); + assert.equal(post._relationships.author.changed.id, '1', 'changed id is 1'); + assert.equal(post._relationships.author.changed.type, 'authors', 'changed type is authors'); + + post.addRelationship('comments', '1'); + assert.ok(post._relationships.comments.added, 'comments relation added'); + assert.equal(post._relationships.comments.added.length, 1, 'one comments relation added'); + post.addRelationship('comments', '2'); + assert.equal(post._relationships.comments.added.length, 2, 'two comments relation added'); +}); + +test('#addRelationships', function(assert) { + let post = this.container.lookup('model:post').create({ + id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} }); + post.addRelationships('comments', ['4', '5']); + let comments = post.get('relationships.comments.data'); + assert.ok(comments.mapBy('id').indexOf('4') !== -1, 'Comment id 4 added'); + assert.ok(comments.mapBy('id').indexOf('5') !== -1, 'Comment id 5 added'); + assert.equal(comments[0].type, 'comments', 'relation has comments type'); + assert.equal(comments[1].type, 'comments', 'relation has comments type'); + post.addRelationships('author', '2'); + let author = post.get('relationships.author.data'); + assert.equal(author.id, '2', 'Author id 2 added'); + assert.equal(author.type, 'authors', 'Author id 2 added'); +}); + +test('#removeRelationship', function(assert) { + // set up models and their relations through create with json payload. + let post = createPostWithRelationships.call(this); let author = this.container.lookup('model:author').create({ id: '2', attributes: { name: 'Bill' }, relationships: { - posts: { data: [{ type: 'posts', id: '1' }], links: { related: ''} } + posts: { data: [{ type: 'posts', id: '1' }], links: { related: 'url'} } } }); - let commenter = this.container.lookup('model:commenter').create({ - id: '3', attributes: { name: 'Virgil Erp' }, + let comment = this.container.lookup('model:comment').create({ + id: '3', attributes: { body: 'Wyatt become a deputy too.' }, relationships: { - comments: { data: [{ type: 'comments', id: '4' }], links: { related: ''} } + commenter: { data: { type: 'commenters', id: '4' }, links: { related: 'url'} }, + post: { data: { type: 'posts', id: '1' }, links: { related: 'url'} } } }); - let comment = this.container.lookup('model:comment').create({ - id: '4', attributes: { body: 'Wyatt become a deputy too.' }, + let commenter = this.container.lookup('model:commenter').create({ + id: '4', attributes: { name: 'Virgil Erp' }, relationships: { - commenter: { data: { type: 'commenters', id: '3' }, links: { related: ''} }, - post: { data: { type: 'posts', id: '1' }, links: { related: ''} } + comments: { data: [{ type: 'comments', id: '3' }], links: { related: 'url'} } } }); // Test for correct representation of relationships. - let authorPostsRelation = {data: [{type: 'posts', id: '1'}], links: {related: ''}}; + let authorPostsRelation = { + data: [{type: 'posts', id: '1'}], links: { related: 'url' } + }; 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: ''}}; + let postAuthorRelation = { + data: { type: 'authors', id: '2'}, links: { related: 'url' } + }; + let postCommentsRelation = { + data: [{ type: 'comments', id: '3'}], links: { related: 'url'} + }; assert.deepEqual(post.get('relationships.author'), postAuthorRelation, 'post relations have an author (hasOne)'); @@ -291,8 +380,12 @@ test('#removeRelationship', function(assert) { 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: ''}}; + let commentCommenterRelation = { + data: {type: 'commenters', id: '4'}, links: { related: 'url'} + }; + let commentPostRelation = { + data: {type: 'posts', id: '1'}, links: { related: 'url'} + }; assert.deepEqual(comment.get('relationships.commenter'), commentCommenterRelation, 'comment relations have a commenter (hasOne)'); @@ -300,7 +393,7 @@ test('#removeRelationship', function(assert) { commentPostRelation, 'comment relations have a post (hasOne)'); - let commenterCommentsRelation = {data: [{type: 'comments', id: '4'}], links: {related: ''}}; + let commenterCommentsRelation = {data: [{type: 'comments', id: '3'}], links: { related: 'url'} }; assert.deepEqual(commenter.get('relationships.comments'), commenterCommentsRelation, 'commenter relations have a comment (hasMany)'); @@ -318,7 +411,7 @@ test('#removeRelationship', function(assert) { postCommentsRelation, 'removed author from post, comments relation unchanged'); - post.removeRelationship('comments', '4'); + post.removeRelationship('comments', '3'); // comments relationship must still exist, but empty (hasMany == empty array) postCommentsRelation.data = []; // author relationship must be unchanged. @@ -336,7 +429,7 @@ test('#removeRelationship', function(assert) { authorPostsRelation, 'removed a post from author, posts relation now empty'); - comment.removeRelationship('commenter', '3'); + comment.removeRelationship('commenter', '4'); // comment relation must still exist, but empty (hasOne == null) commentCommenterRelation.data = null; assert.deepEqual(comment.get('relationships.commenter'), @@ -355,7 +448,7 @@ test('#removeRelationship', function(assert) { commentCommenterRelation, 'removed a post from comment, commenter relation unchanged'); - commenter.removeRelationship('comments', '4'); + commenter.removeRelationship('comments', '3'); // comments relation must still exist, but empty (hasMany == empty array) commenterCommentsRelation.data = []; assert.deepEqual(commenter.get('relationships.comments'), @@ -366,49 +459,44 @@ test('#removeRelationship', function(assert) { 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.'}, + id: '1', attributes: { title: 'Wyatt Earp', excerpt: 'Was a gambler.' }, relationships: { comments: { - data: [ - {type: 'comments', id: '4'}, - {type: 'comments', id: '5'}, - ], - links: {related: ''} + data: [{ type: 'comments', id: '3' }, { type: 'comments', id: '4' }], + links: { related: 'url' } } } }); - let postCommentsRelation = {data: [{type: 'comments', id: '4'}], links: {related: ''}}; - post.removeRelationship('comments', 5); + let postCommentsRelation = { + data: [{ type: 'comments', id: '3' }], links: { related: 'url'} + }; + post.removeRelationship('comments', 4); assert.deepEqual(post.get('relationships.comments'), postCommentsRelation, 'comment relationship removed using number as id'); }); -test('#addRelationships', function(assert) { - let post = this.container.lookup('model:post').create({ - id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'} - }); - post.addRelationships('comments', ['4', '5']); - let comments = post.get('relationships.comments.data'); - assert.ok(comments.mapBy('id').indexOf('4') !== -1, 'Comment id 4 added'); - assert.ok(comments.mapBy('id').indexOf('5') !== -1, 'Comment id 5 added'); - assert.equal(comments[0].type, 'comments', 'relation has comments type'); - assert.equal(comments[1].type, 'comments', 'relation has comments type'); - post.addRelationships('author', '2'); - let author = post.get('relationships.author.data'); - assert.equal(author.id, '2', 'Author id 2 added'); - assert.equal(author.type, 'authors', 'Author id 2 added'); +test('#removeRelationship tracks relationships changes', function(assert) { + let post = createPostWithRelationships.call(this); + post.removeRelationship('author', '2'); + assert.ok(post._relationships.author.previous, 'sets previous refence'); + assert.equal(post._relationships.author.previous.id, '2', 'previous id is 2'); + assert.equal(post._relationships.author.previous.type, 'authors', + 'previous type is authors'); + assert.equal(post._relationships.author.changed, null, + 'has reference for changed relation'); + + post.removeRelationship('comments', '3'); + assert.ok(post._relationships.comments.removals, 'comments relation removed'); + assert.equal(post._relationships.comments.removals.length, 1, + 'one comments relation removed'); + assert.equal(post._relationships.comments.removals.get('firstObject.id'), + '3', 'removed comment id "3"'); }); test('#removeRelationships', function(assert) { - 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'} } - } - }); - post.removeRelationships('comments', ['4']); + let post = createPostWithRelationships.call(this); + post.removeRelationships('comments', ['3']); let comments = post.get('relationships.comments.data'); assert.equal(comments.length, 0, 'remove comment relation'); post.removeRelationships('author', '2'); @@ -416,6 +504,16 @@ test('#removeRelationships', function(assert) { assert.equal(author, null, 'removed author'); }); +test('#changedRelationships', function(assert) { + let post = createPostWithRelationships.call(this); + post.addRelationship('author', '5'); + post.removeRelationships('comments', ['3']); + let changes = post.get('changedRelationships'); + assert.equal(changes.length, 2, 'two relationships were changed'); + assert.ok(changes.indexOf('author') > -1, 'author relationship was changed'); + assert.ok(changes.indexOf('comments') > -1, 'comments relationship was changed'); +}); + test('#didResolveProxyRelation', function(assert) { let post = this.container.lookup('model:post').create({ id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, @@ -495,7 +593,7 @@ test('#updateRelationship, from resource-operations mixin', function(assert) { return RSVP.Promise.resolve(null); }); let post = this.container.lookup('model:post').create({ - id: '1', attributes: {title: 'Wyatt Earp', excerpt: 'Was a gambler.'}, + 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'} } @@ -533,3 +631,26 @@ test('#updateRelationship, from resource-operations mixin', function(assert) { assert.equal(author, null, 'author removed'); }); +function createPost() { + return this.container.lookup('model:post').create({ + id: '1', + attributes: { title: 'Wyatt Earp', excerpt: 'Was a gambler.' } + }); +} + +function createPostWithRelationships() { + return 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' } + }, + comments: { + data: [{ type: 'comments', id: '3' }], links: { related: 'url' } + } + } + }); +} + diff --git a/tests/unit/serializers/application-test.js b/tests/unit/serializers/application-test.js index d87ab44..9ddd37a 100644 --- a/tests/unit/serializers/application-test.js +++ b/tests/unit/serializers/application-test.js @@ -94,6 +94,43 @@ test('when #serializedChanged has nothing to return', function(assert) { assert.equal(serialized, null, 'null is returned when there are no changed attributes'); }); +test('#serializeRelationship', function(assert) { + const serializer = this.subject(); + mockServices.call(this); + let post = createPost.call(this); + let json = serializer.serializeRelationship(post, 'author'); + assert.deepEqual(json, { data: { type: 'authors', id: '2' } }, 'author serialized'); + json = serializer.serializeRelationship(post, 'comments'); + assert.deepEqual(json, { data: [{ type: 'comments', id: '3' }] }, 'comments serialized'); +}); + +test('#serializeRelationships', function(assert) { + const serializer = this.subject(); + mockServices.call(this); + let post = createPost.call(this); + let json = serializer.serializeRelationships(post, ['author', 'comments']); + assert.deepEqual(json, { + author: { data: { type: 'authors', id: '2' } }, + comments: { data: [{ type: 'comments', id: '3' }] } + }, 'author and comments relationships serialized'); +}); + +function createPost() { + return 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' } + }, + comments: { + data: [{ type: 'comments', id: '3' }], links: { related: 'url' } + } + } + }); +} + test('With data as an object #deserialize calls #deserializeResource', function(assert) { const serializer = this.subject(); sandbox.stub(serializer, 'deserializeResource', function () {});