From 22360b4dc96ca7ebfcc2441855456b241bf450ac Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Thu, 16 May 2024 15:54:05 -0500 Subject: [PATCH] fix: `Parse.Installation` not working when installation is deleted on server (#2126) --- integration/test/ParseUserTest.js | 32 ++++++ src/ParseInstallation.ts | 49 ++++++++- src/__tests__/ParseInstallation-test.js | 134 ++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) diff --git a/integration/test/ParseUserTest.js b/integration/test/ParseUserTest.js index 75a216ca0..3060986e3 100644 --- a/integration/test/ParseUserTest.js +++ b/integration/test/ParseUserTest.js @@ -179,6 +179,38 @@ describe('Parse User', () => { }); }); + it('can save new installation when deleted', async () => { + const currentInstallationId = await Parse.CoreManager.getInstallationController().currentInstallationId(); + const installation = await Parse.Installation.currentInstallation(); + expect(installation.installationId).toBe(currentInstallationId); + expect(installation.deviceType).toBe(Parse.Installation.DEVICE_TYPES.WEB); + await installation.save(); + expect(installation.id).toBeDefined(); + const objectId = installation.id; + await installation.destroy({ useMasterKey: true }); + await installation.save(); + expect(installation.id).toBeDefined(); + expect(installation.id).not.toBe(objectId); + const currentInstallation = await Parse.Installation.currentInstallation(); + expect(currentInstallation.id).toBe(installation.id); + }); + + it('can fetch installation when deleted', async () => { + const currentInstallationId = await Parse.CoreManager.getInstallationController().currentInstallationId(); + const installation = await Parse.Installation.currentInstallation(); + expect(installation.installationId).toBe(currentInstallationId); + expect(installation.deviceType).toBe(Parse.Installation.DEVICE_TYPES.WEB); + await installation.save(); + expect(installation.id).toBeDefined(); + const objectId = installation.id; + await installation.destroy({ useMasterKey: true }); + await installation.fetch(); + expect(installation.id).toBeDefined(); + expect(installation.id).not.toBe(objectId); + const currentInstallation = await Parse.Installation.currentInstallation(); + expect(currentInstallation.id).toBe(installation.id); + }); + it('can login with userId', async () => { Parse.User.enableUnsafeCurrentUser(); diff --git a/src/ParseInstallation.ts b/src/ParseInstallation.ts index d0e0195e8..8e9bce4da 100644 --- a/src/ParseInstallation.ts +++ b/src/ParseInstallation.ts @@ -1,4 +1,5 @@ import CoreManager from './CoreManager'; +import ParseError from './ParseError'; import ParseObject from './ParseObject'; import type { AttributeMap } from './ObjectStateMutations'; @@ -197,17 +198,61 @@ class ParseInstallation extends ParseObject { } /** - * Wrap the default save behavior with functionality to save to local storage. + * Wrap the default fetch behavior with functionality to update local storage. + * If the installation is deleted on the server, retry the fetch as a save operation. + * + * @param {...any} args + * @returns {Promise} + */ + async fetch(...args: Array): Promise { + try { + await super.fetch.apply(this, args); + } catch (e) { + if (e.code !== ParseError.OBJECT_NOT_FOUND) { + throw e; + } + // The installation was deleted from the server. + // We always want fetch to succeed. + delete this.id; + this._getId(); // Generate localId + this._markAllFieldsDirty(); + await super.save.apply(this, args); + } + await CoreManager.getInstallationController().updateInstallationOnDisk(this); + return this; + } + + /** + * Wrap the default save behavior with functionality to update the local storage. + * If the installation is deleted on the server, retry saving a new installation. * * @param {...any} args * @returns {Promise} */ async save(...args: Array): Promise { - await super.save.apply(this, args); + try { + await super.save.apply(this, args); + } catch (e) { + if (e.code !== ParseError.OBJECT_NOT_FOUND) { + throw e; + } + // The installation was deleted from the server. + // We always want save to succeed. + delete this.id; + this._getId(); // Generate localId + this._markAllFieldsDirty(); + await super.save.apply(this, args); + } await CoreManager.getInstallationController().updateInstallationOnDisk(this); return this; } + _markAllFieldsDirty() { + for (const [key, value] of Object.entries(this.attributes)) { + this.set(key, value); + } + } + /** * Get the current Parse.Installation from disk. If doesn't exists, create an new installation. * diff --git a/src/__tests__/ParseInstallation-test.js b/src/__tests__/ParseInstallation-test.js index 3bdc11fce..7870fce0b 100644 --- a/src/__tests__/ParseInstallation-test.js +++ b/src/__tests__/ParseInstallation-test.js @@ -12,6 +12,7 @@ jest.dontMock('../TaskQueue'); jest.dontMock('../SingleInstanceStateController'); jest.dontMock('../UniqueInstanceStateController'); +const ParseError = require('../ParseError').default; const LocalDatastore = require('../LocalDatastore'); const ParseInstallation = require('../ParseInstallation'); const CoreManager = require('../CoreManager'); @@ -84,6 +85,67 @@ describe('ParseInstallation', () => { expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(1); }); + it('can save if object not found', async () => { + const InstallationController = { + async updateInstallationOnDisk() {}, + async currentInstallationId() {}, + async currentInstallation() {}, + }; + let once = true; // save will be called twice first time will reject + CoreManager.setInstallationController(InstallationController); + CoreManager.setRESTController({ + request() { + if (!once) { + return Promise.resolve({}, 200); + } + once = false; + const parseError = new ParseError( + ParseError.OBJECT_NOT_FOUND, + 'Object not found.' + ); + return Promise.reject(parseError); + }, + ajax() {}, + }); + CoreManager.setLocalDatastore(LocalDatastore); + jest.spyOn(InstallationController, 'updateInstallationOnDisk').mockImplementationOnce(() => {}); + const installation = new ParseInstallation(); + installation.set('deviceToken', '1234'); + jest.spyOn(installation, '_markAllFieldsDirty'); + await installation.save(); + expect(installation._markAllFieldsDirty).toHaveBeenCalledTimes(1); + expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(1); + }); + + it('can save and handle errors', async () => { + const InstallationController = { + async updateInstallationOnDisk() {}, + async currentInstallationId() {}, + async currentInstallation() {}, + }; + CoreManager.setInstallationController(InstallationController); + CoreManager.setRESTController({ + request() { + const parseError = new ParseError( + ParseError.INTERNAL_SERVER_ERROR, + 'Cannot save installation on client.' + ); + return Promise.reject(parseError); + }, + ajax() {}, + }); + CoreManager.setLocalDatastore(LocalDatastore); + jest.spyOn(InstallationController, 'updateInstallationOnDisk').mockImplementationOnce(() => {}); + const installation = new ParseInstallation(); + installation.set('deviceToken', '1234'); + try { + await installation.save(); + } catch (e) { + expect(e.message).toEqual('Cannot save installation on client.'); + } + expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(0); + }); + it('can get current installation', async () => { const InstallationController = { async updateInstallationOnDisk() {}, @@ -100,4 +162,76 @@ describe('ParseInstallation', () => { expect(installation.deviceType).toEqual('web'); expect(installation.installationId).toEqual('1234'); }); + + it('can fetch and save to disk', async () => { + const InstallationController = { + async updateInstallationOnDisk() {}, + async currentInstallationId() {}, + async currentInstallation() {}, + }; + CoreManager.setInstallationController(InstallationController); + CoreManager.setRESTController({ + request() { + return Promise.resolve({}, 200); + }, + ajax() {}, + }); + CoreManager.setLocalDatastore(LocalDatastore); + jest.spyOn(InstallationController, 'updateInstallationOnDisk').mockImplementationOnce(() => {}); + const installation = new ParseInstallation(); + installation.id = 'abc'; + await installation.fetch(); + expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(1); + }); + + it('can fetch if object not found', async () => { + const InstallationController = { + async updateInstallationOnDisk() {}, + async currentInstallationId() {}, + async currentInstallation() {}, + }; + let once = true; + CoreManager.setInstallationController(InstallationController); + CoreManager.setRESTController({ + request() { + if (!once) { + // save() results + return Promise.resolve({}, 200); + } + once = false; + // fetch() results + const parseError = new ParseError( + ParseError.OBJECT_NOT_FOUND, + 'Object not found.' + ); + return Promise.reject(parseError); + }, + ajax() {}, + }); + CoreManager.setLocalDatastore(LocalDatastore); + jest.spyOn(InstallationController, 'updateInstallationOnDisk').mockImplementationOnce(() => {}); + const installation = new ParseInstallation(); + installation.id = '1234'; + jest.spyOn(installation, '_markAllFieldsDirty'); + await installation.fetch(); + expect(installation._markAllFieldsDirty).toHaveBeenCalledTimes(1); + expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(1); + }); + + it('can fetch and handle errors', async () => { + const InstallationController = { + async updateInstallationOnDisk() {}, + async currentInstallationId() {}, + async currentInstallation() {}, + }; + CoreManager.setInstallationController(InstallationController); + jest.spyOn(InstallationController, 'updateInstallationOnDisk').mockImplementationOnce(() => {}); + const installation = new ParseInstallation(); + try { + await installation.fetch(); + } catch (e) { + expect(e.message).toEqual('Object does not have an ID'); + } + expect(InstallationController.updateInstallationOnDisk).toHaveBeenCalledTimes(0); + }); });