Mailspring/spec/tasks/change-mail-task-spec.coffee
Evan Morikawa d1c587a01c fix(spec): add support for async specs and disable misbehaving ones
More spec fixes

replace process.nextTick with setTimeout(fn, 0) for specs

Also added an unspy in the afterEach

Temporarily disable specs

fix(spec): start fixing specs

Summary:
This is the WIP fix to our spec runner.

Several tests have been completely commented out that will require
substantially more work to fix. These have been added to our sprint
backlog.

Other tests have been fixed to update to new APIs or to deal with genuine
bugs that were introduced without our knowing!

The most common non-trivial change relates to observing the `NylasAPI` and
`NylasAPIRequest`. We used to observe the arguments to `makeRequest`.
Unfortunately `NylasAPIRequest.run` is argumentless. Instead you can do:
`NylasAPIRequest.prototype.run.mostRecentCall.object.options` to get the
`options` passed into the object. the `.object` property grabs the context
of the spy when it was last called.

Fixing these tests uncovered several concerning issues with our test
runner. I spent a while tracking down why our participant-text-field-spec
was failling every so often. I chose that spec because it was the first
spec to likely fail, thereby requiring looking at the least number of
preceding files. I tried binary searching, turning on and off, several
files beforehand only to realize that the failure rate was not determined
by a particular preceding test, but rather the existing and quantity of
preceding tests, AND the number of console.log statements I had. There is
some processor-dependent race condition going on that needs further
investigation.

I also discovered an issue with the file-download-spec. We were getting
errors about it accessing a file, which was very suspicious given the code
stubs out all fs access. This was caused due to a spec that called an
async function outside ot a `waitsForPromise` block or a `waitsFor` block.
The test completed, the spies were cleaned up, but the downstream async
chain was still running. By the time the async chain finished the runner
was already working on the next spec and the spies had been restored
(causing the real fs access to run).

Juan had an idea to kill the specs once one fails to prevent cascading
failures. I'll implement this in the next diff update

Test Plan: npm test

Reviewers: juan, halla, jackie

Differential Revision: https://phab.nylas.com/D3501

Disable other specs

Disable more broken specs

All specs turned off till passing state

Use async-safe versions of spec functions

Add async test spec

Remove unused package code

Remove canary spec
2016-12-15 13:02:00 -05:00

570 lines
23 KiB
CoffeeScript

