brainstem-js 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. data/.travis.yml +3 -6
  2. data/Gemfile.lock +1 -1
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/lib/brainstem/js/version.rb +1 -1
  6. data/spec/brainstem-collection-spec.coffee +25 -0
  7. data/spec/{brianstem-expectation-spec.coffee → brainstem-expectation-spec.coffee} +132 -17
  8. data/spec/brainstem-model-spec.coffee +67 -27
  9. data/spec/brainstem-sync-spec.coffee +29 -6
  10. data/spec/brainstem-utils-spec.coffee +11 -1
  11. data/spec/helpers/builders.coffee +2 -2
  12. data/spec/helpers/models/post.coffee +1 -1
  13. data/spec/helpers/models/project.coffee +1 -1
  14. data/spec/helpers/models/task.coffee +2 -2
  15. data/spec/helpers/models/time-entry.coffee +1 -1
  16. data/spec/helpers/models/user.coffee +1 -1
  17. data/spec/helpers/spec-helper.coffee +21 -0
  18. data/spec/loaders/abstract-loader-shared-behavior.coffee +604 -0
  19. data/spec/loaders/abstract-loader-spec.coffee +3 -0
  20. data/spec/loaders/collection-loader-spec.coffee +146 -0
  21. data/spec/loaders/model-loader-spec.coffee +99 -0
  22. data/spec/storage-manager-spec.coffee +242 -56
  23. data/vendor/assets/javascripts/brainstem/brainstem-collection.coffee +16 -6
  24. data/vendor/assets/javascripts/brainstem/brainstem-expectation.coffee +70 -20
  25. data/vendor/assets/javascripts/brainstem/brainstem-model.coffee +13 -13
  26. data/vendor/assets/javascripts/brainstem/brainstem-sync.coffee +8 -3
  27. data/vendor/assets/javascripts/brainstem/loaders/abstract-loader.coffee +289 -0
  28. data/vendor/assets/javascripts/brainstem/loaders/collection-loader.coffee +68 -0
  29. data/vendor/assets/javascripts/brainstem/loaders/model-loader.coffee +35 -0
  30. data/vendor/assets/javascripts/brainstem/loading-mixin.coffee +1 -7
  31. data/vendor/assets/javascripts/brainstem/storage-manager.coffee +79 -196
  32. data/vendor/assets/javascripts/brainstem/utils.coffee +18 -4
  33. metadata +17 -6
