feat(tasks): add Create, Update, Destroy tasks plus spec & lint fixes

Summary:
1. **Generic CUD Tasks**: There is now a generic `CreateModelTask`,
`UpdateModelTask`, and `DestroyModelTask`. These can either be used as-is
or trivially overridden to easily update simple objects. Hopefully all of
the boilerplate rollback, error handling, and undo logic won't have to be
re-duplicated on every task. There are also tests for these tasks. We use
them to perform mutating actions on `Metadata` objects.

1. **Failing on Promise Rejects**: Turns out that if a Promise rejected
due to an error or `Promise.reject` we were ignoring it and letting tests
pass. Now, tests will Fail if any unhandled promise rejects. This
uncovered a variety of errors throughout the test suite that had to be
fixed. The most significant one was during the `theme-manager` tests when
all packages (and their stores with async DB requests) was loaded. Long
after the `theme-manager` specs finished, those DB requests were
(somtimes) silently failing.

1. **Globally stub `DatabaseStore._query`**: All tests shouldn't actually
make queries on the database. Furthremore, the `inTransaction` block
doesn't resolve at all unless `_query` is stubbed. Instead of manually
remembering to do this in every test that touches the DB, it's now mocked
in `spec_helper`. This broke a handful of tests that needed to be manually
fixed.

1. **ESLint Fixes**: Some minor fixes to the linter config to prevent
yelling about minor ES6 things and ensuring we have the correct parser.

Test Plan: new tests

Reviewers: bengotow, juan, drew

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

Remove cloudState and N1-Send-Later
This commit is contained in:
Evan Morikawa 2016-01-04 16:39:14 -08:00
parent da54fa7e29
commit 6695de4187
30 changed files with 755 additions and 182 deletions

View file

@ -13,8 +13,13 @@
"react/prop-types": [2, {"ignore": ["children"]}],
"eqeqeq": [2, "smart"],
"id-length": [0],
"object-curly-spacing": [0],
"no-console": [0],
"no-loop-func": [0],
"new-cap": [2, {"capIsNew": false}],
"no-shadow": [1]
}
"no-shadow": [1],
"quotes": [0],
"semi": [0]
},
"parser": "babel-eslint"
}

View file

@ -87,6 +87,7 @@ describe "ComposerView", ->
describe "A blank composer view", ->
beforeEach ->
useDraft.call(@)
@composer = ReactTestUtils.renderIntoDocument(
<ComposerView draftClientId="test123" />
)

View file

@ -25,6 +25,8 @@ describe "Composer Quoted Text", ->
@htmlNoQuote = 'Test <strong>HTML</strong><br>'
@htmlWithQuote = 'Test <strong>HTML</strong><br><blockquote class="gmail_quote">QUOTE</blockquote>'
spyOn(Composer.prototype, "_prepareForDraft")
@composer = ReactTestUtils.renderIntoDocument(<Composer draftClientId="unused"/>)
@composer._proxy = trigger: ->
spyOn(@composer, "_addToProxy")

View file

@ -102,7 +102,6 @@ describe "NylasSyncWorkerPool", ->
"id": @thread.id,
"timestamp": "2015-08-26T17:36:45.297Z"
spyOn(DatabaseTransaction.prototype, '_query').andCallFake -> Promise.resolve([])
spyOn(DatabaseTransaction.prototype, 'unpersistModel')
it "should resolve if the object cannot be found", ->

View file

@ -7,9 +7,6 @@ DatabaseStore = require '../src/flux/stores/database-store'
DatabaseTransaction = require '../src/flux/stores/database-transaction'
describe "NylasAPI", ->
beforeEach ->
spyOn(DatabaseStore, '_query').andCallFake => Promise.resolve([])
describe "handleModel404", ->
it "should unpersist the model from the cache that was requested", ->
model = new Thread(id: 'threadidhere')

View file

