mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-14 21:57:55 +08:00
16cab4272b
refactor(send): split delivery from sent folder stuffing Summary: See explanation in https://phab.nylas.com/D3577 Depends on D3577 Test Plan: manual Reviewers: jackie, juan, halla Reviewed By: juan, halla Differential Revision: https://phab.nylas.com/D3578
553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
import {
|
|
Thread,
|
|
Actions,
|
|
Contact,
|
|
Message,
|
|
Account,
|
|
DraftStore,
|
|
DraftHelpers,
|
|
DatabaseStore,
|
|
SoundRegistry,
|
|
DestroyDraftTask,
|
|
ComposerExtension,
|
|
ExtensionRegistry,
|
|
FocusedContentStore,
|
|
DatabaseTransaction,
|
|
} from 'nylas-exports';
|
|
|
|
import {remote} from 'electron';
|
|
import DraftFactory from '../../src/flux/stores/draft-factory';
|
|
|
|
class TestExtension extends ComposerExtension {
|
|
static prepareNewDraft({draft}) {
|
|
draft.body = `Edited by TestExtension! ${draft.body}`;
|
|
}
|
|
}
|
|
|
|
xdescribe('DraftStore', function draftStore() {
|
|
beforeEach(() => {
|
|
this.fakeThread = new Thread({id: 'fake-thread', clientId: 'fake-thread'});
|
|
this.fakeMessage = new Message({id: 'fake-message', clientId: 'fake-message'});
|
|
|
|
spyOn(NylasEnv, 'newWindow').andCallFake(() => {});
|
|
spyOn(DatabaseTransaction.prototype, "persistModel").andReturn(Promise.resolve());
|
|
spyOn(DatabaseStore, 'run').andCallFake((query) => {
|
|
if (query._klass === Thread) { return Promise.resolve(this.fakeThread); }
|
|
if (query._klass === Message) { return Promise.resolve(this.fakeMessage); }
|
|
if (query._klass === Contact) { return Promise.resolve(null); }
|
|
return Promise.reject(new Error(`Not Stubbed for class ${query._klass.name}`));
|
|
});
|
|
|
|
for (const draftClientId of Object.keys(DraftStore._draftSessions)) {
|
|
const sess = DraftStore._draftSessions[draftClientId];
|
|
if (sess.teardown) {
|
|
DraftStore._doneWithSession(sess);
|
|
}
|
|
}
|
|
DraftStore._draftSessions = {};
|
|
});
|
|
|
|
describe("creating and opening drafts", () => {
|
|
beforeEach(() => {
|
|
const draft = new Message({id: "A", subject: "B", clientId: "A", body: "123"});
|
|
this.newDraft = draft;
|
|
spyOn(DraftFactory, "createDraftForReply").andReturn(Promise.resolve(draft));
|
|
spyOn(DraftFactory, "createOrUpdateDraftForReply").andReturn(Promise.resolve(draft));
|
|
spyOn(DraftFactory, "createDraftForForward").andReturn(Promise.resolve(draft));
|
|
spyOn(DraftFactory, "createDraft").andReturn(Promise.resolve(draft));
|
|
});
|
|
|
|
it("should always attempt to focus the new draft", () => {
|
|
spyOn(Actions, 'focusDraft')
|
|
DraftStore._onComposeReply({
|
|
threadId: this.fakeThread.id,
|
|
messageId: this.fakeMessage.id,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
advanceClock();
|
|
advanceClock();
|
|
expect(Actions.focusDraft).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("context", () => {
|
|
it("can accept IDs for thread and message arguments", () => {
|
|
DraftStore._onComposeReply({
|
|
threadId: this.fakeThread.id,
|
|
messageId: this.fakeMessage.id,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
advanceClock();
|
|
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
|
|
thread: this.fakeThread,
|
|
message: this.fakeMessage,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
});
|
|
|
|
it("can accept models for thread and message arguments", () => {
|
|
DraftStore._onComposeReply({
|
|
thread: this.fakeThread,
|
|
message: this.fakeMessage,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
advanceClock();
|
|
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
|
|
thread: this.fakeThread,
|
|
message: this.fakeMessage,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
});
|
|
|
|
it("can accept only a thread / threadId, and use the last message on the thread", () => {
|
|
DraftStore._onComposeReply({
|
|
thread: this.fakeThread,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
advanceClock();
|
|
expect(DraftFactory.createOrUpdateDraftForReply).toHaveBeenCalledWith({
|
|
thread: this.fakeThread,
|
|
message: this.fakeMessage,
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("popout behavior", () => {
|
|
it("can popout a reply", () => {
|
|
runs(() => {
|
|
DraftStore._onComposeReply({
|
|
threadId: this.fakeThread.id,
|
|
messageId: this.fakeMessage.id,
|
|
type: 'reply',
|
|
popout: true}
|
|
);
|
|
});
|
|
waitsFor(() => {
|
|
return DatabaseTransaction.prototype.persistModel.callCount > 0;
|
|
});
|
|
runs(() => {
|
|
expect(NylasEnv.newWindow).toHaveBeenCalledWith({
|
|
title: 'Message',
|
|
hidden: true,
|
|
windowKey: `composer-A`,
|
|
windowType: "composer-preload",
|
|
windowProps: { draftClientId: "A", draftJSON: this.newDraft.toJSON() },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("can popout a forward", () => {
|
|
runs(() => {
|
|
DraftStore._onComposeForward({
|
|
threadId: this.fakeThread.id,
|
|
messageId: this.fakeMessage.id,
|
|
popout: true,
|
|
});
|
|
});
|
|
waitsFor(() => {
|
|
return DatabaseTransaction.prototype.persistModel.callCount > 0;
|
|
});
|
|
runs(() => {
|
|
expect(NylasEnv.newWindow).toHaveBeenCalledWith({
|
|
title: 'Message',
|
|
hidden: true,
|
|
windowKey: `composer-A`,
|
|
windowType: "composer-preload",
|
|
windowProps: { draftClientId: "A", draftJSON: this.newDraft.toJSON() },
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("onDestroyDraft", () => {
|
|
beforeEach(() => {
|
|
this.draftSessionTeardown = jasmine.createSpy('draft teardown');
|
|
this.session =
|
|
{draft() {
|
|
return {pristine: false};
|
|
},
|
|
changes:
|
|
{commit() { return Promise.resolve(); },
|
|
teardown() {},
|
|
},
|
|
teardown: this.draftSessionTeardown,
|
|
};
|
|
DraftStore._draftSessions = {abc: this.session};
|
|
spyOn(Actions, 'queueTask');
|
|
});
|
|
|
|
it("should teardown the draft session, ensuring no more saves are made", () => {
|
|
DraftStore._onDestroyDraft('abc');
|
|
expect(this.draftSessionTeardown).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should not throw if the draft session is not in the window", () => {
|
|
expect(() => DraftStore._onDestroyDraft('other')).not.toThrow();
|
|
});
|
|
|
|
it("should queue a destroy draft task", () => {
|
|
DraftStore._onDestroyDraft('abc');
|
|
expect(Actions.queueTask).toHaveBeenCalled();
|
|
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true);
|
|
});
|
|
|
|
it("should clean up the draft session", () => {
|
|
spyOn(DraftStore, '_doneWithSession');
|
|
DraftStore._onDestroyDraft('abc');
|
|
expect(DraftStore._doneWithSession).toHaveBeenCalledWith(this.session);
|
|
});
|
|
|
|
it("should close the window if it's a popout", () => {
|
|
spyOn(NylasEnv, "close");
|
|
spyOn(NylasEnv, "isComposerWindow").andReturn(true);
|
|
DraftStore._onDestroyDraft('abc');
|
|
expect(NylasEnv.close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should NOT close the window if isn't a popout", () => {
|
|
spyOn(NylasEnv, "close");
|
|
spyOn(NylasEnv, "isComposerWindow").andReturn(false);
|
|
DraftStore._onDestroyDraft('abc');
|
|
expect(NylasEnv.close).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("before unloading", () => {
|
|
it("should destroy pristine drafts", () => {
|
|
DraftStore._draftSessions = {
|
|
abc: {
|
|
changes: {},
|
|
draft() {
|
|
return {pristine: true};
|
|
},
|
|
},
|
|
};
|
|
|
|
spyOn(Actions, 'queueTask');
|
|
DraftStore._onBeforeUnload();
|
|
expect(Actions.queueTask).toHaveBeenCalled();
|
|
expect(Actions.queueTask.mostRecentCall.args[0] instanceof DestroyDraftTask).toBe(true);
|
|
});
|
|
|
|
describe("when drafts return unresolved commit promises", () => {
|
|
beforeEach(() => {
|
|
this.resolve = null;
|
|
DraftStore._draftSessions = {
|
|
abc: {
|
|
changes: {
|
|
commit: () => new Promise((resolve) => { this.resolve = resolve }),
|
|
},
|
|
draft() {
|
|
return {pristine: false};
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it("should return false and call window.close itself", () => {
|
|
const callback = jasmine.createSpy('callback');
|
|
expect(DraftStore._onBeforeUnload(callback)).toBe(false);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
this.resolve();
|
|
advanceClock(1000);
|
|
advanceClock(1000);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when drafts return immediately fulfilled commit promises", () => {
|
|
beforeEach(() => {
|
|
DraftStore._draftSessions = {
|
|
abc: {
|
|
changes:
|
|
{commit: () => Promise.resolve()},
|
|
draft() {
|
|
return {pristine: false};
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it("should still wait one tick before firing NylasEnv.close again", () => {
|
|
const callback = jasmine.createSpy('callback');
|
|
expect(DraftStore._onBeforeUnload(callback)).toBe(false);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
advanceClock();
|
|
advanceClock(1000);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when there are no drafts", () => {
|
|
beforeEach(() => {
|
|
DraftStore._draftSessions = {};
|
|
});
|
|
|
|
it("should return true and allow the window to close", () => {
|
|
expect(DraftStore._onBeforeUnload()).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sending a draft", () => {
|
|
beforeEach(() => {
|
|
this.draft = new Message({
|
|
clientId: "local-123",
|
|
threadId: "thread-123",
|
|
replyToMessageId: "message-123",
|
|
uploads: ['stub'],
|
|
});
|
|
DraftStore._draftSessions = {};
|
|
DraftStore._draftsSending = {};
|
|
this.forceCommit = false;
|
|
const session = {
|
|
prepare() {
|
|
return Promise.resolve(session);
|
|
},
|
|
teardown() {},
|
|
draft: () => this.draft,
|
|
changes: {
|
|
commit: ({force} = {}) => {
|
|
this.forceCommit = force;
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
};
|
|
|
|
DraftStore._draftSessions[this.draft.clientId] = session;
|
|
spyOn(DraftStore, "_doneWithSession").andCallThrough();
|
|
spyOn(DraftHelpers, "prepareDraftForSyncback").andReturn(Promise.resolve());
|
|
spyOn(DraftStore, "trigger");
|
|
spyOn(SoundRegistry, "playSound");
|
|
spyOn(Actions, "queueTask");
|
|
});
|
|
|
|
it("plays a sound immediately when sending draft", () => {
|
|
spyOn(NylasEnv.config, "get").andReturn(true);
|
|
DraftStore._onSendDraft(this.draft.clientId);
|
|
advanceClock();
|
|
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds");
|
|
expect(SoundRegistry.playSound).toHaveBeenCalledWith("hit-send");
|
|
});
|
|
|
|
it("doesn't plays a sound if the setting is off", () => {
|
|
spyOn(NylasEnv.config, "get").andReturn(false);
|
|
DraftStore._onSendDraft(this.draft.clientId);
|
|
advanceClock();
|
|
expect(NylasEnv.config.get).toHaveBeenCalledWith("core.sending.sounds");
|
|
expect(SoundRegistry.playSound).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sets the sending state when sending", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(true);
|
|
DraftStore._onSendDraft(this.draft.clientId);
|
|
advanceClock();
|
|
expect(DraftStore.isSendingDraft(this.draft.clientId)).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);
|
|
runs(() => {
|
|
DraftStore._onSendDraft(this.draft.clientId);
|
|
expect(DraftStore.trigger).not.toHaveBeenCalled();
|
|
});
|
|
waitsFor(() => {
|
|
return Actions.queueTask.calls.length > 0;
|
|
});
|
|
runs(() => {
|
|
// Normally, the session.changes.commit will persist to the
|
|
// Database. Since that's stubbed out, we need to manually invoke
|
|
// to database update event to get the trigger (which we want to
|
|
// test) to fire
|
|
DraftStore._onDataChanged({
|
|
objectClass: "Message",
|
|
objects: [{draft: true}],
|
|
});
|
|
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(true);
|
|
expect(DraftStore.trigger).toHaveBeenCalled();
|
|
expect(DraftStore.trigger.calls.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("returns false if the draft hasn't been seen", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(true);
|
|
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
|
|
});
|
|
|
|
it("closes the window if it's a popout", () => {
|
|
spyOn(NylasEnv, "getWindowType").andReturn("composer");
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(false);
|
|
spyOn(NylasEnv, "close");
|
|
runs(() => {
|
|
return DraftStore._onSendDraft(this.draft.clientId);
|
|
});
|
|
waitsFor("N1 to close", () => NylasEnv.close.calls.length > 0);
|
|
});
|
|
|
|
it("doesn't close the window if it's inline", () => {
|
|
spyOn(NylasEnv, "getWindowType").andReturn("other");
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(false);
|
|
spyOn(NylasEnv, "close");
|
|
spyOn(NylasEnv, "isComposerWindow").andCallThrough();
|
|
runs(() => {
|
|
DraftStore._onSendDraft(this.draft.clientId);
|
|
});
|
|
waitsFor(() => NylasEnv.isComposerWindow.calls.length > 0);
|
|
runs(() => {
|
|
expect(NylasEnv.close).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("resets the sending state if there's an error", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(false);
|
|
DraftStore._draftsSending[this.draft.clientId] = true;
|
|
Actions.draftDeliveryFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
|
|
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
|
|
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
|
|
});
|
|
|
|
it("displays a popup in the main window if there's an error", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(true);
|
|
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
|
|
spyOn(remote.dialog, "showMessageBox");
|
|
spyOn(Actions, "composePopoutDraft");
|
|
DraftStore._draftsSending[this.draft.clientId] = true;
|
|
Actions.draftDeliveryFailed({threadId: 't1', errorMessage: "boohoo", draftClientId: this.draft.clientId});
|
|
advanceClock(400);
|
|
expect(DraftStore.isSendingDraft(this.draft.clientId)).toBe(false);
|
|
expect(DraftStore.trigger).toHaveBeenCalledWith(this.draft.clientId);
|
|
expect(remote.dialog.showMessageBox).toHaveBeenCalled();
|
|
const dialogArgs = remote.dialog.showMessageBox.mostRecentCall.args[1];
|
|
expect(dialogArgs.detail).toEqual("boohoo");
|
|
expect(Actions.composePopoutDraft).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-opens the draft if you're not looking at the thread", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(true);
|
|
spyOn(FocusedContentStore, "focused").andReturn({id: "t1"});
|
|
spyOn(Actions, "composePopoutDraft");
|
|
DraftStore._draftsSending[this.draft.clientId] = true;
|
|
Actions.draftDeliveryFailed({threadId: 't2', errorMessage: "boohoo", draftClientId: this.draft.clientId});
|
|
advanceClock(400);
|
|
expect(Actions.composePopoutDraft).toHaveBeenCalled();
|
|
const call = Actions.composePopoutDraft.calls[0];
|
|
expect(call.args[0]).toBe(this.draft.clientId);
|
|
expect(call.args[1]).toEqual({errorMessage: "boohoo"});
|
|
});
|
|
|
|
it("re-opens the draft if there is no thread id", () => {
|
|
spyOn(NylasEnv, "isMainWindow").andReturn(true);
|
|
spyOn(Actions, "composePopoutDraft");
|
|
DraftStore._draftsSending[this.draft.clientId] = true;
|
|
spyOn(FocusedContentStore, "focused").andReturn(null);
|
|
Actions.draftDeliveryFailed({errorMessage: "boohoo", draftClientId: this.draft.clientId});
|
|
advanceClock(400);
|
|
expect(Actions.composePopoutDraft).toHaveBeenCalled();
|
|
const call = Actions.composePopoutDraft.calls[0];
|
|
expect(call.args[0]).toBe(this.draft.clientId);
|
|
expect(call.args[1]).toEqual({errorMessage: "boohoo"});
|
|
});
|
|
});
|
|
|
|
describe("session teardown", () => {
|
|
beforeEach(() => {
|
|
spyOn(NylasEnv, 'isMainWindow').andReturn(true);
|
|
this.draftTeardown = jasmine.createSpy('draft teardown');
|
|
this.session = {
|
|
draftClientId: "abc",
|
|
draft() {
|
|
return {pristine: false};
|
|
},
|
|
changes: {
|
|
commit() { return Promise.resolve(); },
|
|
reset() {},
|
|
},
|
|
teardown: this.draftTeardown,
|
|
};
|
|
DraftStore._draftSessions = {abc: this.session};
|
|
DraftStore._doneWithSession(this.session);
|
|
});
|
|
|
|
it("removes from the list of draftSessions", () => {
|
|
expect(DraftStore._draftSessions.abc).toBeUndefined();
|
|
});
|
|
|
|
it("Calls teardown on the session", () => {
|
|
expect(this.draftTeardown).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("mailto handling", () => {
|
|
beforeEach(() => {
|
|
spyOn(NylasEnv, 'isMainWindow').andReturn(true);
|
|
});
|
|
|
|
describe("extensions", () => {
|
|
beforeEach(() => {
|
|
ExtensionRegistry.Composer.register(TestExtension);
|
|
});
|
|
afterEach(() => {
|
|
ExtensionRegistry.Composer.unregister(TestExtension);
|
|
});
|
|
|
|
it("should give extensions a chance to customize the draft via ext.prepareNewDraft", () => {
|
|
waitsForPromise(() => {
|
|
return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => {
|
|
const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0];
|
|
expect(received.body.indexOf("Edited by TestExtension!")).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should call through to DraftFactory and popout a new draft", () => {
|
|
const draft = new Message({clientId: "A", body: '123'});
|
|
spyOn(DraftFactory, 'createDraftForMailto').andReturn(Promise.resolve(draft));
|
|
spyOn(DraftStore, '_onPopoutDraftClientId');
|
|
waitsForPromise(() => {
|
|
return DraftStore._onHandleMailtoLink({}, 'mailto:bengotow@gmail.com').then(() => {
|
|
const received = DatabaseTransaction.prototype.persistModel.mostRecentCall.args[0];
|
|
expect(received).toEqual(draft);
|
|
expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("mailfiles handling", () => {
|
|
it("should popout a new draft", () => {
|
|
const defaultMe = new Contact();
|
|
spyOn(DraftStore, '_onPopoutDraftClientId');
|
|
spyOn(Account.prototype, 'defaultMe').andReturn(defaultMe);
|
|
spyOn(Actions, 'addAttachment').andCallFake(({onUploadCreated}) => onUploadCreated());
|
|
DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']);
|
|
waitsFor(() => DatabaseTransaction.prototype.persistModel.callCount > 0);
|
|
runs(() => {
|
|
const {body, subject, from} = DatabaseTransaction.prototype.persistModel.calls[0].args[0];
|
|
expect({body, subject, from}).toEqual({body: '', subject: '', from: [defaultMe]});
|
|
expect(DraftStore._onPopoutDraftClientId).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("should call addAttachment for each provided file path", () => {
|
|
spyOn(Actions, 'addAttachment');
|
|
DraftStore._onHandleMailFiles({}, ['/Users/ben/file1.png', '/Users/ben/file2.png']);
|
|
waitsFor(() => Actions.addAttachment.callCount === 2);
|
|
runs(() => {
|
|
expect(Actions.addAttachment.calls[0].args[0].filePath).toEqual('/Users/ben/file1.png');
|
|
expect(Actions.addAttachment.calls[1].args[0].filePath).toEqual('/Users/ben/file2.png');
|
|
});
|
|
});
|
|
});
|
|
});
|