@@ -0,0 +1,3 @@
1
+ describe 'Loaders AbstractLoader', ->
2
+ loaderClass = Brainstem.AbstractLoader
3
+ itShouldBehaveLike "AbstractLoaderSharedBehavior", loaderClass: loaderClass
@@ -0,0 +1,146 @@
1
+ describe 'Loaders CollectionLoader', ->
2
+ loader = opts = null
3
+ fakeNestedInclude = ['parent', { project: ['participants'] }, { assignees: ['something_else'] }]
4
+ loaderClass = Brainstem.CollectionLoader
5
+
6
+ defaultLoadOptions = ->
7
+ name: 'tasks'
8
+
9
+ createLoader = (opts = {}) ->
10
+ storageManager = new Brainstem.StorageManager()
11
+ storageManager.addCollection('tasks', App.Collections.Tasks)
12
+
13
+ defaults =
14
+ storageManager: storageManager
15
+
16
+ loader = new loaderClass(_.extend {}, defaults, opts)
17
+ loader
18
+
19
+ # It should keep the AbstractLoader behavior.
20
+ itShouldBehaveLike "AbstractLoaderSharedBehavior", loaderClass: loaderClass
21
+
22
+ describe 'CollectionLoader behavior', ->
23
+ beforeEach ->
24
+ loader = createLoader()
25
+ opts = defaultLoadOptions()
26
+
27
+ describe '#getCollection', ->
28
+ it 'should return the externalObject', ->
29
+ loader.setup(opts)
30
+ expect(loader.getCollection()).toEqual loader.externalObject
31
+
32
+ describe '#_getCollectionName', ->
33
+ it 'should return the name from loadOptions', ->
34
+ loader.setup(opts)
35
+ expect(loader._getCollectionName()).toEqual 'tasks'
36
+
37
+ describe '#_createObjects', ->
38
+ collection = null
39
+
40
+ beforeEach ->
41
+ collection = new App.Collections.Tasks()
42
+ spyOn(loader.storageManager, 'createNewCollection').andReturn collection
43
+
44
+ it 'creates a new collection from the name in loadOptions', ->
45
+ loader.setup(opts)
46
+ expect(loader.storageManager.createNewCollection.callCount).toEqual 2
47
+ expect(loader.internalObject).toEqual collection
48
+
49
+ context 'collection is passed in to loadOptions', ->
50
+ it 'uses the collection that is passed in', ->
51
+ opts.collection ?= new App.Collections.Tasks()
52
+ loader.setup(opts)
53
+ expect(loader.externalObject).toEqual opts.collection
54
+
55
+ context 'collection is not passed in to loadOptions', ->
56
+ it 'creates a new collection from the name in loadOptions', ->
57
+ loader.setup(opts)
58
+ expect(loader.storageManager.createNewCollection.callCount).toEqual 2
59
+ expect(loader.externalObject).toEqual collection
60
+
61
+ it 'sets the collection to not loaded', ->
62
+ spyOn(collection, 'setLoaded')
63
+ loader.setup(opts)
64
+ expect(collection.setLoaded).toHaveBeenCalledWith false
65
+
66
+ describe 'resetting the collection', ->
67
+ context 'loadOptions.reset is true', ->
68
+ beforeEach ->
69
+ opts.reset = true
70
+
71
+ it 'calls reset on the collection', ->
72
+ spyOn(collection, 'reset')
73
+ loader.setup(opts)
74
+ expect(collection.reset).toHaveBeenCalled()
75
+
76
+ context 'loadOptions.reset is false', ->
77
+ it 'does not reset the collection', ->
78
+ spyOn(collection, 'reset')
79
+ loader.setup(opts)
80
+ expect(collection.reset).not.toHaveBeenCalled()
81
+
82
+ it 'sets lastFetchOptions on the collection', ->
83
+ list = ['filters', 'page', 'perPage', 'limit', 'offset', 'order', 'search']
84
+
85
+ for e in list
86
+ opts[e] = true
87
+
88
+ opts.include = 'parent'
89
+ loader.setup(opts)
90
+
91
+ expect(loader.externalObject.lastFetchOptions.name).toEqual 'tasks'
92
+ expect(loader.externalObject.lastFetchOptions.include).toEqual 'parent'
93
+
94
+ for e in list
95
+ expect(loader.externalObject.lastFetchOptions[e]).toEqual true
96
+
97
+ # describe '#_updateStorageManagerFromResponse', ->
98
+ # TODO: test this, it's tested right now through integration tests.
99
+
100
+ describe '#_updateObject', ->
101
+ it 'triggers loaded on the object after the attributes have been set', ->
102
+ loadedSpy = jasmine.createSpy().andCallFake ->
103
+ expect(this.length).toEqual 1 # make sure that the spy is called after the models have been added (tests the trigger: false)
104
+
105
+ loader.setup(opts)
106
+ loader.internalObject.listenTo loader.internalObject, 'loaded', loadedSpy
107
+
108
+ loader._updateObjects(loader.internalObject, [{foo: 'bar'}])
109
+ expect(loadedSpy).toHaveBeenCalled()
110
+
111
+ it 'works with a Backbone.Collection', ->
112
+ loader.setup(opts)
113
+ loader._updateObjects(loader.internalObject, new Backbone.Collection([new Backbone.Model(name: 'foo')]))
114
+ expect(loader.internalObject.length).toEqual 1
115
+
116
+ it 'works with an array of models', ->
117
+ loader.setup(opts)
118
+ loader._updateObjects(loader.internalObject, [new Backbone.Model(name: 'foo'), new Backbone.Model(name: 'test')])
119
+ expect(loader.internalObject.length).toEqual 2
120
+
121
+ it 'works with a single model', ->
122
+ loader.setup(opts)
123
+ spy = jasmine.createSpy()
124
+ loader.internalObject.listenTo loader.internalObject, 'reset', spy
125
+
126
+ loader._updateObjects(loader.internalObject, new Backbone.Model(name: 'foo'))
127
+ expect(loader.internalObject.length).toEqual 1
128
+ expect(spy).toHaveBeenCalled()
129
+
130
+ describe '#_getModel', ->
131
+ it 'returns the model from the internal collection', ->
132
+ loader.setup(opts)
133
+ expect(loader._getModel()).toEqual App.Models.Task
134
+
135
+ describe '#_getModelsForAssociation', ->
136
+ it 'returns the models for a given association from all of the models in the internal collection', ->
137
+ loader.setup(opts)
138
+ user = buildAndCacheUser()
139
+ user2 = buildAndCacheUser()
140
+
141
+ loader.internalObject.add(new App.Models.Task(assignee_ids: [user.id]))
142
+ loader.internalObject.add(new App.Models.Task(assignee_ids: [user2.id]))
143
+
144
+ expect(loader._getModelsForAssociation('assignees')).toEqual [[user], [user2]] # Association with a model in it
145
+ expect(loader._getModelsForAssociation('parent')).toEqual [[], []] # Association without any models
146
+ expect(loader._getModelsForAssociation('adfasfa')).toEqual [[], []] # Association that does not exist
@@ -0,0 +1,99 @@
1
+ describe 'Loaders ModelLoader', ->
2
+ loader = opts = null
3
+ fakeNestedInclude = ['parent', { project: ['participants'] }, { assignees: ['something_else'] }]
4
+ loaderClass = Brainstem.ModelLoader
5
+
6
+ defaultLoadOptions = ->
7
+ name: 'task'
8
+ only: 1
9
+
10
+ createLoader = (opts = {}) ->
11
+ storageManager = base.data
12
+ storageManager.addCollection('tasks', App.Collections.Tasks)
13
+
14
+ defaults =
15
+ storageManager: storageManager
16
+
17
+ loader = new loaderClass(_.extend {}, defaults, opts)
18
+ loader
19
+
20
+ # It should keep the AbstractLoader behavior.
21
+ itShouldBehaveLike "AbstractLoaderSharedBehavior", loaderClass: loaderClass
22
+
23
+ describe 'ModelLoader behavior', ->
24
+ beforeEach ->
25
+ loader = createLoader()
26
+ opts = defaultLoadOptions()
27
+
28
+ describe '#getModel', ->
29
+ it 'should return the externalObject', ->
30
+ loader.setup(opts)
31
+ expect(loader.getModel()).toEqual loader.externalObject
32
+
33
+ describe '#_getCollectionName', ->
34
+ it 'returns the pluralized name of the model', ->
35
+ loader.setup(opts)
36
+ expect(loader._getCollectionName()).toEqual 'tasks'
37
+
38
+ describe '#_createObjects', ->
39
+ model = null
40
+
41
+ context 'there is a matching model in the storageManager', ->
42
+ it 'sets the internalObject to be the cached model', ->
43
+ model = buildAndCacheTask(id: 1)
44
+ loader.setup(opts)
45
+ expect(loader.internalObject).toEqual model
46
+
47
+ context 'there is not a matching model in the storageManager', ->
48
+ it 'creates a new model and uses that as the internalObject', ->
49
+ model = new App.Models.Task()
50
+ spyOn(loader.storageManager, 'createNewModel').andReturn model
51
+ loader.setup(opts)
52
+ expect(loader.internalObject).toEqual model
53
+
54
+ it 'sets the ID on that model', ->
55
+ loader.setup(opts)
56
+ expect(loader.internalObject.id).toEqual '1'
57
+
58
+ it 'uses the internalObject as the externalObject', ->
59
+ loader.setup(opts)
60
+ expect(loader.internalObject).toEqual loader.externalObject
61
+
62
+ describe '#_updateStorageManagerFromResponse', ->
63
+ it 'calls parse on the internalObject with the response', ->
64
+ loader.setup(opts)
65
+ spyOn(loader.internalObject, 'parse')
66
+
67
+ loader._updateStorageManagerFromResponse('test response')
68
+ expect(loader.internalObject.parse).toHaveBeenCalledWith 'test response'
69
+
70
+ describe '#_updateObject', ->
71
+ it 'works with a Backbone.Model', ->
72
+ loader.setup(opts)
73
+ loader._updateObjects(loader.internalObject, new Backbone.Model(name: 'foo'))
74
+ expect(loader.internalObject.get('name')).toEqual 'foo'
75
+
76
+ it 'works with an array with a Backbone.Model', ->
77
+ loader.setup(opts)
78
+ loader._updateObjects(loader.internalObject, [new Backbone.Model(name: 'foo')])
79
+ expect(loader.internalObject.get('name')).toEqual 'foo'
80
+
81
+ it 'works with an array of data', ->
82
+ loader.setup(opts)
83
+ loader._updateObjects(loader.internalObject, [name: 'foo'])
84
+ expect(loader.internalObject.get('name')).toEqual 'foo'
85
+
86
+ describe '#_getModel', ->
87
+ it 'returns the constructor of the internalObject', ->
88
+ loader.setup(opts)
89
+ expect(loader._getModel()).toEqual App.Models.Task
90
+
91
+ describe '#_getModelsForAssociation', ->
92
+ it 'returns the models from the internalObject for a given association', ->
93
+ loader.setup(opts)
94
+ user = buildAndCacheUser()
95
+ loader.internalObject.set('assignee_ids', [user.id])
96
+
97
+ expect(loader._getModelsForAssociation('assignees')).toEqual [user] # Association with a model in it
98
+ expect(loader._getModelsForAssociation('parent')).toEqual [] # Association without any models
99
+ expect(loader._getModelsForAssociation('adfasfa')).toEqual [] # Association that does not exist
@@ -40,26 +40,107 @@ describe 'Brainstem Storage Manager', ->
40
40
  tasks = [buildTask(id: 2, title: "a task", project_id: 15)]