@ -116,6 +116,16 @@ beforeEach ->
DatabaseStore._transactionQueue = undefined
## If we don't spy on DatabaseStore._query, then
#`DatabaseStore.inTransaction` will never complete and cause all tests
#that depend on transactions to hang.
#
# @_query("BEGIN IMMEDIATE TRANSACTION") never resolves because
# DatabaseStore._query never runs because the @_open flag is always
# false because we never setup the DB when `NylasEnv.inSpecMode` is
# true.
spyOn(DatabaseStore, '_query').andCallFake => Promise.resolve([])
TaskQueue._queue = []
TaskQueue._completed = []
TaskQueue._onlineStatus = true

View file

@ -26,6 +26,7 @@ describe "DatabaseStore", ->
# Note: We spy on _query and test all of the convenience methods that sit above
# it. None of these tests evaluate whether _query works!
jasmine.unspy(DatabaseStore, "_query")
spyOn(DatabaseStore, "_query").andCallFake (query, values=[], options={}) =>
@performed.push({query: query, values: values})
return Promise.resolve([])

View file

@ -128,6 +128,7 @@ describe "DraftStoreProxy", ->
describe "teardown", ->
it "should mark the session as destroyed", ->
spyOn(DraftStoreProxy.prototype, "prepare")
proxy = new DraftStoreProxy('client-id')
proxy.teardown()
expect(proxy._destroyed).toEqual(true)
@ -135,11 +136,12 @@ describe "DraftStoreProxy", ->
describe "prepare", ->
beforeEach ->
@draft = new Message(draft: true, body: '123', clientId: 'client-id')
spyOn(DraftStoreProxy.prototype, "prepare")
@proxy = new DraftStoreProxy('client-id')
spyOn(@proxy, '_setDraft')
spyOn(DatabaseStore, 'run').andCallFake =>
spyOn(DatabaseStore, 'run').andCallFake (modelQuery) =>
Promise.resolve(@draft)
@proxy._draftPromise = null
jasmine.unspy(DraftStoreProxy.prototype, "prepare")
it "should call setDraft with the retrieved draft", ->
waitsForPromise =>
@ -174,7 +176,6 @@ describe "DraftStoreProxy", ->
@proxy = new DraftStoreProxy('client-id', @draft)
spyOn(DatabaseTransaction.prototype, "persistModel").andReturn Promise.resolve()
spyOn(DatabaseTransaction.prototype, "_query").andReturn Promise.resolve()
spyOn(Actions, "queueTask").andReturn Promise.resolve()
it "should ignore the update unless it applies to the current draft", ->

View file

@ -37,7 +37,6 @@ class TestExtension extends ComposerExtension
describe "DraftStore", ->
beforeEach ->
spyOn(DatabaseTransaction.prototype, '_query').andCallFake -> Promise.resolve([])
spyOn(NylasEnv, 'newWindow').andCallFake ->
for id, session of DraftStore._draftSessions
if session.teardown
@ -671,6 +670,7 @@ describe "DraftStore", ->
spyOn(DraftStore, "_doneWithSession").andCallThrough()
spyOn(DraftStore, "trigger")
spyOn(SoundRegistry, "playSound")
spyOn(Actions, "queueTask")
it "plays a sound immediately when sending draft", ->
spyOn(NylasEnv.config, "get").andReturn true
@ -686,20 +686,14 @@ describe "DraftStore", ->
it "sets the sending state when sending", ->
spyOn(NylasEnv, "isMainWindow").andReturn true
spyOn(Actions, "queueTask").andCallThrough()
runs ->
DraftStore._onSendDraft(draftClientId)
waitsFor ->
Actions.queueTask.calls.length > 0
runs ->
expect(DraftStore.isSendingDraft(draftClientId)).toBe true
DraftStore._onSendDraft(draftClientId)
expect(DraftStore.isSendingDraft(draftClientId)).toBe true
# Since all changes haven't been applied yet, we want to ensure that
# no view of the draft renders the draft as if its sending, but with
# the wrong text.
it "does NOT trigger until the latest changes have been applied", ->
spyOn(NylasEnv, "isMainWindow").andReturn true
spyOn(Actions, "queueTask").andCallThrough()
runs ->
DraftStore._onSendDraft(draftClientId)
expect(DraftStore.trigger).not.toHaveBeenCalled()
@ -743,7 +737,6 @@ describe "DraftStore", ->
expect(NylasEnv.close).not.toHaveBeenCalled()
it "forces a commit to happen before sending", ->
spyOn(Actions, "queueTask")
runs ->
DraftStore._onSendDraft(draftClientId)
waitsFor ->
@ -752,7 +745,6 @@ describe "DraftStore", ->
expect(@forceCommit).toBe true
it "queues a SendDraftTask", ->
spyOn(Actions, "queueTask")
runs ->
DraftStore._onSendDraft(draftClientId)
waitsFor ->
@ -768,7 +760,6 @@ describe "DraftStore", ->
spyOn(NylasEnv, "getWindowType").andReturn "composer"
spyOn(NylasEnv, "isMainWindow").andReturn false
spyOn(NylasEnv, "close")
spyOn(Actions, "queueTask")
runs ->
DraftStore._onSendDraft(draftClientId)
waitsFor ->