_ = require 'underscore'
{APIError,
Folder,
Thread,
Message,
Actions,
NylasAPI,
NylasAPIRequest,
Query,
DatabaseStore,
DatabaseTransaction,
Task,
Utils,
ChangeMailTask} = require 'nylas-exports'
xdescribe "ChangeMailTask", ->
beforeEach ->
@threadA = new Thread(id: 'A', folders: [new Folder(id:'folderA')])
@threadB = new Thread(id: 'B', folders: [new Folder(id:'folderB')])
@threadC = new Thread(id: 'C', folders: [new Folder(id:'folderC')])
@threadAChanged = new Thread(id: 'A', folders: [new Folder(id:'folderC')])
@threadAMesage1 = new Message(id:'A1', threadId: 'A')
@threadAMesage2 = new Message(id:'A2', threadId: 'A')
@threadBMesage1 = new Message(id:'B1', threadId: 'B')
threads = [@threadA, @threadB, @threadC]
messages = [@threadAMesage1, @threadAMesage2, @threadBMesage1]
# Instead of spying on find/findAll, we fake the evaluation of the query.
# This allows queries to be built with findAll().where().blabla... without
# a complex stub chain. Works since query "matchers" can be evaluated in JS
spyOn(DatabaseStore, 'run').andCallFake (query) =>
if query._klass is Message
models = messages
else if query._klass is Thread
models = threads
else
throw new Error("Not stubbed!")
models = models.filter (model) ->
for matcher in query._matchers
if matcher.evaluate(model) is false
return false
return true
if query._singular
models = models[0]
Promise.resolve(models)
@transaction = new DatabaseTransaction()
spyOn(@transaction, 'persistModels').andReturn(Promise.resolve())
spyOn(@transaction, 'persistModel').andReturn(Promise.resolve())
it "leaves subclasses to implement changesToModel", ->
task = new ChangeMailTask()
expect( => task.changesToModel() ).toThrow()
it "leaves subclasses to implement requestBodyForModel", ->
task = new ChangeMailTask()
expect( => task.requestBodyForModel() ).toThrow()
describe "performLocal", ->
it "rejects if it's an undo task and no restore values are present", ->
task = new ChangeMailTask()
task._isUndoTask = true
spyOn(task, '_performLocalThreads').andReturn(Promise.resolve())
waitsForPromise =>
task.performLocal().catch (err) =>
expect(err.message).toEqual("ChangeMailTask: No _restoreValues provided for undo task.")
it "should always call _performLocalThreads and then _performLocalMessages", ->
task = new ChangeMailTask()
task.threads = [@threadA]
@messagesResolve = null
spyOn(task, '_performLocalThreads').andCallFake => Promise.resolve()
spyOn(task, '_performLocalMessages').andCallFake =>
new Promise (resolve, reject) => @messagesResolve = resolve
runs ->
task.performLocal()
waitsFor ->
task._performLocalThreads.callCount > 0
runs ->
advanceClock()
@messagesResolve()
waitsFor ->
task._performLocalMessages.callCount > 0
runs ->
expect(task._performLocalThreads).toHaveBeenCalled()
expect(task._performLocalMessages).toHaveBeenCalled()
describe "_performLocalThreads", ->
beforeEach ->
@task = new ChangeMailTask()
@task.threads = [@threadA, @threadB]
# Note: Simulate applyChanges only changing threadA, not threadB
spyOn(@task, '_applyChanges').andReturn([@threadAChanged])
it "calls _applyChanges and writes changed threads to the database", ->
waitsForPromise =>
@task._performLocalThreads(@transaction).then =>
expect(@task._applyChanges).toHaveBeenCalledWith(@task.threads)
expect(@transaction.persistModels).toHaveBeenCalledWith([@threadAChanged])
describe "when processNestedMessages is overridden to return true", ->
it "fetches messages on changed threads and appends them to the messages to update", ->
waitsForPromise =>
@task.processNestedMessages = => true
@task._performLocalThreads(@transaction).then =>
expect(@task._applyChanges).toHaveBeenCalledWith(@task.threads)
expect(@task.messages).toEqual([@threadAMesage1, @threadAMesage2])
describe "_performLocalMessages", ->
beforeEach ->
@task = new ChangeMailTask()
@task.messages = [@threadAMesage1, @threadAMesage2, @threadBMesage1]
# Note: Simulate applyChanges only changing threadBMesage1
spyOn(@task, '_applyChanges').andReturn([@threadBMesage1])
it "calls _applyChanges and writes changed messages to the database", ->
waitsForPromise =>
@task._performLocalMessages(@transaction).then =>
expect(@task._applyChanges).toHaveBeenCalledWith(@task.messages)
expect(@transaction.persistModels).toHaveBeenCalledWith([@threadBMesage1])
describe "_applyChanges", ->
beforeEach ->
@task = new ChangeMailTask()
describe "when applying forwards", ->
beforeEach ->
spyOn(@task, '_shouldChangeBackwards').andReturn(false)
spyOn(@task, 'changesToModel').andCallFake (thread) =>
if thread is @threadC
return {folders: [new Folder(id: "different!")]}
else
return {folders: thread.folders}
it "should call changesToModel on each model", ->
@task._applyChanges([@threadA, @threadB])
expect(@task.changesToModel.callCount).toBe(2)
expect(@task.changesToModel.calls[0].args[0]).toBe(@threadA)
expect(@task.changesToModel.calls[1].args[0]).toBe(@threadB)
it "should return only the models with new values", ->
out = @task._applyChanges([@threadA, @threadB, @threadC])
expect(_.isArray(out)).toBe(true)
expect(out.length).toBe(1)
expect(out[0].id).toBe('C')
expect(out[0].folders[0].id).toBe('different!')
it "should save restore values only for changed items", ->
out = @task._applyChanges([@threadA, @threadB, @threadC])
expect(@task._restoreValues['A']).toBe(undefined)
expect(@task._restoreValues['B']).toBe(undefined)
expect(@task._restoreValues['C']).toEqual(folders: @threadC.folders)
it "should treat models as if they're frozen, returning new models", ->
out = @task._applyChanges([@threadA, @threadB, @threadC])
expect(out[0]).not.toBe(@threadC)
expect(out[0].id).toBe(@threadC.id)
expect(@task._restoreValues['C']).toEqual(folders: @threadC.folders)
describe "when applying backwards (reverting or undoing)", ->
beforeEach ->
spyOn(@task, '_shouldChangeBackwards').andReturn(true)
@task._restoreValues =
'C': {folders: [new Folder(id:'oldFolderC')]}
it "should return only models with the restore values, with the restore values applied", ->
out = @task._applyChanges([@threadA, @threadB, @threadC])
expect(_.isArray(out)).toBe(true)
expect(out.length).toBe(1)
expect(out[0].id).toBe('C')
expect(out[0].folders[0].id).toBe('oldFolderC')
describe "performRemote", ->
describe "if threads are set", ->
it "should only call _performRequests with threads", ->
@task = new ChangeMailTask()
@task.threads = [@threadA, @threadB]
@task.messages = [@threadAMesage1, @threadAMesage2]
spyOn(@task, '_performRequests').andReturn(Promise.resolve())
waitsForPromise =>
@task.performRemote().then =>
expect(@task._performRequests).toHaveBeenCalledWith(Thread, @task.threads)
expect(@task._performRequests.callCount).toBe(1)
describe "if only messages are set", ->
it "should only call _performRequests with messages", ->
@task = new ChangeMailTask()
@task.threads = []
@task.messages = [@threadAMesage1, @threadAMesage2]
spyOn(@task, '_performRequests').andReturn(Promise.resolve())
waitsForPromise =>
@task.performRemote().then =>
expect(@task._performRequests).toHaveBeenCalledWith(Message, @task.messages)
expect(@task._performRequests.callCount).toBe(1)
describe "if somehow there are no threads or messages", ->
it "should resolve", ->
@task = new ChangeMailTask()
@task.threads = []
@task.messages = []
waitsForPromise =>
@task.performRemote().then (code) =>
expect(code).toEqual(Task.Status.Success)
describe "if _performRequests resolves", ->
it "should resolve with Task.Status.Success", ->
@task = new ChangeMailTask()
spyOn(@task, '_performRequests').andReturn(Promise.resolve())
waitsForPromise =>
@task.performRemote().then (result) =>
expect(result).toBe(Task.Status.Success)
describe "if _performRequests rejects with a permanent network error", ->
beforeEach ->
@task = new ChangeMailTask()
@error = new APIError(statusCode: 400)
spyOn(@task, '_performRequests').andReturn(Promise.reject(@error))
spyOn(@task, 'performLocal').andReturn(Promise.resolve())
it "should set isReverting and call performLocal", ->
waitsForPromise =>
@task.performRemote().then (result) =>
expect(@task.performLocal).toHaveBeenCalled()
expect(@task._isReverting).toBe(true)
it "should resolve with Task.Status.Failed after reverting", ->
waitsForPromise =>
@task.performRemote().then (result) =>
expect(result).toEqual([Task.Status.Failed, @error])
describe "if _performRequests rejects with a temporary network error", ->
beforeEach ->
@task = new ChangeMailTask()
spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)))
spyOn(@task, 'performLocal').andReturn(Promise.resolve())
it "should not revert", ->
waitsForPromise =>
@task.performRemote().then (result) =>
expect(@task.performLocal).not.toHaveBeenCalled()
expect(@task._isReverting).not.toBe(true)
it "should resolve with Task.Status.Retry", ->
waitsForPromise =>
@task.performRemote().then (result) =>
expect(result).toBe(Task.Status.Retry)
describe "_performRequests", ->
beforeEach ->
@task = new ChangeMailTask()
@task._restoreValues =
'A': {}
'B': {}
'C': {}
'A1': {}
spyOn(@task, 'requestBodyForModel').andCallFake (model) =>
if model is @threadA
return {field: 'thread-a-body'}
if model is @threadB
return {field: 'thread-b-body'}
if model is @threadAMesage1
return {field: 'message-1'}
it "should call NylasAPIRequest.run for each model, passing the result of requestBodyForModel", ->
spyOn(NylasAPIRequest.prototype, 'run').andReturn(Promise.resolve())
runs ->
@task._performRequests(Thread, [@threadA, @threadB])
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 2
runs ->
expect(NylasAPIRequest.prototype.run.calls[0].args[0].body).toEqual({field: 'thread-a-body'})
expect(NylasAPIRequest.prototype.run.calls[1].args[0].body).toEqual({field: 'thread-b-body'})
it "should resolve when all of the requests complete", ->
promises = []
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> promises.push({resolve, reject})
resolved = false
runs ->
@task._performRequests(Thread, [@threadA, @threadB]).then =>
resolved = true
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 2
runs ->
expect(resolved).toBe(false)
promises[0].resolve()
advanceClock()
expect(resolved).toBe(false)
promises[1].resolve()
advanceClock()
expect(resolved).toBe(true)
it "should carry on and resolve if a request 404s, since the NylasAPI manager will clean the object from the cache", ->
promises = []
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> promises.push({resolve, reject})
resolved = false
runs ->
@task._performRequests(Thread, [@threadA, @threadB]).then =>
resolved = true
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 2
runs ->
promises[0].resolve()
promises[1].reject(new APIError(statusCode: 404))
advanceClock()
expect(resolved).toBe(true)
it "should reject with the request error encountered by any request", ->
promises = []
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> promises.push({resolve, reject})
err = null
runs ->
@task._performRequests(Thread, [@threadA, @threadB]).catch (error) =>
err = error
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 2
runs ->
expect(err).toBe(null)
promises[0].resolve()
advanceClock()
expect(err).toBe(null)
apiError = new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)
promises[1].reject(apiError)
advanceClock()
expect(err).toBe(apiError)
it "should use /threads when the klass provided is Thread", ->
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> #noop
runs ->
@task._performRequests(Thread, [@threadA, @threadB])
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 2
runs ->
path = "/threads/#{@threadA.id}"
expect(NylasAPIRequest.prototype.run.calls[0].args[0].path).toBe(path)
expect(NylasAPIRequest.prototype.run.calls[0].args[0].accountId).toBe(@threadA.accountId)
it "should use /messages when the klass provided is Message", ->
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> #noop
runs ->
@task._performRequests(Message, [@threadAMesage1])
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 1
runs ->
path = "/messages/#{@threadAMesage1.id}"
expect(NylasAPIRequest.prototype.run.calls[0].args[0].path).toBe(path)
expect(NylasAPIRequest.prototype.run.calls[0].args[0].accountId).toBe(@threadAMesage1.accountId)
it "should decrement change counts as requests complete", ->
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> #noop
spyOn(@task, '_removeLock')
runs ->
@task._performRequests(Thread, [@threadAMesage1])
waitsFor ->
NylasAPIRequest.prototype.run.callCount is 1
runs ->
NylasAPIRequest.prototype.run.calls[0].args[0].beforeProcessing({})
expect(@task._removeLock).toHaveBeenCalledWith(@threadAMesage1)
it "should make no more than 10 requests at once", ->
resolves = []
spyOn(@task, '_removeLock')
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) -> resolves.push(resolve)
threads = []
threads.push new Thread(id: "#{idx}", subject: idx) for idx in [0..100]
@task._restoreValues = _.map threads, (t) -> {some: 'data'}
@task._performRequests(Thread, threads)
advanceClock()
expect(resolves.length).toEqual(5)
advanceClock()
expect(resolves.length).toEqual(5)
resolves[0]()
resolves[1]()
advanceClock()
expect(resolves.length).toEqual(7)
resolves[idx]() for idx in [2...7]
advanceClock()
expect(resolves.length).toEqual(12)
it "should stop making requests after non-404 network errors", ->
resolves = []
rejects = []
spyOn(@task, '_removeLock')
spyOn(NylasAPIRequest.prototype, 'run').andCallFake ->
new Promise (resolve, reject) ->
resolves.push(resolve)
rejects.push(reject)
threads = []
threads.push new Thread(id: "#{idx}", subject: idx) for idx in [0..100]
@task._restoreValues = _.map threads, (t) -> {some: 'data'}
@task._performRequests(Thread, threads).catch (err) ->
# Need to catch the error so it's not a
# Promise.possiblyUnhandledRejection, which will stop the tests.
expect(err.statusCode).toBe 400
advanceClock()
expect(resolves.length).toEqual(5)
resolves[idx]() for idx in [0...4]
advanceClock()
expect(resolves.length).toEqual(9)
# simulate request failure
reject = rejects[rejects.length - 1]
reject(new APIError(statusCode: 400))
advanceClock()
# simulate more requests succeeding
resolves[idx]() for idx in [5...9]
advanceClock()
# check that no more requests have been queued
expect(resolves.length).toEqual(9)
describe "optimistic object locking", ->
beforeEach ->
@task = new ChangeMailTask()
spyOn(@task, '_performLocalThreads').andReturn(Promise.resolve())
spyOn(@task, '_lockAll')
it "increments the locks in performLocal", ->
waitsForPromise =>
@task.performLocal().then =>
expect(@task._lockAll).toHaveBeenCalled()
describe "when the task is reverting after request failures", ->
it "should not increment change locks", ->
@task._isReverting = true
waitsForPromise =>
@task.performLocal().then =>
expect(@task._lockAll).not.toHaveBeenCalled()
describe "when the task is undoing", ->
it "should increment change locks", ->
@task._isUndoTask = true
@task._restoreValues = {}
waitsForPromise =>
@task.performLocal().then =>
expect(@task._lockAll).toHaveBeenCalled()
describe "when performRemote is returning Task.Status.Success", ->
it "should clean up locks", ->
spyOn(@task, '_performRequests').andReturn(Promise.resolve())
spyOn(@task, '_ensureLocksRemoved')
waitsForPromise =>
@task.performRemote().then =>
expect(@task._ensureLocksRemoved).toHaveBeenCalled()
describe "when performRemote is returning Task.Status.Failed after reverting", ->
it "should clean up locks", ->
spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: 400)))
spyOn(@task, '_ensureLocksRemoved')
waitsForPromise =>
@task.performRemote().then =>
expect(@task._ensureLocksRemoved).toHaveBeenCalled()
describe "when performRemote is returning Task.Status.Retry", ->
it "should not clean up locks", ->
spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)))
spyOn(@task, '_ensureLocksRemoved')
waitsForPromise =>
@task.performRemote().then =>
expect(@task._ensureLocksRemoved).not.toHaveBeenCalled()
describe "_lockAll", ->
beforeEach ->
@task = new ChangeMailTask()
@task.threads = [@threadA, @threadB]
spyOn(NylasAPI, 'incrementRemoteChangeLock')
it "should keep a hash of the items that it locks", ->
@task._lockAll()
expect(NylasAPI.incrementRemoteChangeLock.callCount).toBe(2)
expect(@task._locked).toEqual('A': 1, 'B': 1)
it "should not break anything if it's accidentally called twice", ->
@task._lockAll()
@task._lockAll()
expect(NylasAPI.incrementRemoteChangeLock.callCount).toBe(4)
expect(@task._locked).toEqual('A': 2, 'B': 2)
describe "_ensureLocksRemoved", ->
it "should decrement locks given any aribtrarily messed up lock state and reset the locked array", ->
@task = new ChangeMailTask()
@task.threads = [@threadA, @threadB, @threadC]
spyOn(NylasAPI, 'decrementRemoteChangeLock')
@task._locked = {'A': 2, 'B': 2, 'C': 1}
@task._ensureLocksRemoved()
expect(NylasAPI.decrementRemoteChangeLock.callCount).toBe(5)
expect(NylasAPI.decrementRemoteChangeLock.calls[0].args[1]).toBe('A')
expect(NylasAPI.decrementRemoteChangeLock.calls[1].args[1]).toBe('A')
expect(NylasAPI.decrementRemoteChangeLock.calls[2].args[1]).toBe('B')
expect(NylasAPI.decrementRemoteChangeLock.calls[3].args[1]).toBe('B')
expect(NylasAPI.decrementRemoteChangeLock.calls[4].args[1]).toBe('C')
expect(@task._locked).toEqual(null)
describe "createIdenticalTask", ->
it "should return a copy of the task, but with the objects converted into object ids", ->
task = new ChangeMailTask()
task.messages = [@threadAMesage1, @threadAMesage2]
clone = task.createIdenticalTask()
expect(clone.messages).toEqual([@threadAMesage1.id, @threadAMesage2.id])
task = new ChangeMailTask()
task.threads = [@threadA, @threadB]
clone = task.createIdenticalTask()
expect(clone.threads).toEqual([@threadA.id, @threadB.id])
task = new ChangeMailTask()
task.threads = [@threadA.id, @threadB.id]
clone = task.createIdenticalTask()
expect(clone.threads).toEqual([@threadA.id, @threadB.id])
describe "createUndoTask", ->
it "should return a task initialized with _isUndoTask and _restoreValues", ->
task = new ChangeMailTask()
task.messages = [@threadAMesage1, @threadAMesage2]
task._restoreValues = {'A': 'bla'}
undo = task.createUndoTask()
expect(undo.messages).toEqual([@threadAMesage1.id, @threadAMesage2.id])
expect(undo._restoreValues).toBe(task._restoreValues)
expect(undo._isUndoTask).toBe(true)
it "should throw if you try to make an undo task of an undo task", ->
task = new ChangeMailTask()
task._isUndoTask = true
expect( -> task.createUndoTask()).toThrow()
it "should throw if _restoreValues are not availble", ->
task = new ChangeMailTask()
task.messages = [@threadAMesage1, @threadAMesage2]
task._restoreValues = null
expect( -> task.createUndoTask()).toThrow()
describe "isDependentOnTask", ->
it "should return true if another, older ChangeMailTask involves the same threads", ->
a = new ChangeMailTask()
a.threads = ['t1', 't2', 't3']
a.sequentialId = 0
b = new ChangeMailTask()
b.threads = ['t3', 't4', 't7']
b.sequentialId = 1
c = new ChangeMailTask()
c.threads = ['t0', 't7']
c.sequentialId = 2
expect(a.isDependentOnTask(b)).toEqual(false)
expect(a.isDependentOnTask(c)).toEqual(false)
expect(b.isDependentOnTask(a)).toEqual(true)
expect(c.isDependentOnTask(a)).toEqual(false)
expect(c.isDependentOnTask(b)).toEqual(true)