41
41
  projects = [buildProject(id: 15)]
42
42
  timeEntries = [buildTimeEntry(id: 1, task_id: 2, project_id: 15, title: "a time entry")]
43
- respondWith server, "/api/time_entries?only=1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
44
- respondWith server, "/api/time_entries?include=project%2Ctask&only=1", resultsFrom: "time_entries", data: { time_entries: timeEntries, tasks: tasks, projects: projects }
43
+ respondWith server, "/api/time_entries/1", resultsFrom: "time_entries", data: { time_entries: timeEntries }
44
+ respondWith server, "/api/time_entries/1?include=project%2Ctask", resultsFrom: "time_entries", data: { time_entries: timeEntries, tasks: tasks, projects: projects }
45
+
46
+ it "creates a new model with the supplied id", ->
47
+ loader = base.data.loadModel "time_entry", "333"
48
+ expect(loader.getModel().id).toEqual "333"
49
+
50
+ it "calls Backbone.sync with the model from the loader", ->
51
+ spyOn(Backbone, 'sync')
52
+ loader = base.data.loadModel "time_entry", "333"
53
+ expect(Backbone.sync).toHaveBeenCalledWith 'read', loader.getModel(), loader._buildSyncOptions()
45
54
 
46
55
  it "loads a single model from the server, including associations", ->
