diff --git a/packages/snjs/jest.config.ts b/packages/snjs/jest.config.ts index f2ceaa0ba..b5f9495d8 100644 --- a/packages/snjs/jest.config.ts +++ b/packages/snjs/jest.config.ts @@ -16,6 +16,7 @@ export default { collectCoverageFrom: [ //'lib/**/{!(index),}.ts', 'lib/services/component_manager.ts', + 'lib/application.ts', ], // The directory where Jest should output its coverage files diff --git a/packages/snjs/lib/application.ts b/packages/snjs/lib/application.ts index 27359ae75..59b497748 100644 --- a/packages/snjs/lib/application.ts +++ b/packages/snjs/lib/application.ts @@ -1310,11 +1310,30 @@ public getSessions(): Promise<(HttpResponse & { data: RemoteSession[] }) | HttpR ); } - public async signOut(): Promise { - await this.credentialService.signOut(); - await this.notifyEvent(ApplicationEvent.SignedOut); - await this.prepareForDeinit(); - this.deinit(DeinitSource.SignOut); + public async signOut(force = false): Promise { + const performSignOut = async () => { + await this.credentialService.signOut(); + await this.notifyEvent(ApplicationEvent.SignedOut); + await this.prepareForDeinit(); + this.deinit(DeinitSource.SignOut); + }; + + if (force) { + await performSignOut(); + return; + } + + const dirtyItems = this.itemManager.getDirtyItems(); + if (dirtyItems.length > 0) { + const didConfirm = await this.alertService.confirm( + `There are ${dirtyItems.length} items with unsynced changes. If you sign out, these changes will be lost forever. Are you sure you want to sign out?` + ); + if (didConfirm) { + await performSignOut(); + } + } else { + await performSignOut(); + } } public async validateAccountPassword(password: string): Promise { @@ -1636,7 +1655,7 @@ public getSessions(): Promise<(HttpResponse & { data: RemoteSession[] }) | HttpR case SessionEvent.Revoked: { /** Keep a reference to the soon-to-be-cleared alertService */ const alertService = this.alertService; - await this.signOut(); + await this.signOut(true); void alertService.alert(SessionStrings.CurrentSessionRevoked); break; } diff --git a/packages/snjs/package.json b/packages/snjs/package.json index d0e1663b4..fad911027 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/snjs", - "version": "2.7.11", + "version": "2.7.12", "engines": { "node": ">=14.0.0 <16.0.0" }, diff --git a/packages/snjs/test/lib/application.test.js b/packages/snjs/test/lib/application.test.js new file mode 100644 index 000000000..69561df48 --- /dev/null +++ b/packages/snjs/test/lib/application.test.js @@ -0,0 +1,86 @@ +import { + Platform, + Environment, + DeinitSource, +} from '@Lib/index'; +import { createApplication } from '../setup/snjs/appFactory'; +import { + createNoteItem, +} from '../helpers'; + +describe('Application', () => { + /** The global Standard Notes application. */ + let testSNApp; + + beforeEach(async () => { + testSNApp = await createApplication('test-application', Environment.Web, Platform.LinuxWeb); + }); + + describe('signOut()', () => { + let testNote1; + let confirmAlert; + let deinit; + + const signOutConfirmMessage = (numberOfItems) => { + return `There are ${numberOfItems} items with unsynced changes. ` + + 'If you sign out, these changes will be lost forever. Are you sure you want to sign out?' + }; + + beforeEach(async () => { + testNote1 = await createNoteItem(testSNApp, { + title: 'Note 1', + text: 'This is a test note!' + }); + confirmAlert = jest.spyOn( + testSNApp.alertService, + 'confirm' + ); + deinit = jest.spyOn( + testSNApp, + 'deinit' + ); + }); + + it('shows confirmation dialog when there are unsaved changes', async () => { + await testSNApp.itemManager.setItemDirty(testNote1.uuid); + await testSNApp.signOut(); + + const expectedConfirmMessage = signOutConfirmMessage(1); + + expect(confirmAlert).toBeCalledTimes(1); + expect(confirmAlert).toBeCalledWith(expectedConfirmMessage); + expect(deinit).toBeCalledTimes(1); + expect(deinit).toBeCalledWith(DeinitSource.SignOut); + }); + + it('does not show confirmation dialog when there are no unsaved changes', async () => { + await testSNApp.signOut(); + + expect(confirmAlert).toBeCalledTimes(0); + expect(deinit).toBeCalledTimes(1); + expect(deinit).toBeCalledWith(DeinitSource.SignOut); + }); + + it('does not show confirmation dialog when there are unsaved changes and the "force" option is set to true', async () => { + await testSNApp.itemManager.setItemDirty(testNote1.uuid); + await testSNApp.signOut(true); + + expect(confirmAlert).toBeCalledTimes(0); + expect(deinit).toBeCalledTimes(1); + expect(deinit).toBeCalledWith(DeinitSource.SignOut); + }); + + it('cancels sign out if confirmation dialog is rejected', async () => { + confirmAlert.mockImplementation((message) => false); + + await testSNApp.itemManager.setItemDirty(testNote1.uuid); + await testSNApp.signOut(); + + const expectedConfirmMessage = signOutConfirmMessage(1); + + expect(confirmAlert).toBeCalledTimes(1); + expect(confirmAlert).toBeCalledWith(expectedConfirmMessage); + expect(deinit).toBeCalledTimes(0); + }); + }); +}); diff --git a/packages/snjs/test/setup/snjs/deviceInterface.ts b/packages/snjs/test/setup/snjs/deviceInterface.ts index 25f585264..5830f5d25 100644 --- a/packages/snjs/test/setup/snjs/deviceInterface.ts +++ b/packages/snjs/test/setup/snjs/deviceInterface.ts @@ -128,7 +128,9 @@ export default class DeviceInterface extends SNDeviceInterface { async getRawKeychainValue() { const keychain = this.localStorage.getItem(KEYCHAIN_STORAGE_KEY); - return JSON.parse(keychain); + if (keychain) { + return JSON.parse(keychain); + } } async clearRawKeychainValue() {