diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js new file mode 100644 index 000000000..ce718b99f --- /dev/null +++ b/integration/test/ParseEventuallyQueueTest.js @@ -0,0 +1,238 @@ +'use strict'; + +const assert = require('assert'); +const Parse = require('../../node'); +const sleep = require('./sleep'); + +describe('Parse EventuallyQueue', () => { + beforeEach(async () => { + await Parse.EventuallyQueue.clear(); + }); + + it('can queue save object', async () => { + const object = new TestObject({ test: 'test' }); + await object.save(); + object.set('foo', 'bar'); + await Parse.EventuallyQueue.save(object); + await Parse.EventuallyQueue.sendQueue(); + + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + assert.strictEqual(result.get('foo'), 'bar'); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + }); + + it('can queue destroy object', async () => { + const object = new TestObject({ test: 'test' }); + await object.save(); + await Parse.EventuallyQueue.destroy(object); + await Parse.EventuallyQueue.sendQueue(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const results = await query.find(); + assert.strictEqual(results.length, 0); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + }); + + it('can queue multiple object', async () => { + const obj1 = new TestObject({ foo: 'bar' }); + const obj2 = new TestObject({ foo: 'baz' }); + const obj3 = new TestObject({ foo: 'bag' }); + await Parse.EventuallyQueue.save(obj1); + await Parse.EventuallyQueue.save(obj2); + await Parse.EventuallyQueue.save(obj3); + + let length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 3); + + await Parse.EventuallyQueue.sendQueue(); + + const query = new Parse.Query(TestObject); + query.ascending('createdAt'); + const results = await query.find(); + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].get('foo'), 'bar'); + assert.strictEqual(results[1].get('foo'), 'baz'); + assert.strictEqual(results[2].get('foo'), 'bag'); + + length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + + // TODO: Properly handle SingleInstance + await Parse.EventuallyQueue.destroy(results[0]); + await Parse.EventuallyQueue.destroy(results[1]); + await Parse.EventuallyQueue.destroy(results[2]); + + length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 3); + + await Parse.EventuallyQueue.sendQueue(); + const objects = await query.find(); + assert.strictEqual(objects.length, 0); + }); + + it('can queue destroy for object that does not exist', async () => { + const object = new TestObject({ test: 'test' }); + await object.save(); + await object.destroy(); + await Parse.EventuallyQueue.destroy(object); + await Parse.EventuallyQueue.sendQueue(); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + }); + + it('can queue destroy then save', async () => { + const object = new TestObject({ hash: 'test' }); + await Parse.EventuallyQueue.destroy(object); + await Parse.EventuallyQueue.save(object); + await Parse.EventuallyQueue.sendQueue(); + + const query = new Parse.Query(TestObject); + query.equalTo('hash', 'test'); + const results = await query.find(); + assert.strictEqual(results.length, 1); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + }); + + it('can queue unsaved object with hash', async () => { + const hash = 'secret'; + const object = new TestObject({ test: 'test' }); + object.set('hash', hash); + await Parse.EventuallyQueue.save(object); + await Parse.EventuallyQueue.sendQueue(); + + const query = new Parse.Query(TestObject); + query.equalTo('hash', hash); + const results = await query.find(); + assert.strictEqual(results.length, 1); + }); + + it('can queue saved object and unsaved with hash', async () => { + const hash = 'ransom+salt'; + const object = new TestObject({ test: 'test' }); + object.set('hash', hash); + await Parse.EventuallyQueue.save(object); + await Parse.EventuallyQueue.sendQueue(); + + let query = new Parse.Query(TestObject); + query.equalTo('hash', hash); + const results = await query.find(); + assert.strictEqual(results.length, 1); + + const unsaved = new TestObject({ hash, foo: 'bar' }); + await Parse.EventuallyQueue.save(unsaved); + await Parse.EventuallyQueue.sendQueue(); + + query = new Parse.Query(TestObject); + query.equalTo('hash', hash); + const hashes = await query.find(); + assert.strictEqual(hashes.length, 1); + assert.strictEqual(hashes[0].get('foo'), 'bar'); + }); + + it('can queue same object but override undefined fields', async () => { + const object = new Parse.Object('TestObject'); + object.set('foo', 'bar'); + object.set('test', '1234'); + await Parse.EventuallyQueue.save(object); + + object.set('foo', undefined); + await Parse.EventuallyQueue.save(object); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 1); + + const queue = await Parse.EventuallyQueue.getQueue(); + assert.strictEqual(queue[0].object.foo, 'bar'); + assert.strictEqual(queue[0].object.test, '1234'); + }); + + it('can poll server', async () => { + const object = new TestObject({ test: 'test' }); + await object.save(); + object.set('foo', 'bar'); + await Parse.EventuallyQueue.save(object); + Parse.EventuallyQueue.poll(); + assert.ok(Parse.EventuallyQueue.isPolling()); + + await sleep(4000); + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + assert.strictEqual(result.get('foo'), 'bar'); + + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); + }); + + it('can clear queue', async () => { + const object = new TestObject({ test: 'test' }); + await object.save(); + await Parse.EventuallyQueue.save(object); + const q = await Parse.EventuallyQueue.getQueue(); + assert.strictEqual(q.length, 1); + + await Parse.EventuallyQueue.clear(); + const length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + }); + + it('can saveEventually', async done => { + const parseServer = await reconfigureServer(); + const object = new TestObject({ hash: 'saveSecret' }); + await parseServer.handleShutdown(); + parseServer.server.close(async () => { + await object.saveEventually(); + let length = await Parse.EventuallyQueue.length(); + assert(Parse.EventuallyQueue.isPolling()); + assert.strictEqual(length, 1); + + await reconfigureServer({}); + await sleep(3000); // Wait for polling + + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); + length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + + const query = new Parse.Query(TestObject); + query.equalTo('hash', 'saveSecret'); + const results = await query.find(); + assert.strictEqual(results.length, 1); + done(); + }); + }); + + it('can destroyEventually', async done => { + const parseServer = await reconfigureServer(); + const object = new TestObject({ hash: 'deleteSecret' }); + await object.save(); + await parseServer.handleShutdown(); + parseServer.server.close(async () => { + await object.destroyEventually(); + let length = await Parse.EventuallyQueue.length(); + assert(Parse.EventuallyQueue.isPolling()); + assert.strictEqual(length, 1); + + await reconfigureServer({}); + await sleep(3000); // Wait for polling + + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); + length = await Parse.EventuallyQueue.length(); + assert.strictEqual(length, 0); + + const query = new Parse.Query(TestObject); + query.equalTo('hash', 'deleteSecret'); + const results = await query.find(); + assert.strictEqual(results.length, 0); + done(); + }); + }); +}); diff --git a/integration/test/helper.js b/integration/test/helper.js index f6739c718..87786d8cd 100644 --- a/integration/test/helper.js +++ b/integration/test/helper.js @@ -1,4 +1,4 @@ -jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; const ParseServer = require('parse-server').default; const CustomAuth = require('./CustomAuth'); diff --git a/jasmine.json b/jasmine.json index a7eb69d0e..1d66e1118 100644 --- a/jasmine.json +++ b/jasmine.json @@ -7,5 +7,5 @@ "*Test.js" ], "random": false, - "timeout": 5000 + "timeout": 10000 } diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js new file mode 100644 index 000000000..b84fe9c22 --- /dev/null +++ b/src/EventuallyQueue.js @@ -0,0 +1,373 @@ +/** + * https://github.com/francimedia/parse-js-local-storage + * + * @flow + */ + +import CoreManager from './CoreManager'; +import ParseObject from './ParseObject'; +import ParseQuery from './ParseQuery'; +import Storage from './Storage'; + +import type { SaveOptions } from './ParseObject'; +import type { RequestOptions } from './RESTController'; + +type QueueObject = { + queueId: string, + action: string, + object: ParseObject, + serverOptions: SaveOptions | RequestOptions, + id: string, + className: string, + hash: string, + createdAt: Date, +}; + +type Queue = Array; + +const QUEUE_KEY = 'Parse/Eventually/Queue'; +let queueCache = []; +let dirtyCache = true; +let polling = undefined; + +/** + * Provides utility functions to queue objects that will be + * saved to the server at a later date. + * + * @class Parse.EventuallyQueue + * @static + */ +const EventuallyQueue = { + /** + * Add object to queue with save operation. + * + * @function save + * @name Parse.EventuallyQueue.save + * @param {ParseObject} object Parse.Object to be saved eventually + * @param {object} [serverOptions] See {@link https://parseplatform.org/Parse-SDK-JS/api/master/Parse.Object.html#save Parse.Object.save} options. + * @returns {Promise} A promise that is fulfilled if object is added to queue. + * @static + * @see Parse.Object#saveEventually + */ + save(object: ParseObject, serverOptions: SaveOptions = {}): Promise { + return this.enqueue('save', object, serverOptions); + }, + + /** + * Add object to queue with save operation. + * + * @function destroy + * @name Parse.EventuallyQueue.destroy + * @param {ParseObject} object Parse.Object to be destroyed eventually + * @param {object} [serverOptions] See {@link https://parseplatform.org/Parse-SDK-JS/api/master/Parse.Object.html#destroy Parse.Object.destroy} options + * @returns {Promise} A promise that is fulfilled if object is added to queue. + * @static + * @see Parse.Object#destroyEventually + */ + destroy(object: ParseObject, serverOptions: RequestOptions = {}): Promise { + return this.enqueue('destroy', object, serverOptions); + }, + + /** + * Generate unique identifier to avoid duplicates and maintain previous state. + * + * @param {string} action save / destroy + * @param {object} object Parse.Object to be queued + * @returns {string} + * @static + * @ignore + */ + generateQueueId(action: string, object: ParseObject): string { + object._getId(); + const { className, id, _localId } = object; + const uniqueId = object.get('hash') || _localId; + return [action, className, id, uniqueId].join('_'); + }, + + /** + * Build queue object and add to queue. + * + * @param {string} action save / destroy + * @param {object} object Parse.Object to be queued + * @param {object} [serverOptions] + * @returns {Promise} A promise that is fulfilled if object is added to queue. + * @static + * @ignore + */ + async enqueue( + action: string, + object: ParseObject, + serverOptions: SaveOptions | RequestOptions + ): Promise { + const queueData = await this.getQueue(); + const queueId = this.generateQueueId(action, object); + + let index = this.queueItemExists(queueData, queueId); + if (index > -1) { + // Add cached values to new object if they don't exist + for (const prop in queueData[index].object) { + if (typeof object.get(prop) === 'undefined') { + object.set(prop, queueData[index].object[prop]); + } + } + } else { + index = queueData.length; + } + queueData[index] = { + queueId, + action, + object: object.toJSON(), + serverOptions, + id: object.id, + className: object.className, + hash: object.get('hash'), + createdAt: new Date(), + }; + return this.setQueue(queueData); + }, + + store(data) { + return Storage.setItemAsync(QUEUE_KEY, JSON.stringify(data)); + }, + + load() { + return Storage.getItemAsync(QUEUE_KEY); + }, + + /** + * Sets the in-memory queue from local storage and returns. + * + * @function getQueue + * @name Parse.EventuallyQueue.getQueue + * @returns {Promise} + * @static + */ + async getQueue(): Promise { + if (dirtyCache) { + queueCache = JSON.parse((await this.load()) || '[]'); + dirtyCache = false; + } + return queueCache; + }, + + /** + * Saves the queue to local storage + * + * @param {Queue} queue Queue containing Parse.Object data. + * @returns {Promise} A promise that is fulfilled when queue is stored. + * @static + * @ignore + */ + setQueue(queue: Queue): Promise { + queueCache = queue; + return this.store(queueCache); + }, + + /** + * Removes Parse.Object data from queue. + * + * @param {string} queueId Unique identifier for Parse.Object data. + * @returns {Promise} A promise that is fulfilled when queue is stored. + * @static + * @ignore + */ + async remove(queueId: string): Promise { + const queueData = await this.getQueue(); + const index = this.queueItemExists(queueData, queueId); + if (index > -1) { + queueData.splice(index, 1); + await this.setQueue(queueData); + } + }, + + /** + * Removes all objects from queue. + * + * @function clear + * @name Parse.EventuallyQueue.clear + * @returns {Promise} A promise that is fulfilled when queue is cleared. + * @static + */ + clear(): Promise { + queueCache = []; + return this.store([]); + }, + + /** + * Return the index of a queueId in the queue. Returns -1 if not found. + * + * @param {Queue} queue Queue containing Parse.Object data. + * @param {string} queueId Unique identifier for Parse.Object data. + * @returns {number} + * @static + * @ignore + */ + queueItemExists(queue: Queue, queueId: string): number { + return queue.findIndex(data => data.queueId === queueId); + }, + + /** + * Return the number of objects in the queue. + * + * @function length + * @name Parse.EventuallyQueue.length + * @returns {number} + * @static + */ + async length(): number { + const queueData = await this.getQueue(); + return queueData.length; + }, + + /** + * Sends the queue to the server. + * + * @function sendQueue + * @name Parse.EventuallyQueue.sendQueue + * @returns {Promise} Returns true if queue was sent successfully. + * @static + */ + async sendQueue(): Promise { + const queue = await this.getQueue(); + const queueData = [...queue]; + + if (queueData.length === 0) { + return false; + } + for (let i = 0; i < queueData.length; i += 1) { + const queueObject = queueData[i]; + const { id, hash, className } = queueObject; + const ObjectType = ParseObject.extend(className); + if (id) { + await this.process.byId(ObjectType, queueObject); + } else if (hash) { + await this.process.byHash(ObjectType, queueObject); + } else { + await this.process.create(ObjectType, queueObject); + } + } + return true; + }, + + /** + * Build queue object and add to queue. + * + * @param {ParseObject} object Parse.Object to be processed + * @param {QueueObject} queueObject Parse.Object data from the queue + * @returns {Promise} A promise that is fulfilled when operation is performed. + * @static + * @ignore + */ + async sendQueueCallback(object: ParseObject, queueObject: QueueObject): Promise { + if (!object) { + return this.remove(queueObject.queueId); + } + switch (queueObject.action) { + case 'save': + // Queued update was overwritten by other request. Do not save + if ( + typeof object.updatedAt !== 'undefined' && + object.updatedAt > new Date(queueObject.object.createdAt) + ) { + return this.remove(queueObject.queueId); + } + try { + await object.save(queueObject.object, queueObject.serverOptions); + await this.remove(queueObject.queueId); + } catch (e) { + if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await this.remove(queueObject.queueId); + } + } + break; + case 'destroy': + try { + await object.destroy(queueObject.serverOptions); + await this.remove(queueObject.queueId); + } catch (e) { + if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await this.remove(queueObject.queueId); + } + } + break; + } + }, + + /** + * Start polling server for network connection. + * Will send queue if connection is established. + * + * @function poll + * @name Parse.EventuallyQueue.poll + * @param [ms] Milliseconds to ping the server. Default 2000ms + * @static + */ + poll(ms: number = 2000) { + if (polling) { + return; + } + polling = setInterval(() => { + const RESTController = CoreManager.getRESTController(); + RESTController.ajax('GET', CoreManager.get('SERVER_URL')).catch(error => { + if (error !== 'Unable to connect to the Parse API') { + this.stopPoll(); + return this.sendQueue(); + } + }); + }, ms); + }, + + /** + * Turns off polling. + * + * @function stopPoll + * @name Parse.EventuallyQueue.stopPoll + * @static + */ + stopPoll() { + clearInterval(polling); + polling = undefined; + }, + + /** + * Return true if pinging the server. + * + * @function isPolling + * @name Parse.EventuallyQueue.isPolling + * @returns {boolean} + * @static + */ + isPolling(): boolean { + return !!polling; + }, + + _setPolling(flag: boolean) { + polling = flag; + }, + + process: { + create(ObjectType, queueObject) { + const object = new ObjectType(); + return EventuallyQueue.sendQueueCallback(object, queueObject); + }, + async byId(ObjectType, queueObject) { + const { sessionToken } = queueObject.serverOptions; + const query = new ParseQuery(ObjectType); + query.equalTo('objectId', queueObject.id); + const results = await query.find({ sessionToken }); + return EventuallyQueue.sendQueueCallback(results[0], queueObject); + }, + async byHash(ObjectType, queueObject) { + const { sessionToken } = queueObject.serverOptions; + const query = new ParseQuery(ObjectType); + query.equalTo('hash', queueObject.hash); + const results = await query.find({ sessionToken }); + if (results.length > 0) { + return EventuallyQueue.sendQueueCallback(results[0], queueObject); + } + return EventuallyQueue.process.create(ObjectType, queueObject); + }, + }, +}; + +module.exports = EventuallyQueue; diff --git a/src/Parse.js b/src/Parse.js index aeef1f2fc..e132b2778 100644 --- a/src/Parse.js +++ b/src/Parse.js @@ -11,6 +11,7 @@ import decode from './decode'; import encode from './encode'; import CoreManager from './CoreManager'; import CryptoController from './CryptoController'; +import EventuallyQueue from './EventuallyQueue'; import InstallationController from './InstallationController'; import * as ParseOp from './ParseOp'; import RESTController from './RESTController'; @@ -46,6 +47,7 @@ const Parse = { /* eslint-enable no-console */ } Parse._initialize(applicationId, javaScriptKey); + EventuallyQueue.poll(); }, _initialize(applicationId: string, javaScriptKey: string, masterKey: string) { @@ -197,6 +199,7 @@ Parse.CLP = require('./ParseCLP').default; Parse.CoreManager = require('./CoreManager'); Parse.Config = require('./ParseConfig').default; Parse.Error = require('./ParseError').default; +Parse.EventuallyQueue = EventuallyQueue; Parse.FacebookUtils = require('./FacebookUtils').default; Parse.File = require('./ParseFile').default; Parse.GeoPoint = require('./ParseGeoPoint').default; diff --git a/src/ParseObject.js b/src/ParseObject.js index 1efb3101c..312428ddf 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -14,6 +14,7 @@ import canBeSerialized from './canBeSerialized'; import decode from './decode'; import encode from './encode'; import escape from './escape'; +import EventuallyQueue from './EventuallyQueue'; import ParseACL from './ParseACL'; import parseDate from './parseDate'; import ParseError from './ParseError'; @@ -56,7 +57,7 @@ type SaveParams = { body: AttributeMap, }; -type SaveOptions = FullOptions & { +export type SaveOptions = FullOptions & { cascadeSave?: boolean, context?: AttributeMap, }; @@ -1200,6 +1201,42 @@ class ParseObject { return this.fetch(options); } + /** + * Saves this object to the server at some unspecified time in the future, + * even if Parse is currently inaccessible. + * + * Use this when you may not have a solid network connection, and don't need to know when the save completes. + * If there is some problem with the object such that it can't be saved, it will be silently discarded. + * + * Objects saved with this method will be stored locally in an on-disk cache until they can be delivered to Parse. + * They will be sent immediately if possible. Otherwise, they will be sent the next time a network connection is + * available. Objects saved this way will persist even after the app is closed, in which case they will be sent the + * next time the app is opened. + * + * @param {object} [options] + * Used to pass option parameters to method if arg1 and arg2 were both passed as strings. + * Valid options are: + *
    + *
  • sessionToken: A valid session token, used for making a request on + * behalf of a specific user. + *
  • cascadeSave: If `false`, nested objects will not be saved (default is `true`). + *
  • context: A dictionary that is accessible in Cloud Code `beforeSave` and `afterSave` triggers. + *
+ * @returns {Promise} A promise that is fulfilled when the save + * completes. + */ + async saveEventually(options: SaveOptions): Promise { + try { + await this.save(null, options); + } catch (e) { + if (e.message === 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await EventuallyQueue.save(this, options); + EventuallyQueue.poll(); + } + } + return this; + } + /** * Set a hash of model attributes, and save the model to the server. * updatedAt will be updated when the request returns. @@ -1305,6 +1342,40 @@ class ParseObject { }); } + /** + * Deletes this object from the server at some unspecified time in the future, + * even if Parse is currently inaccessible. + * + * Use this when you may not have a solid network connection, + * and don't need to know when the delete completes. If there is some problem with the object + * such that it can't be deleted, the request will be silently discarded. + * + * Delete instructions made with this method will be stored locally in an on-disk cache until they can be transmitted + * to Parse. They will be sent immediately if possible. Otherwise, they will be sent the next time a network connection + * is available. Delete requests will persist even after the app is closed, in which case they will be sent the + * next time the app is opened. + * + * @param {object} [options] + * Valid options are:
    + *
  • sessionToken: A valid session token, used for making a request on + * behalf of a specific user. + *
  • context: A dictionary that is accessible in Cloud Code `beforeDelete` and `afterDelete` triggers. + *
+ * @returns {Promise} A promise that is fulfilled when the destroy + * completes. + */ + async destroyEventually(options: RequestOptions): Promise { + try { + await this.destroy(options); + } catch (e) { + if (e.message === 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await EventuallyQueue.destroy(this, options); + EventuallyQueue.poll(); + } + } + return this; + } + /** * Destroy this model on the server if it was already persisted. * diff --git a/src/__tests__/EventuallyQueue-test.js b/src/__tests__/EventuallyQueue-test.js new file mode 100644 index 000000000..c5460d264 --- /dev/null +++ b/src/__tests__/EventuallyQueue-test.js @@ -0,0 +1,427 @@ +jest.autoMockOff(); +jest.useFakeTimers(); + +let objectCount = 0; + +class MockObject { + constructor(className) { + this.className = className; + this.attributes = {}; + + this.id = String(objectCount++); + this._localId = `local${objectCount}`; + } + destroy() {} + save() {} + _getId() { + return this.id || this._localId; + } + set(key, value) { + this.attributes[key] = value; + } + get(key) { + return this.attributes[key]; + } + toJSON() { + return this.attributes; + } + static extend(className) { + class MockSubclass { + constructor() { + this.className = className; + } + } + return MockSubclass; + } +} +jest.setMock('../ParseObject', MockObject); + +const mockQueryFind = jest.fn(); +jest.mock('../ParseQuery', () => { + return jest.fn().mockImplementation(function () { + this.equalTo = jest.fn(); + this.find = mockQueryFind; + }); +}); +const mockRNStorageInterface = require('./test_helpers/mockRNStorage'); +const CoreManager = require('../CoreManager'); +const EventuallyQueue = require('../EventuallyQueue'); +const ParseError = require('../ParseError').default; +const ParseObject = require('../ParseObject'); +const RESTController = require('../RESTController'); +const Storage = require('../Storage'); +const mockXHR = require('./test_helpers/mockXHR'); + +function flushPromises() { + return new Promise(resolve => setImmediate(resolve)); +} + +describe('EventuallyQueue', () => { + beforeEach(async () => { + jest.clearAllMocks(); + CoreManager.setAsyncStorage(mockRNStorageInterface); + CoreManager.setStorageController(require('../StorageController.react-native')); + CoreManager.setRESTController(RESTController); + EventuallyQueue.stopPoll(); + await EventuallyQueue.clear(); + }); + + it('init empty', async () => { + const queue = await EventuallyQueue.getQueue(); + const length = await EventuallyQueue.length(); + expect(queue.length).toBe(length); + }); + + it('can get invalid storage', async () => { + jest.spyOn(Storage, 'getItemAsync').mockImplementationOnce(() => { + return Promise.resolve(undefined); + }); + const queue = await EventuallyQueue.getQueue(); + expect(queue.length).toBe(0); + expect(queue).toEqual([]); + }); + + it('can queue object', async () => { + const object = new ParseObject('TestObject'); + await EventuallyQueue.save(object, { sessionToken: 'token' }); + let length = await EventuallyQueue.length(); + expect(length).toBe(1); + + await EventuallyQueue.destroy(object, { sessionToken: 'secret' }); + length = await EventuallyQueue.length(); + expect(length).toBe(2); + + const [savedObject, deleteObject] = await EventuallyQueue.getQueue(); + expect(savedObject.id).toEqual(object.id); + expect(savedObject.action).toEqual('save'); + expect(savedObject.object).toEqual(object.toJSON()); + expect(savedObject.className).toEqual(object.className); + expect(savedObject.serverOptions).toEqual({ sessionToken: 'token' }); + expect(savedObject.createdAt).toBeDefined(); + expect(savedObject.queueId).toEqual(EventuallyQueue.generateQueueId('save', object)); + + expect(deleteObject.id).toEqual(object.id); + expect(deleteObject.action).toEqual('destroy'); + expect(deleteObject.object).toEqual(object.toJSON()); + expect(deleteObject.className).toEqual(object.className); + expect(deleteObject.serverOptions).toEqual({ sessionToken: 'secret' }); + expect(deleteObject.createdAt).toBeDefined(); + expect(deleteObject.queueId).toEqual(EventuallyQueue.generateQueueId('destroy', object)); + }); + + it('can queue same save object', async () => { + const object = new ParseObject('TestObject'); + await EventuallyQueue.save(object); + await EventuallyQueue.save(object); + const length = await EventuallyQueue.length(); + expect(length).toBe(1); + }); + + it('can queue same destroy object', async () => { + const object = new ParseObject('TestObject'); + await EventuallyQueue.destroy(object); + await EventuallyQueue.destroy(object); + const length = await EventuallyQueue.length(); + expect(length).toBe(1); + }); + + it('can queue same object but override undefined fields', async () => { + const object = new ParseObject('TestObject'); + object.set('foo', 'bar'); + object.set('test', '1234'); + await EventuallyQueue.save(object); + + object.set('foo', undefined); + await EventuallyQueue.save(object); + + const length = await EventuallyQueue.length(); + expect(length).toBe(1); + + const queue = await EventuallyQueue.getQueue(); + expect(queue[0].object.test).toBe('1234'); + }); + + it('can remove object from queue', async () => { + const object = new ParseObject('TestObject'); + await EventuallyQueue.save(object); + let length = await EventuallyQueue.length(); + expect(length).toBe(1); + + const queue = await EventuallyQueue.getQueue(); + const { queueId } = queue[0]; + await EventuallyQueue.remove(queueId); + + length = await EventuallyQueue.length(); + expect(length).toBe(0); + await EventuallyQueue.remove(queueId); + + length = await EventuallyQueue.length(); + expect(length).toBe(0); + }); + + it('can send empty queue to server', async () => { + const length = await EventuallyQueue.length(); + expect(length).toBe(0); + const didSend = await EventuallyQueue.sendQueue(); + expect(didSend).toBe(false); + }); + + it('can send queue by object id', async () => { + jest.spyOn(EventuallyQueue.process, 'byId').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + await EventuallyQueue.save(object); + + const didSend = await EventuallyQueue.sendQueue(); + expect(didSend).toBe(true); + expect(EventuallyQueue.process.byId).toHaveBeenCalledTimes(1); + }); + + it('can send queue by object hash', async () => { + jest.spyOn(EventuallyQueue.process, 'byHash').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + delete object.id; + object.set('hash', 'secret'); + await EventuallyQueue.save(object); + + const didSend = await EventuallyQueue.sendQueue(); + expect(didSend).toBe(true); + expect(EventuallyQueue.process.byHash).toHaveBeenCalledTimes(1); + }); + + it('can send queue by object create', async () => { + jest.spyOn(EventuallyQueue.process, 'create').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + delete object.id; + await EventuallyQueue.save(object); + + const didSend = await EventuallyQueue.sendQueue(); + expect(didSend).toBe(true); + expect(EventuallyQueue.process.create).toHaveBeenCalledTimes(1); + }); + + it('can handle send queue destroy callback', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'destroy').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const queueObject = { + action: 'destroy', + queueId: 'queue1', + serverOptions: { sessionToken: 'token' }, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.destroy).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(object.destroy).toHaveBeenCalledWith({ sessionToken: 'token' }); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue1'); + }); + + it('should remove if queue destroy callback fails', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'destroy').mockImplementationOnce(() => { + return Promise.reject('Unable to delete object.'); + }); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const queueObject = { + action: 'destroy', + queueId: 'queue1', + serverOptions: {}, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.destroy).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(object.destroy).toHaveBeenCalledWith({}); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue1'); + }); + + it('should not remove if queue destroy callback network fails', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'destroy').mockImplementationOnce(() => { + throw new ParseError( + ParseError.CONNECTION_FAILED, + 'XMLHttpRequest failed: "Unable to connect to the Parse API"' + ); + }); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const queueObject = { + action: 'destroy', + queueId: 'queue1', + serverOptions: {}, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.destroy).toHaveBeenCalledTimes(1); + expect(object.destroy).toHaveBeenCalledWith({}); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(0); + }); + + it('can handle send queue save callback with no object', async () => { + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const object = null; + + const queueObject = { queueId: 'queue0' }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue0'); + }); + + it('can handle send queue save callback', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'save').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + await EventuallyQueue.save(object); + + const queueObject = { + action: 'save', + queueId: 'queue2', + object: { foo: 'bar' }, + serverOptions: { sessionToken: 'token' }, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.save).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, { sessionToken: 'token' }); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue2'); + }); + + it('can handle send queue save callback', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'save').mockImplementationOnce(() => { + return Promise.reject('Unable to save.'); + }); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + await EventuallyQueue.save(object); + + const queueObject = { + action: 'save', + queueId: 'queue2', + object: { foo: 'bar' }, + serverOptions: { sessionToken: 'token' }, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.save).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, { sessionToken: 'token' }); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue2'); + }); + + it('should not remove if queue save callback network fails', async () => { + const object = new ParseObject('TestObject'); + jest.spyOn(object, 'save').mockImplementationOnce(() => { + throw new ParseError( + ParseError.CONNECTION_FAILED, + 'XMLHttpRequest failed: "Unable to connect to the Parse API"' + ); + }); + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const queueObject = { + action: 'save', + queueId: 'queue2', + object: { foo: 'bar' }, + serverOptions: {}, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(object.save).toHaveBeenCalledTimes(1); + expect(object.save).toHaveBeenCalledWith({ foo: 'bar' }, {}); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(0); + }); + + it('can handle send queue save callback if queue is old', async () => { + jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const object = new ParseObject('TestObject'); + object.updatedAt = tomorrow; + await EventuallyQueue.save(object); + + const queueObject = { + action: 'save', + queueId: 'queue3', + object: { createdAt: new Date() }, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue3'); + }); + + it('can process new object', async () => { + jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); + await EventuallyQueue.process.create(MockObject, {}); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + }); + + it('can process object by id', async () => { + jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + const queueObject = { + id: 'object1', + serverOptions: { sessionToken: 'idToken' }, + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); + await EventuallyQueue.process.byId(MockObject, queueObject); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'idToken' }); + }); + + it('can process object by hash', async () => { + jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + const queueObject = { + hash: 'secret', + serverOptions: { sessionToken: 'hashToken' }, + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); + await EventuallyQueue.process.byHash(MockObject, queueObject); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); + }); + + it('can process new object if hash not exists', async () => { + jest.spyOn(EventuallyQueue.process, 'create').mockImplementationOnce(() => {}); + const queueObject = { + hash: 'secret', + serverOptions: { sessionToken: 'hashToken' }, + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([])); + await EventuallyQueue.process.byHash(MockObject, queueObject); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); + }); + + it('cannot poll if already polling', () => { + EventuallyQueue._setPolling(true); + EventuallyQueue.poll(); + expect(EventuallyQueue.isPolling()).toBe(true); + }); + + it('can poll server', async () => { + jest.spyOn(EventuallyQueue, 'sendQueue').mockImplementationOnce(() => {}); + RESTController._setXHR(mockXHR([{ status: 107, response: { error: 'ok' } }])); + EventuallyQueue.poll(); + expect(EventuallyQueue.isPolling()).toBe(true); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(EventuallyQueue.isPolling()).toBe(false); + expect(EventuallyQueue.sendQueue).toHaveBeenCalledTimes(1); + }); + + it('can continue polling with connection error', async () => { + const retry = CoreManager.get('REQUEST_ATTEMPT_LIMIT'); + CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1); + RESTController._setXHR( + mockXHR([{ status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }]) + ); + EventuallyQueue.poll(); + expect(EventuallyQueue.isPolling()).toBe(true); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(EventuallyQueue.isPolling()).toBe(true); + CoreManager.set('REQUEST_ATTEMPT_LIMIT', retry); + }); +}); diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js index d78d53acd..c38c95672 100644 --- a/src/__tests__/Parse-test.js +++ b/src/__tests__/Parse-test.js @@ -14,8 +14,10 @@ jest.dontMock('../encode'); jest.dontMock('../Parse'); jest.dontMock('../LocalDatastore'); jest.dontMock('crypto-js/aes'); +jest.setMock('../EventuallyQueue', { poll: jest.fn() }); const CoreManager = require('../CoreManager'); +const EventuallyQueue = require('../EventuallyQueue'); const Parse = require('../Parse'); describe('Parse module', () => { @@ -36,6 +38,12 @@ describe('Parse module', () => { expect(CoreManager.get('USE_MASTER_KEY')).toBe(true); }); + it('should not start eventually queue poll in node build', () => { + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + Parse.initialize('A', 'B'); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0); + }); + it('exposes certain keys as properties', () => { Parse.applicationId = '123'; expect(CoreManager.get('APPLICATION_ID')).toBe('123'); diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 892e6fb11..3a2ddba85 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -140,6 +140,7 @@ const mockLocalDatastore = { jest.setMock('../LocalDatastore', mockLocalDatastore); const CoreManager = require('../CoreManager'); +const EventuallyQueue = require('../EventuallyQueue'); const ParseACL = require('../ParseACL').default; const ParseError = require('../ParseError').default; const ParseFile = require('../ParseFile').default; @@ -1533,6 +1534,53 @@ describe('ParseObject', () => { }); }); + it('can save the object eventually', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: { + objectId: 'PFEventually', + }, + }, + ]) + ); + const p = new ParseObject('Person'); + p.set('age', 38); + const obj = await p.saveEventually(); + expect(obj).toBe(p); + expect(obj.get('age')).toBe(38); + expect(obj.op('age')).toBe(undefined); + expect(obj.dirty()).toBe(false); + }); + + it('can save the object eventually on network failure', async () => { + const p = new ParseObject('Person'); + jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(p, 'save').mockImplementationOnce(() => { + throw new ParseError( + ParseError.CONNECTION_FAILED, + 'XMLHttpRequest failed: "Unable to connect to the Parse API"' + ); + }); + await p.saveEventually(); + expect(EventuallyQueue.save).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1); + }); + + it('should not save the object eventually on error', async () => { + const p = new ParseObject('Person'); + jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(p, 'save').mockImplementationOnce(() => { + throw new ParseError(ParseError.OTHER_CAUSE, 'Tried to save a batch with a cycle.'); + }); + await p.saveEventually(); + expect(EventuallyQueue.save).toHaveBeenCalledTimes(0); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0); + }); + it('can save the object with key / value', done => { CoreManager.getRESTController()._setXHR( mockXHR([ @@ -2844,6 +2892,33 @@ describe('ObjectController', () => { await result; }); + it('can destroy the object eventually on network failure', async () => { + const p = new ParseObject('Person'); + jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(p, 'destroy').mockImplementationOnce(() => { + throw new ParseError( + ParseError.CONNECTION_FAILED, + 'XMLHttpRequest failed: "Unable to connect to the Parse API"' + ); + }); + await p.destroyEventually(); + expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1); + }); + + it('should not destroy object eventually on error', async () => { + const p = new ParseObject('Person'); + jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(p, 'destroy').mockImplementationOnce(() => { + throw new ParseError(ParseError.OTHER_CAUSE, 'Unable to delete.'); + }); + await p.destroyEventually(); + expect(EventuallyQueue.destroy).toHaveBeenCalledTimes(0); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(0); + }); + it('can save an object', async () => { const objectController = CoreManager.getObjectController(); const xhr = { diff --git a/src/__tests__/browser-test.js b/src/__tests__/browser-test.js index 3b77c4d83..7e1682ce8 100644 --- a/src/__tests__/browser-test.js +++ b/src/__tests__/browser-test.js @@ -8,8 +8,10 @@ jest.dontMock('../Parse'); jest.dontMock('../RESTController'); jest.dontMock('../Storage'); jest.dontMock('crypto-js/aes'); +jest.setMock('../EventuallyQueue', { poll: jest.fn() }); const ParseError = require('../ParseError').default; +const EventuallyQueue = require('../EventuallyQueue'); class XMLHttpRequest {} class XDomainRequest { @@ -50,6 +52,14 @@ describe('Browser', () => { expect(Parse._initialize).toHaveBeenCalledTimes(1); }); + it('should start eventually queue poll on initialize', () => { + const Parse = require('../Parse'); + jest.spyOn(console, 'log').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + Parse.initialize('A', 'B'); + expect(EventuallyQueue.poll).toHaveBeenCalledTimes(1); + }); + it('load StorageController', () => { const StorageController = require('../StorageController.browser'); jest.spyOn(StorageController, 'setItem');