47
- model = base.data.loadModel "time_entry", 1, include: ["project", "task"]
48
- expect(model.loaded).toBe false
56
+ loaded = false
57
+ loader = base.data.loadModel "time_entry", 1, include: ["project", "task"]
58
+ loader.done -> loaded = true
59
+ model = loader.getModel()
60
+
61
+ expect(loaded).toBe false
49
62
  server.respond()
50
- expect(model.loaded).toBe true
63
+ expect(loaded).toBe true
51
64
  expect(model.id).toEqual "1"
52
65
  expect(model.get("title")).toEqual "a time entry"
53
66
  expect(model.get('task').get('title')).toEqual "a task"
54
67
  expect(model.get('project').id).toEqual "15"
55
68
 
69
+ it "works with complex associations", ->
70
+ mainProject = buildProject(title: "my project")
71
+ mainTask = buildTask(project_id: mainProject.id, title: "foo")
72
+ timeTask = buildTask(title: 'hello')
73
+ timeEntry = buildTimeEntry(project_id: mainProject.id, task_id: timeTask.id, time: 50)
74
+ mainProject.set('time_entry_ids', [timeEntry.id])
75
+
76
+ subTask = buildTask()
77
+ mainTask.set('sub_task_ids', [subTask.id])
78
+
79
+ mainTaskAssignee = buildUser(name: 'Kimbo')
80
+ mainTask.set('assignee_ids', [mainTaskAssignee.id])
81
+
82
+ subTaskAssignee = buildUser(name: 'Slice')
83
+ subTask.set('assignee_ids', [subTaskAssignee.id])
84
+
85
+ respondWith server, "/api/tasks/#{mainTask.id}?include=assignees%2Csub_tasks%2Cproject", resultsFrom: "tasks", data: { results: resultsArray("tasks", [mainTask]), tasks: resultsObject([mainTask, subTask]), projects: resultsObject([mainProject]), users: resultsObject([mainTaskAssignee]) }
86
+ respondWith server, "/api/tasks?include=assignees&only=#{subTask.id}", resultsFrom: "tasks", data: { results: resultsArray("tasks", [subTask]), tasks: resultsObject([subTask]), users: resultsObject([subTaskAssignee]) }
87
+ respondWith server, "/api/projects?include=time_entries&only=#{mainProject.id}", resultsFrom: "projects", data: { results: resultsArray("projects", [mainProject]), time_entries: resultsObject([timeEntry]), projects: resultsObject([mainProject]) }
88
+ respondWith server, "/api/time_entries?include=task&only=" + timeEntry.id, resultsFrom: "time_entries", data: { results: resultsArray("time_entries", [timeEntry]), time_entries: resultsObject([timeEntry]), tasks: resultsObject([timeTask]) }
89
+
90
+ loader = base.data.loadModel "task", mainTask.id, include: ["assignees", {"sub_tasks": ["assignees"]}, { "project" : [{ "time_entries": ["task"] }] }]
91
+ model = loader.getModel()
92
+
93
+ server.respond()
94
+
95
+ # check main model
96
+ expect(model.attributes).toEqual(mainTask.attributes)
97
+
98
+ # check assignees
99
+ expect(model.get('assignees').length).toEqual(1)
100
+ expect(model.get('assignees').first().get('name')).toEqual('Kimbo')
101
+
102
+ # check sub_tasks
103
+ subTasks = model.get('sub_tasks')
104
+ expect(subTasks.length).toEqual(1)
105
+
106
+ # check sub_tasks -> assignees
107
+ assignees = subTasks.at(0).get('assignees')
108
+ expect(assignees.length).toEqual(1)
109
+ expect(assignees.at(0).get('name')).toEqual('Slice')
110
+
111
+ # check project
112
+ project = model.get('project')
113
+ expect(project.get('title')).toEqual('my project')
114
+
115
+ # check project -> time_entries
116
+ timeEntries = project.get('time_entries')
117
+ expect(timeEntries.length).toEqual(1)
118
+
119
+ timeEntry = timeEntries.at(0)
120
+ expect(timeEntry.get('time')).toEqual(50)
121
+
122
+ # check project -> time_entries -> task
123
+ expect(timeEntry.get('task').get('title')).toEqual('hello')
124
+
125
+ it "uses the cache if it can", ->
126
+ task = buildAndCacheTask(id: 200)
127
+ spy = spyOn(Brainstem.AbstractLoader.prototype, '_loadFromServer')
128
+
129
+ loader = base.data.loadModel "task", task.id
130
+ model = loader.getModel()
131
+ expect(model.attributes).toEqual(task.attributes)
132
+ expect(spy).not.toHaveBeenCalled()
133
+
56
134
  it "works even when the server returned associations of the same type", ->
57
135
  posts = [buildPost(id: 2, reply: true), buildPost(id: 3, reply: true), buildPost(id: 1, reply: false, reply_ids: [2, 3])]