View file

@ -129,12 +129,14 @@ describe "ThreadCountsStore", ->
describe "when a count fails", ->
it "should not immediately try to count any other categories", ->
spyOn(console, "warn")
ThreadCountsStore._fetchCountsMissing()
spyOn(ThreadCountsStore, '_fetchCountsMissing')
spyOn(console, 'error')
advanceClock()
@countReject(new Error("Oh man something really bad."))
advanceClock()
expect(console.warn).toHaveBeenCalled()
expect(ThreadCountsStore._fetchCountsMissing).not.toHaveBeenCalled()
describe "_fetchCountForCategory", ->
@ -174,7 +176,6 @@ describe "ThreadCountsStore", ->
})
it "should persist the new counts to the database", ->
spyOn(DatabaseStore, '_query').andCallFake -> Promise.resolve([])
spyOn(DatabaseTransaction.prototype, 'persistJSONBlob')
runs =>
ThreadCountsStore._saveCounts()

View file

@ -50,7 +50,6 @@ describe "ChangeMailTask", ->
spyOn(DatabaseTransaction.prototype, 'persistModels').andReturn(Promise.resolve())
spyOn(DatabaseTransaction.prototype, 'persistModel').andReturn(Promise.resolve())
spyOn(DatabaseTransaction.prototype, '_query').andReturn(Promise.resolve([]))
it "leaves subclasses to implement changesToModel", ->
task = new ChangeMailTask()

View file