58
- respondWith server, "/api/posts?include=replies&only=1", data: { results: [{ key: "posts", id: 1 }], posts: posts }
59
- model = base.data.loadModel "post", 1, include: ["replies"]
60
- expect(model.loaded).toBe false
136
+ respondWith server, "/api/posts/1?include=replies", data: { results: [{ key: "posts", id: 1 }], posts: posts }
137
+ loaded = false
138
+ loader = base.data.loadModel "post", 1, include: ["replies"]
139
+ loader.done -> loaded = true
140
+ model = loader.getModel()
141
+ expect(loaded).toBe false
61
142
  server.respond()
62
- expect(model.loaded).toBe true
143
+ expect(loaded).toBe true
63
144
  expect(model.id).toEqual "1"
64
145
  expect(model.get("replies").pluck("id")).toEqual ["2", "3"]
65
146
 
@@ -72,29 +153,83 @@ describe 'Brainstem Storage Manager', ->
72
153
  expect(events).toEqual ["tasks", "time_entries"]
73
154
 
74
155
  it "triggers changes", ->
75
- model = base.data.loadModel "time_entry", 1, include: ["project", "task"]
156
+ loaded = false
157
+ loader = base.data.loadModel "time_entry", 1, include: ["project", "task"]
158
+ loader.done -> loaded = true
159
+ model = loader.getModel()
76
160
  spy = jasmine.createSpy().andCallFake ->
77
- expect(model.loaded).toBe true
78
161
  expect(model.get("title")).toEqual "a time entry"
79
162
  expect(model.get('task').get('title')).toEqual "a task"
80
163
  expect(model.get('project').id).toEqual "15"
81
164
  model.bind "change", spy
82
165
  expect(spy).not.toHaveBeenCalled()
166
+ expect(loaded).toBe false
83
167
  server.respond()
84
168
  expect(spy).toHaveBeenCalled()
85
169
  expect(spy.callCount).toEqual 1
170
+ expect(loaded).toBe true
86
171
 
87
172
  it "accepts a success function", ->
88
- spy = jasmine.createSpy().andCallFake (model) ->
89
- expect(model.loaded).toBe true
90
- model = base.data.loadModel "time_entry", 1, success: spy
173
+ spy = jasmine.createSpy()
174
+ base.data.loadModel "time_entry", 1, success: spy
91
175
  server.respond()
92
176
  expect(spy).toHaveBeenCalled()
93
177
 
94
- it "can disbale caching", ->
95
- spy = spyOn(base.data, 'loadCollection')
96
- model = base.data.loadModel "time_entry", 1, cache: false
97
- expect(spy.mostRecentCall.args[1]['cache']).toBe(false)
178
+ it "can disable caching", ->
179
+ spy = spyOn(Brainstem.ModelLoader.prototype, '_checkCacheForData').andCallThrough()
180
+ base.data.loadModel "time_entry", 1, cache: false
181
+ expect(spy).not.toHaveBeenCalled()
182
+
183
+ it "invokes the error callback when the server responds with a 404", ->
184
+ successSpy = jasmine.createSpy('successSpy')
185
+ errorSpy = jasmine.createSpy('errorSpy')
186
+ respondWith server, "/api/time_entries/1337", data: { results: [] }, status: 404
187
+ base.data.loadModel "time_entry", 1337, success: successSpy, error: errorSpy
188
+
189
+ server.respond()
190
+ expect(successSpy).not.toHaveBeenCalled()
191
+ expect(errorSpy).toHaveBeenCalled()
192
+
193
+ it "does not resolve until all of the associations are included", ->
194
+ base.data.enableExpectations()
195
+
196
+ project = buildProject()
197
+ user = buildUser()
198
+
199
+ task = buildTask(title: 'foobar', project_id: project.id)
200
+ task2 = buildTask(project_id: project.id)
201
+ task3 = buildTask(project_id: project.id, assignee_ids: [user.id])
202
+
203
+ project.set('task_ids', [task.id, task2.id, task3.id])
204
+
205
+ taskExpectation = base.data.stubModel "task", task.id, include: ['project': [{ 'tasks': ['assignees'] }]], response: (stub) ->
206
+ stub.result = task
207
+ stub.associated.project = [project]
208
+ stub.recursive = true
209
+
210
+ projectExpectation = base.data.stub "projects", only: project.id, include: ['tasks': ['assignees']], response: (stub) ->
211
+ stub.results = [project]
212
+ stub.associated.tasks = [task, task2, task3]
213
+ stub.recursive = true
214
+
215
+ taskWithAssigneesExpectation = base.data.stub "tasks", only: [task.id, task2.id, task3.id], include: ['assignees'], response: (stub) ->
216
+ stub.results = [task]
217
+ stub.associated.users = [user]
218
+
219
+ resolvedSpy = jasmine.createSpy('resolved')
220
+
221
+ model = buildAndCacheTask(id: task.id)
222
+ loader = base.data.loadModel "task", model.id, include: ['project': [{ 'tasks': ['assignees'] }]]
223
+ loader.done(resolvedSpy)
224
+
225
+ taskExpectation.respond()
226
+ expect(resolvedSpy).not.toHaveBeenCalled()
227
+
228
+ projectExpectation.respond()
229
+ expect(resolvedSpy).not.toHaveBeenCalled()
230
+
231
+ taskWithAssigneesExpectation.respond()
232
+ expect(resolvedSpy).toHaveBeenCalled()
98
233
 
99
234
  describe 'loadCollection', ->
100
235
  it "loads a collection of models", ->
@@ -225,8 +360,8 @@ describe 'Brainstem Storage Manager', ->
225
360
  taskTwoSub = buildTask(project_id: projectTwo.id, parent_id: 11); taskTwoSubWithAssignees = buildTask(id: taskTwoSub.id, assignee_ids: [taskTwoAssignee.id], parent_id: 11)
226
361
  taskOne = buildTask(id: 10, project_id: projectOne.id, assignee_ids: [taskOneAssignee.id], sub_task_ids: [taskOneSub.id])
227
362
  taskTwo = buildTask(id: 11, project_id: projectTwo.id, assignee_ids: [taskTwoAssignee.id], sub_task_ids: [taskTwoSub.id])
228
- respondWith server, "/api/tasks.json?include=assignees%2Cproject%2Csub_tasks&parents_only=true&per_page=20&page=1", data: { results: resultsArray("tasks", [taskOne, taskTwo]), tasks: resultsObject([taskOne, taskTwo, taskOneSub, taskTwoSub]), users: resultsObject([taskOneAssignee, taskTwoAssignee]), projects: resultsObject([projectOne, projectTwo]) }
229
- respondWith server, "/api/tasks.json?include=assignees&only=#{taskOneSub.id}%2C#{taskTwoSub.id}", data: { results: resultsArray("tasks", [taskOneSub, taskTwoSub]), tasks: resultsObject([taskOneSubWithAssignees, taskTwoSubWithAssignees]), users: resultsObject([taskOneSubAssignee, taskTwoAssignee]) }
363
+ respondWith server, "/api/tasks?include=assignees%2Cproject%2Csub_tasks&parents_only=true&per_page=20&page=1", data: { results: resultsArray("tasks", [taskOne, taskTwo]), tasks: resultsObject([taskOne, taskTwo, taskOneSub, taskTwoSub]), users: resultsObject([taskOneAssignee, taskTwoAssignee]), projects: resultsObject([projectOne, projectTwo]) }
364
+ respondWith server, "/api/tasks?include=assignees&only=#{taskOneSub.id}%2C#{taskTwoSub.id}", data: { results: resultsArray("tasks", [taskOneSub, taskTwoSub]), tasks: resultsObject([taskOneSubWithAssignees, taskTwoSubWithAssignees]), users: resultsObject([taskOneSubAssignee, taskTwoAssignee]) }
230
365
  respondWith server, "/api/projects?include=time_entries&only=#{projectOne.id}%2C#{projectTwo.id}", data: { results: resultsArray("projects", [projectOne, projectTwo]), projects: resultsObject([projectOneWithTimeEntries, projectTwoWithTimeEntries]), time_entries: resultsObject([projectOneTimeEntry]) }
231
366
  respondWith server, "/api/time_entries?include=task&only=#{projectOneTimeEntry.id}", data: { results: resultsArray("time_entries", [projectOneTimeEntry]), time_entries: resultsObject([projectOneTimeEntryWithTask]), tasks: resultsObject([projectOneTimeEntryTask]) }
232
367
 
@@ -274,16 +409,33 @@ describe 'Brainstem Storage Manager', ->
274
409
  expect(collection2.get(1).get('project').id).toEqual "15"
275
410
  expect(collection2.get(2).get('project').id).toEqual "10"
276
411
 
277
- it "does go to the server when more records are requested than it has previously requested, and remembers previously requested pages", ->
278
- collection1 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2
279
- server.respond()
280
- expect(collection1.loaded).toBe true
281
- collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 2, perPage: 2
282
- expect(collection2.loaded).toBe false
283
- server.respond()
284
- expect(collection2.loaded).toBe true
285
- collection3 = base.data.loadCollection "time_entries", include: ["project"], page: 1, perPage: 2
286
- expect(collection3.loaded).toBe true
412
+ context "using perPage and page", ->
413
+ it "does go to the server when more records are requested than it has previously requested, and remembers previously requested pages", ->
414
+ collection1 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 1, perPage: 2
415
+ server.respond()
416
+ expect(collection1.loaded).toBe true
417
+ collection2 = base.data.loadCollection "time_entries", include: ["project", "task"], page: 2, perPage: 2
418
+ expect(collection2.loaded).toBe false
419
+ server.respond()
420
+ expect(collection2.loaded).toBe true
421
+ collection3 = base.data.loadCollection "time_entries", include: ["project"], page: 1, perPage: 2
422
+ expect(collection3.loaded).toBe true
423
+
424
+ context "using limit and offset", ->
425
+ it "does go to the server when more records are requested than it knows about", ->
426
+ timeEntries = [buildTimeEntry(), buildTimeEntry()]
427
+ respondWith server, "/api/time_entries?limit=2&offset=0", resultsFrom: "time_entries", data: { time_entries: timeEntries }
428
+ respondWith server, "/api/time_entries?limit=2&offset=2", resultsFrom: "time_entries", data: { time_entries: timeEntries }
429
+
430
+ collection1 = base.data.loadCollection "time_entries", limit: 2, offset: 0
431
+ server.respond()
432
+ expect(collection1.loaded).toBe true
433
+ collection2 = base.data.loadCollection "time_entries", limit: 2, offset: 2
434
+ expect(collection2.loaded).toBe false
435
+ server.respond()
436
+ expect(collection2.loaded).toBe true
437
+ collection3 = base.data.loadCollection "time_entries", limit: 2, offset: 0
438
+ expect(collection3.loaded).toBe true
287
439
 