@ -0,0 +1,169 @@
import {
Task,
NylasAPI,
APIError,
CreateModelTask,
DatabaseTransaction } from 'nylas-exports'
describe("CreateModelTask", () => {
beforeEach(() => {
spyOn(DatabaseTransaction.prototype, "persistModel")
});
it("constructs without error", () => {
const t = new CreateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new CreateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if `requiredFields` are missing", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {foo: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass data field.*/)
}
});
it("throws if the model name can't be found", () => {
const accountId = "a123"
const modelName = "dne"
const endpoint = "/endpoint"
const data = {stuff: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("persists the new model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {key: "foo", value: "bar"}
const requiredFields = ["key", "value"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model.constructor.name).toBe(modelName)
expect(model.key).toBe("foo")
expect(model.value).toBe("bar")
});
});
});
});
describe("performRemote", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {key: "foo", value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("makes a POST request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("POST")
expect(opts.body.key).toBe("foo")
})
});
it("marks task success on API success", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote((status) => {
expect(status).toBe(Task.Status.Success)
})
});
it("retries on non permanent errors", () => {
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(new APIError({statusCode: 429}))
})
performRemote((status) => {
expect(status).toBe(Task.Status.Retry)
})
});
it("fails on permanent errors", () => {
const err = new APIError({statusCode: 500})
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
it("fails on other thrown errors", () => {
const err = new Error("foo")
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
});
describe("undo", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {key: "foo", value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate DestroyModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("DestroyModelTask")
expect(undoTask.clientId).toBe(this.task.model.clientId)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -29,7 +29,6 @@ describe "DestroyCategoryTask", ->
category: category
beforeEach ->
spyOn(DatabaseTransaction.prototype, '_query').andCallFake -> Promise.resolve([])
spyOn(DatabaseTransaction.prototype, 'persistModel').andCallFake -> Promise.resolve()
describe "performLocal", ->

View file

@ -0,0 +1,140 @@
import {
Metadata,
NylasAPI,
DatabaseStore,
DestroyModelTask,
DatabaseTransaction} from 'nylas-exports'
describe("DestroyModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "unpersistModel")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(this.existingModel)
})
this.defaultArgs = {
clientId: "local-123",
accountId: "a123",
modelName: "Metadata",
endpoint: "/endpoint",
}
});
it("constructs without error", () => {
const t = new DestroyModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new DestroyModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if the model name can't be found", () => {
this.defaultArgs.modelName = "dne"
const t = new DestroyModelTask(this.defaultArgs)
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("throws if it can't find the object", () => {
jasmine.unspy(DatabaseStore, "findBy")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(null)
})
const t = new DestroyModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
throw new Error("Shouldn't succeed")
}).catch((err) => {
expect(err.message).toMatch(/^Couldn't find the model with clientId.*/)
});
});
});
it("unpersists the new existing model properly", () => {
const unpersistFn = DatabaseTransaction.prototype.unpersistModel
const t = new DestroyModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(unpersistFn).toHaveBeenCalled()
const model = unpersistFn.calls[0].args[0]
expect(model).toBe(this.existingModel)
});
});
});
});
describe("performRemote", () => {
beforeEach(() => {
this.task = new DestroyModelTask(this.defaultArgs)
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("throws an error if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
});
});
});
it("makes a DELETE request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("DELETE")
expect(opts.path).toBe("/endpoint/server-123")
expect(opts.accountId).toBe(this.defaultArgs.accountId)
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new DestroyModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate CreateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("CreateModelTask")
expect(undoTask.data).toBe(this.task.oldModel)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -12,7 +12,6 @@ _ = require 'underscore'
describe "EventRSVPTask", ->
beforeEach ->
spyOn(DatabaseStore, 'find').andCallFake => Promise.resolve(@event)
spyOn(DatabaseTransaction.prototype, '_query').andCallFake -> Promise.resolve([])
spyOn(DatabaseTransaction.prototype, 'persistModel').andCallFake -> Promise.resolve()
@myName = "Ben Tester"
@myEmail = "tester@nylas.com"

View file

@ -15,16 +15,6 @@ _ = require 'underscore'
DBt = DatabaseTransaction.prototype
describe "SendDraftTask", ->
beforeEach ->
## TODO FIXME: If we don't spy on DatabaseStore._query, then
# `DatabaseStore.inTransaction` will never complete and cause all
# tests that depend on transactions to hang.
#
# @_query("BEGIN IMMEDIATE TRANSACTION") never resolves because
# DatabaseStore._query never runs because the @_open flag is always
# false because we never setup the DB when `NylasEnv.inSpecMode` is
# true.
spyOn(DatabaseStore, '_query').andCallFake => Promise.resolve([])
describe "isDependentTask", ->
it "is not dependent on any pending SyncbackDraftTasks", ->

View file

@ -27,7 +27,6 @@ describe "SyncbackCategoryTask", ->
beforeEach ->
spyOn(NylasAPI, "makeRequest").andCallFake ->
Promise.resolve(id: "server-444")
spyOn(DatabaseTransaction.prototype, "_query").andCallFake => Promise.resolve([])
spyOn(DatabaseTransaction.prototype, "persistModel")
it "sends API req to /labels if user uses labels", ->

View file

@ -41,8 +41,6 @@ describe "SyncbackDraftTask", ->
else if clientId is "missingDraftId" then Promise.resolve()
else return Promise.resolve()
spyOn(DatabaseTransaction.prototype, "_query").andCallFake ->
Promise.resolve([])
spyOn(DatabaseTransaction.prototype, "persistModel").andCallFake ->
Promise.resolve()

View file

@ -0,0 +1,143 @@
import {
Metadata,
NylasAPI,
DatabaseStore,
UpdateModelTask,
DatabaseTransaction} from 'nylas-exports'
describe("UpdateModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "persistModel")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(this.existingModel)
})
this.defaultArgs = {
clientId: "local-123",
newData: {value: "baz"},
accountId: "a123",
modelName: "Metadata",
endpoint: "/endpoint",
}
});
it("constructs without error", () => {
const t = new UpdateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new UpdateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if the model name can't be found", () => {
this.defaultArgs.modelName = "dne"
const t = new UpdateModelTask(this.defaultArgs)
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("throws if it can't find the object", () => {
jasmine.unspy(DatabaseStore, "findBy")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(null)
})
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
throw new Error("Shouldn't succeed")
}).catch((err) => {
expect(err.message).toMatch(/^Couldn't find the model with clientId.*/)
});
});
});
it("persists the new existing model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model).toBe(this.existingModel)
});
});
});
});
describe("performRemote", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("throws an error if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
});
});
});
it("makes a PUT request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("PUT")
expect(opts.path).toBe("/endpoint/server-123")
expect(opts.body).toBe(this.defaultArgs.newData)
expect(opts.accountId).toBe(this.defaultArgs.accountId)
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate UpdateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("UpdateModelTask")
expect(undoTask.newData).toBe(this.task.oldModel)
expect(undoTask.newData.value).toBe("bar")
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -17,6 +17,14 @@ describe "ThemeManager", ->
spyOn(console, "warn")
spyOn(console, "error")
theme_dir = path.resolve(__dirname, '../internal_packages')
# Don't load ALL of our packages. Some packages may do very expensive
# and asynchronous things on require, including hitting the database.
packagePaths = [
path.resolve(__dirname, '../internal_packages/ui-light')
path.resolve(__dirname, '../internal_packages/ui-dark')
]
spyOn(NylasEnv.packages, "getAvailablePackagePaths").andReturn packagePaths
NylasEnv.packages.packageDirPaths.unshift(theme_dir)
themeManager = new ThemeManager({packageManager: NylasEnv.packages, resourcePath, configDirPath})
@ -33,7 +41,7 @@ describe "ThemeManager", ->
it 'getLoadedThemes get all the loaded themes', ->
themes = themeManager.getLoadedThemes()
expect(themes.length).toBeGreaterThan(2)
expect(themes.length).toEqual(2)
it 'getActiveThemes get all the active themes', ->
waitsForPromise ->

View file

@ -500,10 +500,6 @@ class Actions
###
@pushSheet: ActionScopeWindow
@metadataError: ActionScopeWindow
@metadataCreated: ActionScopeWindow
@metadataDestroyed: ActionScopeWindow
###
Public: Publish a user event to any analytics services linked to N1.
###

View file

@ -121,6 +121,8 @@ class ThreadCountsStore extends NylasStore
# but we don't want to flood the db with expensive SELECT COUNT queries.
_.delay(@_fetchCountsMissing, 3000)
@_saveCountsSoon()
.catch (err) ->
console.warn(err)
# This method is not intended to return a promise and it
# could cause strange chaining.

View file

@ -1,57 +0,0 @@
_ = require 'underscore'
Task = require './task'
{APIError} = require '../errors'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
DatabaseStore = require '../stores/database-store'
AccountStore = require '../stores/account-store'
module.exports =
class CreateMetadataTask extends Task
constructor: ({@type, @publicId, @key, @value}) ->
super
@name = "CreateMetadataTask"
shouldDequeueOtherTask: (other) ->
@_isSameTask(other) or @_isOldDestroyTask(other)
_isSameTask: (other) ->
other instanceof CreateMetadataTask and
@type is other.type and @publicId is other.publicId and
@key is other.key and @value is other.value
_isOldDestroyTask: (other) ->
other.name is "DestroyMetadataTask"
@type is other.type and @publicId is other.publicId and @key is other.key
performLocal: ->
return Promise.reject(new Error("Must pass a type")) unless @type?
@metadatum = new Metadata({@type, @publicId, @key, @value})
DatabaseStore.inTransaction (t) =>
t.persistModel(@metadatum)
performRemote: ->
new Promise (resolve, reject) =>
EdgehillAPI.request
method: "POST"
path: "/metadata/#{AccountStore.current().id}/#{@type}/#{@publicId}"
body:
key: @key
value: @value
success: =>
Actions.metadataCreated @type, @metadatum
resolve(Task.Status.Success)
error: (apiError) =>
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
resolve(Task.Status.Failed)
_baseErrorData: ->
action: "create"
className: @constructor.name
type: @type
publicId: @publicId
key: @key
value: @value

View file

@ -0,0 +1,76 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class CreateModelTask extends Task {
constructor({data = {}, modelName, endpoint, requiredFields = [], accountId} = {}) {
super()
this.data = data
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
this.requiredFields = requiredFields || []
}
shouldDequeueOtherTask(other) {
return (other instanceof CreateModelTask &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.data, other.data))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["accountId", "endpoint"])
for (const field of this.requiredFields) {
if (this.data[field] === null || this.data[field] === undefined) {
throw new Error(`Must pass data field "${field}"`)
}
}
const Klass = require('nylas-exports')[this.modelName]
if (!_.isFunction(Klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
this.model = new Klass(this.data)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(this.model)
});
}
performRemote() {
return NylasAPI.makeRequest({
path: this.endpoint,
method: "POST",
accountId: this.accountId,
body: this.model.toJSON(),
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const DestroyModelTask = require('./destroy-model-task')
const undoTask = new DestroyModelTask({
clientId: this.model.clientId,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -1,76 +0,0 @@
_ = require 'underscore'
Task = require './task'
{APIError} = require '../errors'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
DatabaseStore = require '../stores/database-store'
AccountStore = require '../stores/account-store'
module.exports =
class DestroyMetadataTask extends Task
constructor: ({@type, @publicId, @key}) ->
super
@name = "DestroyMetadataTask"
shouldDequeueOtherTask: (other) ->
@_isSameTask(other) or @_isOldCreateTask(other)
_isSameTask: (other) ->
other instanceof DestroyMetadataTask and
@type is other.type and @publicId is other.publicId and @key is other.key
_isOldCreateTask: (other) ->
other.name is "CreateMetadataTask"
@type is other.type and @publicId is other.publicId and @key is other.key
performLocal: ->
return Promise.reject(new Error("Must pass a type")) unless @type?
return Promise.reject(new Error("Must pass an publicId")) unless @publicId?
new Promise (resolve, reject) =>
if @key?
matcher = {@type, @publicId, @key}
else
matcher = {@type, @publicId}
DatabaseStore.findAll(Metadata, matcher)
.then (models) ->
if (models ? []).length is 0
resolve()
else
DatabaseStore.inTransaction (t) ->
promises = models.map (m) ->
t.unpersistModel(m)
Promise.settle(promises)
.then(resolve)
.catch(reject)
.catch (error) ->
console.error "Error finding Metadata to destroy", error
console.error error.stack
reject(error)
performRemote: -> new Promise (resolve, reject) =>
if @key?
body = {@key}
else
body = null
EdgehillAPI.request
method: "DELETE"
path: "/metadata/#{AccountStore.current().id}/#{@type}/#{@publicId}"
body: body
success: =>
Actions.metadataDestroyed(@type)
resolve(Task.Status.Success)
error: (apiError) =>
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
resolve(Task.Status.Failed)
_baseErrorData: ->
action: "destroy"
className: @constructor.name
type: @type
publicId: @publicId
key: @key

View file

@ -0,0 +1,76 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class DestroyModelTask extends Task {
constructor({clientId, modelName, endpoint, accountId} = {}) {
super()
this.clientId = clientId
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
}
shouldDequeueOtherTask(other) {
return (other instanceof DestroyModelTask &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
this.clientId === other.clientId)
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["clientId", "accountId", "endpoint"])
const klass = this.getModelConstructor()
if (!_.isFunction(klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
return DatabaseStore.findBy(klass, {clientId: this.clientId}).then((model) => {
if (!model) {
throw new Error(`Couldn't find the model with clientId ${this.clientId}`)
}
this.serverId = model.serverId
this.oldModel = model.clone()
return DatabaseStore.inTransaction((t) => {
return t.unpersistModel(model)
});
})
}
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to destroy remotely")
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
method: "DELETE",
accountId: this.accountId,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const CreateModelTask = require('./create-model-task')
const undoTask = new CreateModelTask({
data: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -269,6 +269,29 @@ class Task
return Promise.reject(err)
########################################################################
########################## HELPER METHODS ##############################
########################################################################
validateRequiredFields: (fields=[]) =>
for field in fields
if not this[field]? then throw new Error("Must pass #{field}")
# Most tasks that interact with a RESTful API will want to behave in a
# similar way. We retry on temproary API error codes and permenantly
# fail on others.
apiErrorHandler: (err={}) =>
{PermanentErrorCodes} = require '../nylas-api'
{APIError} = require '../errors'
if err instanceof APIError
if err.statusCode in PermanentErrorCodes
return Promise.resolve([Task.Status.Failed, err])
else
return Promise.resolve(Task.Status.Retry)
else
return Promise.resolve([Task.Status.Failed, err])
########################################################################
######################## METHODS TO OVERRIDE ###########################
########################################################################

View file

@ -0,0 +1,81 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class UpdateModelTask extends Task {
constructor({clientId, newData = {}, modelName, endpoint, accountId} = {}) {
super()
this.clientId = clientId
this.newData = newData
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
}
shouldDequeueOtherTask(other) {
return (other instanceof UpdateModelTask &&
this.clientId === other.clientId &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.newData, other.newData))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["clientId", "accountId", "endpoint"])
const klass = this.getModelConstructor()
if (!_.isFunction(klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
return DatabaseStore.findBy(klass, {clientId: this.clientId}).then((model) => {
if (!model) {
throw new Error(`Couldn't find the model with clientId ${this.clientId}`)
}
this.serverId = model.serverId
this.oldModel = model.clone()
const updatedModel = _.extend(model, this.newData)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(updatedModel)
});
});
}
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to update remotely")
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
method: "PUT",
accountId: this.accountId,
body: this.newData,
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const undoTask = new UpdateModelTask({
clientId: this.clientId,
newData: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -103,8 +103,9 @@ class NylasExports
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
@require "CreateMetadataTask", 'flux/tasks/create-metadata-task'
@require "DestroyMetadataTask", 'flux/tasks/destroy-metadata-task'
@require "CreateModelTask", 'flux/tasks/create-model-task'
@require "UpdateModelTask", 'flux/tasks/update-model-task'
@require "DestroyModelTask", 'flux/tasks/destroy-model-task'
@require "ReprocessMailRulesTask", 'flux/tasks/reprocess-mail-rules-task'
# Stores

View file

@ -246,9 +246,8 @@ class NylasEnvConstructor extends Model
@emitter.emit 'did-throw-error', {message, url, line, column, originalError}
# Since Bluebird is the promise library, we can properly report
# unhandled errors from business logic inside promises.
Promise.longStackTraces() unless @inSpecMode()
if @inSpecMode() or @inDevMode()
Promise.longStackTraces()
Promise.onPossiblyUnhandledRejection (error) =>
error.stack = convertStackTrace(error.stack, sourceMapCache)
@ -259,7 +258,7 @@ class NylasEnvConstructor extends Model
return
if @inSpecMode()
console.error(error.stack)
jasmine.getEnv().currentSpec.fail(error)
else if @inDevMode()
console.error(error.message, error.stack, error)
@openDevTools()