288
440
  it "does go to the server when some associations are missing, when otherwise it would have the data", ->
289
441
  collection1 = base.data.loadCollection "time_entries", include: ["project"], page: 1, perPage: 2
@@ -502,7 +654,7 @@ describe 'Brainstem Storage Manager', ->
502
654
 
503
655
  beforeEach ->
504
656
  item = buildTask()
505
- respondWith server, "/api/tasks.json?per_page=20&page=1", resultsFrom: "tasks", data: { tasks: [item] }
657
+ respondWith server, "/api/tasks?per_page=20&page=1", resultsFrom: "tasks", data: { tasks: [item] }
506
658
 
507
659
  it "goes to server even if we have matching items in cache", ->
508
660
  syncSpy = spyOn(Backbone, 'sync')
@@ -515,15 +667,37 @@ describe 'Brainstem Storage Manager', ->
515
667
  server.respond()
516
668
  expect(spy).toHaveBeenCalled()
517
669
 
670
+ describe "types of pagination", ->
671
+ it "prioritizes limit and offset over per page and page", ->
672
+ respondWith server, "/api/time_entries?limit=1&offset=0", resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry()] }
673
+ base.data.loadCollection "time_entries", limit: 1, offset: 0, perPage: 5, page: 10
674
+ server.respond()
675
+
676
+ it "limits to at least 1 and offset 0", ->
677
+ respondWith server, "/api/time_entries?limit=1&offset=0", resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry()] }
678
+ base.data.loadCollection "time_entries", limit: -5, offset: -5
679
+ server.respond()
680
+
681
+ it "falls back to per page and page if both limit and offset are not complete", ->
682
+ respondWith server, "/api/time_entries?per_page=5&page=10", resultsFrom: "time_entries", data: { time_entries: [buildTimeEntry()] }
683
+ base.data.loadCollection "time_entries", limit: "", offset: "", perPage: 5, page: 10
684
+ server.respond()
685
+
686
+ base.data.loadCollection "time_entries", limit: "", perPage: 5, page: 10
687
+ server.respond()
688
+
689
+ base.data.loadCollection "time_entries", offset: "", perPage: 5, page: 10
690
+ server.respond()
691
+
518
692
  describe "searching", ->
519
693
  it 'turns off caching', ->
520
- spy = spyOn(base.data, '_loadCollectionWithFirstLayer')
694
+ spy = spyOn(Brainstem.AbstractLoader.prototype, '_checkCacheForData').andCallThrough()
521
695
  collection = base.data.loadCollection "tasks", search: "the meaning of life"
522
- expect(spy.mostRecentCall.args[0]['cache']).toBe(false)
696
+ expect(spy).not.toHaveBeenCalled()
523
697
 
524
698
  it "returns the matching items with includes, triggering reset and success", ->
525
699
  task = buildTask()
526
- respondWith server, "/api/tasks.json?per_page=20&page=1&search=go+go+gadget+search",
700
+ respondWith server, "/api/tasks?per_page=20&page=1&search=go+go+gadget+search",
527
701
  data: { results: [{key: "tasks", id: task.id}], tasks: [task] }
528
702
  spy2 = jasmine.createSpy().andCallFake (collection) ->
529
703
  expect(collection.loaded).toBe true
@@ -538,15 +712,31 @@ describe 'Brainstem Storage Manager', ->
538
712
  expect(spy2).toHaveBeenCalled()
539
713
 
540
714
  it 'does not blow up when no results are returned', ->
541
- respondWith server, "/api/tasks.json?per_page=20&page=1&search=go+go+gadget+search", data: { results: [], tasks: [] }
715
+ respondWith server, "/api/tasks?per_page=20&page=1&search=go+go+gadget+search", data: { results: [], tasks: [] }
542
716
  collection = base.data.loadCollection "tasks", search: "go go gadget search"
543
717
  server.respond()
544
718
 
545
719
  it 'acts as if no search options were passed if the search string is blank', ->
546
- respondWith server, "/api/tasks.json?per_page=20&page=1", data: { results: [], tasks: [] }
720
+ respondWith server, "/api/tasks?per_page=20&page=1", data: { results: [], tasks: [] }
547
721
  collection = base.data.loadCollection "tasks", search: ""
548
722
  server.respond()
549
723
 
724
+ describe 'return values', ->
725
+ it 'adds the jQuery XHR object to the return values if returnValues is passed in', ->
726
+ baseXhr = $.ajax()
727
+ returnValues = {}
728
+
729
+ base.data.loadCollection "tasks", search: "the meaning of life", returnValues: returnValues
730
+ expect(returnValues.jqXhr).not.toBeUndefined()
731
+
732
+ # if it has most of the functions of a jQuery XHR object then it's probably a jQuery XHR object
733
+ jqXhrKeys = ['setRequestHeader', 'getAllResponseHeaders', 'getResponseHeader', 'overrideMimeType', 'abort']
734
+
735
+ for functionName in jqXhrKeys
736
+ funct = returnValues.jqXhr[functionName]
737
+ expect(funct).not.toBeUndefined()
738
+ expect(funct.toString()).toEqual(baseXhr[functionName].toString())
739
+
550
740
  describe "createNewCollection", ->
551
741
  it "makes a new collection of the appropriate type", ->
552
742
  expect(base.data.createNewCollection("tasks", [buildTask(), buildTask()]) instanceof App.Collections.Tasks).toBe true
@@ -557,26 +747,6 @@ describe 'Brainstem Storage Manager', ->
557
747
  collection = base.data.createNewCollection("tasks", [buildTask(), buildTask()], loaded: true)
558
748
  expect(collection.loaded).toBe true
559
749
 
560
- describe "_wrapObjects", ->
561
- it "wraps elements in an array with objects unless they are already objects", ->
562
- expect(base.data._wrapObjects([])).toEqual []
563
- expect(base.data._wrapObjects(['a', 'b'])).toEqual [{a: []}, {b: []}]
564
- expect(base.data._wrapObjects(['a', 'b': []])).toEqual [{a: []}, {b: []}]
565
- expect(base.data._wrapObjects(['a', 'b': 'c'])).toEqual [{a: []}, {b: [{c: []}]}]
566
- expect(base.data._wrapObjects([{'a':[], b: 'c', d: 'e' }])).toEqual [{a: []}, {b: [{c: []}]}, {d: [{e: []}]}]
567
- expect(base.data._wrapObjects(['a', { b: 'c', d: 'e' }])).toEqual [{a: []}, {b: [{c: []}]}, {d: [{e: []}]}]
568
- expect(base.data._wrapObjects([{'a': []}, {'b': ['c', d: []]}])).toEqual [{a: []}, {b: [{c: []}, {d: []}]}]
569
-
570
- describe "_countRequiredServerRequests", ->
571
- it "should count the number of loads needed to get the date", ->
572
- expect(base.data._countRequiredServerRequests(['a'])).toEqual 1
573
- expect(base.data._countRequiredServerRequests(['a', 'b', 'c': []])).toEqual 1
574
- expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e']])).toEqual 3
575
- expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': []]])).toEqual 3
576
- expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': ['f']]])).toEqual 4
577
- expect(base.data._countRequiredServerRequests([{'a': ['d']}, 'b', 'c': ['e': ['f', 'g': ['h']]]])).toEqual 5
578
- expect(base.data._countRequiredServerRequests([{'a': ['d': ['h']]}, { 'b':['g'] }, 'c': ['e': ['f', 'i']]])).toEqual 6
579
-
580
750
  describe "error handling", ->
581
751
  describe "setting a storage manager default error handler", ->
582
752
  it "allows an error interceptor to be set on construction", ->
@@ -600,6 +770,22 @@ describe 'Brainstem Storage Manager', ->
600
770
  server.respond()
601
771
  expect(customHandler).toHaveBeenCalled()
602
772
 
773
+ it "should also get called any amount of layers deep", ->
774
+ errorHandler = jasmine.createSpy('errorHandler')
775
+ successHandler = jasmine.createSpy('successHandler')
776
+ taskOne = buildTask(id: 10, sub_task_ids: [12])
777
+ taskOneSub = buildTask(id: 12, parent_id: 10, sub_task_ids: [13], project_id: taskOne.get('workspace_id'))
778
+ respondWith server, "/api/tasks?include=sub_tasks&parents_only=true&per_page=20&page=1", data: { results: resultsArray("tasks", [taskOne]), tasks: resultsObject([taskOne, taskOneSub]) }
779
+ server.respondWith "GET", "/api/tasks?include=sub_tasks&only=12", [ 401, {"Content-Type": "application/json"}, JSON.stringify({ errors: ["Invalid OAuth 2 Request"]}) ]
780
+ base.data.loadCollection("tasks", filters: { parents_only: "true" }, include: [ "sub_tasks": ["sub_tasks"] ], success: successHandler, error: errorHandler)
781
+
782
+ expect(successHandler).not.toHaveBeenCalled()
783
+ expect(errorHandler).not.toHaveBeenCalled()
784
+ server.respond()
785
+ expect(successHandler).not.toHaveBeenCalled()
786
+ expect(errorHandler).toHaveBeenCalled()
787
+ expect(errorHandler.callCount).toEqual(1)
788
+
603
789
  describe "when no storage manager error interceptor is given", ->
604
790
  it "has a default error interceptor", ->
605
791
  manager = new Brainstem.StorageManager()