From 340bcf211ee571a153bf4f9813c2ef8045353ea8 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 3 Feb 2021 02:37:59 -0600 Subject: [PATCH 1/5] Add EventuallyQueue API --- integration/test/ParseEventuallyQueueTest.js | 178 ++++++++++ src/EventuallyQueue.js | 181 +++++++++++ src/Parse.js | 1 + src/__tests__/EventuallyQueue-test.js | 323 +++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 integration/test/ParseEventuallyQueueTest.js create mode 100644 src/EventuallyQueue.js create mode 100644 src/__tests__/EventuallyQueue-test.js diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js new file mode 100644 index 000000000..609171cea --- /dev/null +++ b/integration/test/ParseEventuallyQueueTest.js @@ -0,0 +1,178 @@ +'use strict'; + +const assert = require('assert'); +const clear = require('./clear'); +const Parse = require('../../node'); +const sleep = require('./sleep'); + +const TestObject = Parse.Object.extend('TestObject'); + +describe('Parse EventuallyQueue', () => { + beforeEach(done => { + Parse.initialize('integration', null, 'notsosecret'); + Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse'); + Parse.Storage._clear(); + clear().then(() => { + done(); + }); + }); + + 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: can't use obj1, etc because they don't have an id + 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 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.polling); + + 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.polling, undefined); + }); + + 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); + }); +}); diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js new file mode 100644 index 000000000..744f34102 --- /dev/null +++ b/src/EventuallyQueue.js @@ -0,0 +1,181 @@ +/** + * 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'; + +const EventuallyQueue = { + localStorageKey: 'Parse.Eventually.Queue', + polling: undefined, + + save(object) { + return this.enqueue('save', object); + }, + + destroy(object) { + return this.enqueue('delete', object); + }, + + async enqueue(action, object) { + const queueData = await this.getQueue(); + object._getId(); + const { className, id, _localId } = object; + const hash = object.get('hash') || _localId; + const queueId = [action, className, id, hash].join('_'); + + 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.attributes) { + if (typeof object.get(prop) === 'undefined') { + object.set(prop, queueData[index].object.attributes[prop]); + } + } + } else { + index = queueData.length; + } + queueData[index] = { + id, + queueId, + className, + action, + object, + hash: object.get('hash'), + createdAt: new Date(), + }; + return this.setQueue(queueData); + }, + + async getQueue() { + const q = await Storage.getItemAsync(this.localStorageKey); + if (!q) { + return []; + } + return JSON.parse(q); + }, + + setQueue(queueData) { + return Storage.setItemAsync(this.localStorageKey, JSON.stringify(queueData)); + }, + + async remove(queueId) { + const queueData = await this.getQueue(); + const index = this.queueItemExists(queueData, queueId); + if (index > -1) { + queueData.splice(index, 1); + await this.setQueue(queueData); + } + }, + + clear() { + return Storage.setItemAsync(this.localStorageKey, JSON.stringify([])); + }, + + queueItemExists(queueData, queueId) { + return queueData.findIndex(data => data.queueId === queueId); + }, + + async length() { + const queueData = await this.getQueue(); + return queueData.length; + }, + + async sendQueue(sessionToken) { + const queueData = await this.getQueue(); + if (queueData.length === 0) { + return false; + } + for (let i = 0; i < queueData.length; i += 1) { + const ObjectType = ParseObject.extend(queueData[i].className); + if (queueData[i].id) { + await this.reprocess.byId(ObjectType, queueData[i], sessionToken); + } else if (queueData[i].hash) { + await this.reprocess.byHash(ObjectType, queueData[i], sessionToken); + } else { + await this.reprocess.create(ObjectType, queueData[i], sessionToken); + } + } + return true; + }, + + async sendQueueCallback(object, queueObject, sessionToken) { + 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, { sessionToken }); + await this.remove(queueObject.queueId); + } catch (e) { + // Do Nothing + } + break; + case 'delete': + try { + await object.destroy({ sessionToken }); + } catch (e) { + // Do Nothing + } + await this.remove(queueObject.queueId); + break; + } + }, + + poll(sessionToken) { + if (this.polling) { + return; + } + this.polling = setInterval(() => { + let url = CoreManager.get('SERVER_URL'); + url += url[url.length - 1] !== '/' ? '/health' : 'health'; + + const RESTController = CoreManager.getRESTController(); + RESTController.ajax('GET', url) + .then(async () => { + clearInterval(this.polling); + delete this.polling; + await this.sendQueue(sessionToken); + }) + .catch(() => { + // Can't connect to server, continue + }); + }, 2000); + }, + + reprocess: { + create(ObjectType, queueObject, sessionToken) { + const newObject = new ObjectType(); + return EventuallyQueue.sendQueueCallback(newObject, queueObject, sessionToken); + }, + async byId(ObjectType, queueObject, sessionToken) { + const query = new ParseQuery(ObjectType); + query.equalTo('objectId', queueObject.id); + const results = await query.find({ sessionToken }); + return EventuallyQueue.sendQueueCallback(results[0], queueObject, sessionToken); + }, + async byHash(ObjectType, queueObject, sessionToken) { + 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, sessionToken); + } + return EventuallyQueue.reprocess.create(ObjectType, queueObject, sessionToken); + }, + }, +}; + +module.exports = EventuallyQueue; diff --git a/src/Parse.js b/src/Parse.js index aeef1f2fc..f82913b70 100644 --- a/src/Parse.js +++ b/src/Parse.js @@ -197,6 +197,7 @@ Parse.CLP = require('./ParseCLP').default; Parse.CoreManager = require('./CoreManager'); Parse.Config = require('./ParseConfig').default; Parse.Error = require('./ParseError').default; +Parse.EventuallyQueue = require('./EventuallyQueue'); Parse.FacebookUtils = require('./FacebookUtils').default; Parse.File = require('./ParseFile').default; Parse.GeoPoint = require('./ParseGeoPoint').default; diff --git a/src/__tests__/EventuallyQueue-test.js b/src/__tests__/EventuallyQueue-test.js new file mode 100644 index 000000000..2e0510ab0 --- /dev/null +++ b/src/__tests__/EventuallyQueue-test.js @@ -0,0 +1,323 @@ +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]; + } + 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 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.polling = undefined; + 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); + let length = await EventuallyQueue.length(); + expect(length).toBe(1); + + await EventuallyQueue.destroy(object); + length = await EventuallyQueue.length(); + expect(length).toBe(2); + }); + + it('can queue same 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 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.attributes.foo).toBe('bar'); + expect(queue[0].object.attributes.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.reprocess, 'byId').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + await EventuallyQueue.save(object); + + const didSend = await EventuallyQueue.sendQueue(); + expect(didSend).toBe(true); + expect(EventuallyQueue.reprocess.byId).toHaveBeenCalledTimes(1); + }); + + it('can send queue by object hash', async () => { + jest.spyOn(EventuallyQueue.reprocess, '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.reprocess.byHash).toHaveBeenCalledTimes(1); + }); + + it('can send queue by object create', async () => { + jest.spyOn(EventuallyQueue.reprocess, '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.reprocess.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(() => {}); + await EventuallyQueue.save(object); + + const queueObject = { + action: 'delete', + queueId: 'queue1', + }; + await EventuallyQueue.sendQueueCallback(object, queueObject, 'token'); + expect(object.destroy).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(object.destroy).toHaveBeenCalledWith({ sessionToken: 'token' }); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue1'); + }); + + 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, 'token'); + 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' }, + }; + await EventuallyQueue.sendQueueCallback(object, queueObject, 'token'); + 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 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, 'token'); + expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue3'); + }); + + it('can process new object', async () => { + jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); + await EventuallyQueue.reprocess.create(MockObject, {}, 'createToken'); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueueCallback.mock.calls[0][2]).toBe('createToken'); + }); + + it('can process object by id', async () => { + jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); + const object = new ParseObject('TestObject'); + const queueObject = { + id: 'object1', + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); + await EventuallyQueue.reprocess.byId(MockObject, queueObject, 'idToken'); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject, 'idToken'); + 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', + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); + await EventuallyQueue.reprocess.byHash(MockObject, queueObject, 'hashToken'); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith( + object, + queueObject, + 'hashToken' + ); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); + }); + + it('can process new object if hash not exists', async () => { + jest.spyOn(EventuallyQueue.reprocess, 'create').mockImplementationOnce(() => {}); + const queueObject = { + hash: 'secret', + }; + mockQueryFind.mockImplementationOnce(() => Promise.resolve([])); + await EventuallyQueue.reprocess.byHash(MockObject, queueObject, 'hashToken'); + expect(EventuallyQueue.reprocess.create.mock.calls[0][2]).toBe('hashToken'); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); + }); + + it('cannot poll if already polling', () => { + EventuallyQueue.polling = true; + EventuallyQueue.poll(); + expect(EventuallyQueue.polling).toBe(true); + }); + + it('can poll server', async () => { + jest.spyOn(EventuallyQueue, 'sendQueue').mockImplementationOnce(() => {}); + RESTController._setXHR(mockXHR([{ status: 200, response: { status: 'ok' } }])); + EventuallyQueue.poll('pollToken'); + expect(EventuallyQueue.polling).toBeDefined(); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(EventuallyQueue.polling).toBeUndefined(); + expect(EventuallyQueue.sendQueue).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.sendQueue).toHaveBeenCalledWith('pollToken'); + }); + + it('can poll server 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.polling).toBeDefined(); + jest.runOnlyPendingTimers(); + await flushPromises(); + + expect(EventuallyQueue.polling).toBeDefined(); + CoreManager.set('REQUEST_ATTEMPT_LIMIT', retry); + }); +}); From 28999b9cb1178cd6ac9c2cb35fec018423e7d315 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Thu, 4 Feb 2021 16:35:37 -0600 Subject: [PATCH 2/5] Add saveEventually and destroyEventually --- integration/test/ParseEventuallyQueueTest.js | 63 ++++++-- integration/test/helper.js | 2 +- jasmine.json | 2 +- src/EventuallyQueue.js | 91 ++++++----- src/Parse.js | 4 +- src/ParseObject.js | 71 +++++++++ src/__tests__/EventuallyQueue-test.js | 156 +++++++++++++++---- src/__tests__/Parse-test.js | 8 + src/__tests__/ParseObject-test.js | 75 +++++++++ src/__tests__/browser-test.js | 10 ++ 10 files changed, 398 insertions(+), 84 deletions(-) diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js index 609171cea..44552ff87 100644 --- a/integration/test/ParseEventuallyQueueTest.js +++ b/integration/test/ParseEventuallyQueueTest.js @@ -1,22 +1,10 @@ 'use strict'; const assert = require('assert'); -const clear = require('./clear'); const Parse = require('../../node'); const sleep = require('./sleep'); -const TestObject = Parse.Object.extend('TestObject'); - describe('Parse EventuallyQueue', () => { - beforeEach(done => { - Parse.initialize('integration', null, 'notsosecret'); - Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse'); - Parse.Storage._clear(); - clear().then(() => { - done(); - }); - }); - it('can queue save object', async () => { const object = new TestObject({ test: 'test' }); await object.save(); @@ -175,4 +163,55 @@ describe('Parse EventuallyQueue', () => { 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.polling); + assert.strictEqual(length, 1); + + await reconfigureServer({}); + await sleep(3000); // Wait for polling + + assert.strictEqual(Parse.EventuallyQueue.polling, undefined); + 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.polling); + assert.strictEqual(length, 1); + + await reconfigureServer({}); + await sleep(3000); // Wait for polling + + assert.strictEqual(Parse.EventuallyQueue.polling, undefined); + 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 index 744f34102..abaac4e02 100644 --- a/src/EventuallyQueue.js +++ b/src/EventuallyQueue.js @@ -13,20 +13,22 @@ const EventuallyQueue = { localStorageKey: 'Parse.Eventually.Queue', polling: undefined, - save(object) { - return this.enqueue('save', object); + save(object, serverOptions = {}) { + return this.enqueue('save', object, serverOptions); }, - destroy(object) { - return this.enqueue('delete', object); + destroy(object, serverOptions = {}) { + return this.enqueue('destroy', object, serverOptions); }, - - async enqueue(action, object) { - const queueData = await this.getQueue(); + generateQueueId(action, object) { object._getId(); const { className, id, _localId } = object; - const hash = object.get('hash') || _localId; - const queueId = [action, className, id, hash].join('_'); + const uniqueId = object.get('hash') || _localId; + return [action, className, id, uniqueId].join('_'); + }, + async enqueue(action, object, serverOptions) { + const queueData = await this.getQueue(); + const queueId = this.generateQueueId(action, object); let index = this.queueItemExists(queueData, queueId); if (index > -1) { @@ -40,11 +42,12 @@ const EventuallyQueue = { index = queueData.length; } queueData[index] = { - id, queueId, - className, action, object, + serverOptions, + id: object.id, + className: object.className, hash: object.get('hash'), createdAt: new Date(), }; @@ -85,7 +88,7 @@ const EventuallyQueue = { return queueData.length; }, - async sendQueue(sessionToken) { + async sendQueue() { const queueData = await this.getQueue(); if (queueData.length === 0) { return false; @@ -93,17 +96,17 @@ const EventuallyQueue = { for (let i = 0; i < queueData.length; i += 1) { const ObjectType = ParseObject.extend(queueData[i].className); if (queueData[i].id) { - await this.reprocess.byId(ObjectType, queueData[i], sessionToken); + await this.reprocess.byId(ObjectType, queueData[i]); } else if (queueData[i].hash) { - await this.reprocess.byHash(ObjectType, queueData[i], sessionToken); + await this.reprocess.byHash(ObjectType, queueData[i]); } else { - await this.reprocess.create(ObjectType, queueData[i], sessionToken); + await this.reprocess.create(ObjectType, queueData[i]); } } return true; }, - async sendQueueCallback(object, queueObject, sessionToken) { + async sendQueueCallback(object, queueObject) { if (!object) { return this.remove(queueObject.queueId); } @@ -117,63 +120,67 @@ const EventuallyQueue = { return this.remove(queueObject.queueId); } try { - await object.save(queueObject.object, { sessionToken }); + await object.save(queueObject.object, queueObject.serverOptions); await this.remove(queueObject.queueId); } catch (e) { - // Do Nothing + if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await this.remove(queueObject.queueId); + } } break; - case 'delete': + case 'destroy': try { - await object.destroy({ sessionToken }); + await object.destroy(queueObject.serverOptions); + await this.remove(queueObject.queueId); } catch (e) { - // Do Nothing + if (e.message !== 'XMLHttpRequest failed: "Unable to connect to the Parse API"') { + await this.remove(queueObject.queueId); + } } - await this.remove(queueObject.queueId); break; } }, - poll(sessionToken) { + poll() { if (this.polling) { return; } this.polling = setInterval(() => { - let url = CoreManager.get('SERVER_URL'); - url += url[url.length - 1] !== '/' ? '/health' : 'health'; - const RESTController = CoreManager.getRESTController(); - RESTController.ajax('GET', url) - .then(async () => { + RESTController.ajax('GET', CoreManager.get('SERVER_URL')).catch(error => { + if (error !== 'Unable to connect to the Parse API') { clearInterval(this.polling); - delete this.polling; - await this.sendQueue(sessionToken); - }) - .catch(() => { - // Can't connect to server, continue - }); + this.polling = undefined; + return this.sendQueue(); + } + }); }, 2000); }, - + stopPoll() { + clearInterval(this.polling); + this.polling = undefined; + }, reprocess: { - create(ObjectType, queueObject, sessionToken) { + create(ObjectType, queueObject) { const newObject = new ObjectType(); - return EventuallyQueue.sendQueueCallback(newObject, queueObject, sessionToken); + return EventuallyQueue.sendQueueCallback(newObject, queueObject); }, - async byId(ObjectType, queueObject, sessionToken) { + 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, sessionToken); + return EventuallyQueue.sendQueueCallback(results[0], queueObject); }, - async byHash(ObjectType, queueObject, sessionToken) { + 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, sessionToken); + return EventuallyQueue.sendQueueCallback(results[0], queueObject); } - return EventuallyQueue.reprocess.create(ObjectType, queueObject, sessionToken); + return EventuallyQueue.reprocess.create(ObjectType, queueObject); }, }, }; diff --git a/src/Parse.js b/src/Parse.js index f82913b70..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,7 +199,7 @@ Parse.CLP = require('./ParseCLP').default; Parse.CoreManager = require('./CoreManager'); Parse.Config = require('./ParseConfig').default; Parse.Error = require('./ParseError').default; -Parse.EventuallyQueue = require('./EventuallyQueue'); +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..b8948d006 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'; @@ -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: + * + * @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: + * @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 index 2e0510ab0..13ebd6222 100644 --- a/src/__tests__/EventuallyQueue-test.js +++ b/src/__tests__/EventuallyQueue-test.js @@ -43,6 +43,7 @@ jest.mock('../ParseQuery', () => { 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'); @@ -58,7 +59,7 @@ describe('EventuallyQueue', () => { CoreManager.setAsyncStorage(mockRNStorageInterface); CoreManager.setStorageController(require('../StorageController.react-native')); CoreManager.setRESTController(RESTController); - EventuallyQueue.polling = undefined; + EventuallyQueue.stopPoll(); await EventuallyQueue.clear(); }); @@ -79,16 +80,33 @@ describe('EventuallyQueue', () => { it('can queue object', async () => { const object = new ParseObject('TestObject'); - await EventuallyQueue.save(object); + await EventuallyQueue.save(object, { sessionToken: 'token' }); let length = await EventuallyQueue.length(); expect(length).toBe(1); - await EventuallyQueue.destroy(object); + 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); + 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); + 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 object', async () => { + it('can queue same save object', async () => { const object = new ParseObject('TestObject'); await EventuallyQueue.save(object); await EventuallyQueue.save(object); @@ -96,6 +114,14 @@ describe('EventuallyQueue', () => { 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'); @@ -175,25 +201,62 @@ describe('EventuallyQueue', () => { const object = new ParseObject('TestObject'); jest.spyOn(object, 'destroy').mockImplementationOnce(() => {}); jest.spyOn(EventuallyQueue, 'remove').mockImplementationOnce(() => {}); - await EventuallyQueue.save(object); - const queueObject = { - action: 'delete', + action: 'destroy', queueId: 'queue1', + serverOptions: { sessionToken: 'token' }, }; - await EventuallyQueue.sendQueueCallback(object, queueObject, '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, 'token'); + await EventuallyQueue.sendQueueCallback(object, queueObject); expect(EventuallyQueue.remove).toHaveBeenCalledTimes(1); expect(EventuallyQueue.remove).toHaveBeenCalledWith('queue0'); }); @@ -208,14 +271,57 @@ describe('EventuallyQueue', () => { action: 'save', queueId: 'queue2', object: { foo: 'bar' }, + serverOptions: { sessionToken: 'token' }, }; - await EventuallyQueue.sendQueueCallback(object, queueObject, '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(); @@ -231,16 +337,15 @@ describe('EventuallyQueue', () => { queueId: 'queue3', object: { createdAt: new Date() }, }; - await EventuallyQueue.sendQueueCallback(object, queueObject, 'token'); + 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.reprocess.create(MockObject, {}, 'createToken'); + await EventuallyQueue.reprocess.create(MockObject, {}); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); - expect(EventuallyQueue.sendQueueCallback.mock.calls[0][2]).toBe('createToken'); }); it('can process object by id', async () => { @@ -248,11 +353,12 @@ describe('EventuallyQueue', () => { const object = new ParseObject('TestObject'); const queueObject = { id: 'object1', + serverOptions: { sessionToken: 'idToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); - await EventuallyQueue.reprocess.byId(MockObject, queueObject, 'idToken'); + await EventuallyQueue.reprocess.byId(MockObject, queueObject); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); - expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject, 'idToken'); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'idToken' }); }); @@ -262,15 +368,12 @@ describe('EventuallyQueue', () => { const object = new ParseObject('TestObject'); const queueObject = { hash: 'secret', + serverOptions: { sessionToken: 'hashToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); - await EventuallyQueue.reprocess.byHash(MockObject, queueObject, 'hashToken'); + await EventuallyQueue.reprocess.byHash(MockObject, queueObject); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); - expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith( - object, - queueObject, - 'hashToken' - ); + expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); }); @@ -279,10 +382,10 @@ describe('EventuallyQueue', () => { jest.spyOn(EventuallyQueue.reprocess, 'create').mockImplementationOnce(() => {}); const queueObject = { hash: 'secret', + serverOptions: { sessionToken: 'hashToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([])); - await EventuallyQueue.reprocess.byHash(MockObject, queueObject, 'hashToken'); - expect(EventuallyQueue.reprocess.create.mock.calls[0][2]).toBe('hashToken'); + await EventuallyQueue.reprocess.byHash(MockObject, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); }); @@ -295,18 +398,17 @@ describe('EventuallyQueue', () => { it('can poll server', async () => { jest.spyOn(EventuallyQueue, 'sendQueue').mockImplementationOnce(() => {}); - RESTController._setXHR(mockXHR([{ status: 200, response: { status: 'ok' } }])); - EventuallyQueue.poll('pollToken'); + RESTController._setXHR(mockXHR([{ status: 107, response: { error: 'ok' } }])); + EventuallyQueue.poll(); expect(EventuallyQueue.polling).toBeDefined(); jest.runOnlyPendingTimers(); await flushPromises(); expect(EventuallyQueue.polling).toBeUndefined(); expect(EventuallyQueue.sendQueue).toHaveBeenCalledTimes(1); - expect(EventuallyQueue.sendQueue).toHaveBeenCalledWith('pollToken'); }); - it('can poll server with connection error', async () => { + it('can continue polling with connection error', async () => { const retry = CoreManager.get('REQUEST_ATTEMPT_LIMIT'); CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1); RESTController._setXHR( 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'); From 820751bb19eeb5f7454babc4a33d5ed60eab7741 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Fri, 5 Feb 2021 03:36:55 -0600 Subject: [PATCH 3/5] Documentation --- src/EventuallyQueue.js | 186 +++++++++++++++++++++++++++++++++++++---- src/ParseObject.js | 2 +- 2 files changed, 170 insertions(+), 18 deletions(-) diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js index abaac4e02..258cab7ff 100644 --- a/src/EventuallyQueue.js +++ b/src/EventuallyQueue.js @@ -9,24 +9,94 @@ 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; + +/** + * Provides utility functions to queue objects that will be + * saved to the server at a later date. + * + * @class Parse.EventuallyQueue + * @static + */ const EventuallyQueue = { - localStorageKey: 'Parse.Eventually.Queue', + localStorageKey: 'Parse/Eventually/Queue', polling: undefined, - save(object, serverOptions = {}) { + /** + * 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); }, - destroy(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); }, - generateQueueId(action, object) { + + /** + * 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('_'); }, - async enqueue(action, object, serverOptions) { + + /** + * 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); @@ -54,7 +124,15 @@ const EventuallyQueue = { return this.setQueue(queueData); }, - async getQueue() { + /** + * Returns the queue from local storage + * + * @function getQueue + * @name Parse.EventuallyQueue.getQueue + * @returns {Promise} + * @static + */ + async getQueue(): Promise { const q = await Storage.getItemAsync(this.localStorageKey); if (!q) { return []; @@ -62,11 +140,27 @@ const EventuallyQueue = { return JSON.parse(q); }, - setQueue(queueData) { - return Storage.setItemAsync(this.localStorageKey, JSON.stringify(queueData)); + /** + * 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 { + return Storage.setItemAsync(this.localStorageKey, JSON.stringify(queue)); }, - async remove(queueId) { + /** + * 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) { @@ -75,20 +169,53 @@ const EventuallyQueue = { } }, - clear() { + /** + * 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 { return Storage.setItemAsync(this.localStorageKey, JSON.stringify([])); }, - queueItemExists(queueData, queueId) { - return queueData.findIndex(data => data.queueId === queueId); + /** + * 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); }, - async length() { + /** + * 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; }, - async sendQueue() { + /** + * 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 queueData = await this.getQueue(); if (queueData.length === 0) { return false; @@ -106,7 +233,16 @@ const EventuallyQueue = { return true; }, - async sendQueueCallback(object, queueObject) { + /** + * 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); } @@ -141,6 +277,14 @@ const EventuallyQueue = { } }, + /** + * Start polling server for network connection. + * Will send queue if connection is established. + * + * @function poll + * @name Parse.EventuallyQueue.poll + * @static + */ poll() { if (this.polling) { return; @@ -149,17 +293,25 @@ const EventuallyQueue = { const RESTController = CoreManager.getRESTController(); RESTController.ajax('GET', CoreManager.get('SERVER_URL')).catch(error => { if (error !== 'Unable to connect to the Parse API') { - clearInterval(this.polling); - this.polling = undefined; + this.stopPoll(); return this.sendQueue(); } }); }, 2000); }, + + /** + * Turns off polling. + * + * @function stopPoll + * @name Parse.EventuallyQueue.stopPoll + * @static + */ stopPoll() { clearInterval(this.polling); this.polling = undefined; }, + reprocess: { create(ObjectType, queueObject) { const newObject = new ObjectType(); diff --git a/src/ParseObject.js b/src/ParseObject.js index b8948d006..312428ddf 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -57,7 +57,7 @@ type SaveParams = { body: AttributeMap, }; -type SaveOptions = FullOptions & { +export type SaveOptions = FullOptions & { cascadeSave?: boolean, context?: AttributeMap, }; From 98450318a4ced6e5fe121a0487430b4880cb494c Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Fri, 5 Feb 2021 17:41:53 -0600 Subject: [PATCH 4/5] Add in memory cache, prevent raise conditions --- integration/test/ParseEventuallyQueueTest.js | 35 ++++++-- src/EventuallyQueue.js | 94 +++++++++++++------- src/__tests__/EventuallyQueue-test.js | 44 ++++----- 3 files changed, 114 insertions(+), 59 deletions(-) diff --git a/integration/test/ParseEventuallyQueueTest.js b/integration/test/ParseEventuallyQueueTest.js index 44552ff87..ce718b99f 100644 --- a/integration/test/ParseEventuallyQueueTest.js +++ b/integration/test/ParseEventuallyQueueTest.js @@ -5,6 +5,10 @@ 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(); @@ -59,7 +63,7 @@ describe('Parse EventuallyQueue', () => { length = await Parse.EventuallyQueue.length(); assert.strictEqual(length, 0); - // TODO: can't use obj1, etc because they don't have an id + // TODO: Properly handle SingleInstance await Parse.EventuallyQueue.destroy(results[0]); await Parse.EventuallyQueue.destroy(results[1]); await Parse.EventuallyQueue.destroy(results[2]); @@ -134,13 +138,30 @@ describe('Parse EventuallyQueue', () => { 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.polling); + assert.ok(Parse.EventuallyQueue.isPolling()); await sleep(4000); const query = new Parse.Query(TestObject); @@ -149,7 +170,7 @@ describe('Parse EventuallyQueue', () => { const length = await Parse.EventuallyQueue.length(); assert.strictEqual(length, 0); - assert.strictEqual(Parse.EventuallyQueue.polling, undefined); + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); }); it('can clear queue', async () => { @@ -171,13 +192,13 @@ describe('Parse EventuallyQueue', () => { parseServer.server.close(async () => { await object.saveEventually(); let length = await Parse.EventuallyQueue.length(); - assert(Parse.EventuallyQueue.polling); + assert(Parse.EventuallyQueue.isPolling()); assert.strictEqual(length, 1); await reconfigureServer({}); await sleep(3000); // Wait for polling - assert.strictEqual(Parse.EventuallyQueue.polling, undefined); + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); length = await Parse.EventuallyQueue.length(); assert.strictEqual(length, 0); @@ -197,13 +218,13 @@ describe('Parse EventuallyQueue', () => { parseServer.server.close(async () => { await object.destroyEventually(); let length = await Parse.EventuallyQueue.length(); - assert(Parse.EventuallyQueue.polling); + assert(Parse.EventuallyQueue.isPolling()); assert.strictEqual(length, 1); await reconfigureServer({}); await sleep(3000); // Wait for polling - assert.strictEqual(Parse.EventuallyQueue.polling, undefined); + assert.strictEqual(Parse.EventuallyQueue.isPolling(), false); length = await Parse.EventuallyQueue.length(); assert.strictEqual(length, 0); diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js index 258cab7ff..5df37baa5 100644 --- a/src/EventuallyQueue.js +++ b/src/EventuallyQueue.js @@ -25,6 +25,11 @@ type QueueObject = { 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. @@ -33,9 +38,6 @@ type Queue = Array; * @static */ const EventuallyQueue = { - localStorageKey: 'Parse/Eventually/Queue', - polling: undefined, - /** * Add object to queue with save operation. * @@ -103,9 +105,9 @@ const EventuallyQueue = { 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.attributes) { + for (const prop in queueData[index].object) { if (typeof object.get(prop) === 'undefined') { - object.set(prop, queueData[index].object.attributes[prop]); + object.set(prop, queueData[index].object[prop]); } } } else { @@ -114,7 +116,7 @@ const EventuallyQueue = { queueData[index] = { queueId, action, - object, + object: object.toJSON(), serverOptions, id: object.id, className: object.className, @@ -124,8 +126,16 @@ const EventuallyQueue = { return this.setQueue(queueData); }, + store(data) { + return Storage.setItemAsync(QUEUE_KEY, JSON.stringify(data)); + }, + + load() { + return Storage.getItemAsync(QUEUE_KEY); + }, + /** - * Returns the queue from local storage + * Sets the in-memory queue from local storage and returns. * * @function getQueue * @name Parse.EventuallyQueue.getQueue @@ -133,11 +143,11 @@ const EventuallyQueue = { * @static */ async getQueue(): Promise { - const q = await Storage.getItemAsync(this.localStorageKey); - if (!q) { - return []; + if (dirtyCache) { + queueCache = JSON.parse((await this.load()) || '[]'); + dirtyCache = false; } - return JSON.parse(q); + return queueCache; }, /** @@ -149,7 +159,8 @@ const EventuallyQueue = { * @ignore */ setQueue(queue: Queue): Promise { - return Storage.setItemAsync(this.localStorageKey, JSON.stringify(queue)); + queueCache = queue; + return this.store(queueCache); }, /** @@ -165,7 +176,6 @@ const EventuallyQueue = { const index = this.queueItemExists(queueData, queueId); if (index > -1) { queueData.splice(index, 1); - await this.setQueue(queueData); } }, @@ -178,7 +188,8 @@ const EventuallyQueue = { * @static */ clear(): Promise { - return Storage.setItemAsync(this.localStorageKey, JSON.stringify([])); + queueCache = []; + return this.store([]); }, /** @@ -216,18 +227,22 @@ const EventuallyQueue = { * @static */ async sendQueue(): Promise { - const queueData = await this.getQueue(); + const queue = await this.getQueue(); + const queueData = [...queue]; + if (queueData.length === 0) { return false; } for (let i = 0; i < queueData.length; i += 1) { - const ObjectType = ParseObject.extend(queueData[i].className); - if (queueData[i].id) { - await this.reprocess.byId(ObjectType, queueData[i]); - } else if (queueData[i].hash) { - await this.reprocess.byHash(ObjectType, queueData[i]); + 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.reprocess.create(ObjectType, queueData[i]); + await this.process.create(ObjectType, queueObject); } } return true; @@ -283,13 +298,14 @@ const EventuallyQueue = { * * @function poll * @name Parse.EventuallyQueue.poll + * @param [ms] Milliseconds to ping the server. Default 2000ms * @static */ - poll() { - if (this.polling) { + poll(ms: number = 2000) { + if (polling) { return; } - this.polling = setInterval(() => { + polling = setInterval(() => { const RESTController = CoreManager.getRESTController(); RESTController.ajax('GET', CoreManager.get('SERVER_URL')).catch(error => { if (error !== 'Unable to connect to the Parse API') { @@ -297,7 +313,7 @@ const EventuallyQueue = { return this.sendQueue(); } }); - }, 2000); + }, ms); }, /** @@ -308,14 +324,30 @@ const EventuallyQueue = { * @static */ stopPoll() { - clearInterval(this.polling); - this.polling = undefined; + 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; }, - reprocess: { + process: { create(ObjectType, queueObject) { - const newObject = new ObjectType(); - return EventuallyQueue.sendQueueCallback(newObject, queueObject); + const object = new ObjectType(); + return EventuallyQueue.sendQueueCallback(object, queueObject); }, async byId(ObjectType, queueObject) { const { sessionToken } = queueObject.serverOptions; @@ -332,7 +364,7 @@ const EventuallyQueue = { if (results.length > 0) { return EventuallyQueue.sendQueueCallback(results[0], queueObject); } - return EventuallyQueue.reprocess.create(ObjectType, queueObject); + return EventuallyQueue.process.create(ObjectType, queueObject); }, }, }; diff --git a/src/__tests__/EventuallyQueue-test.js b/src/__tests__/EventuallyQueue-test.js index 13ebd6222..c5460d264 100644 --- a/src/__tests__/EventuallyQueue-test.js +++ b/src/__tests__/EventuallyQueue-test.js @@ -22,6 +22,9 @@ class MockObject { get(key) { return this.attributes[key]; } + toJSON() { + return this.attributes; + } static extend(className) { class MockSubclass { constructor() { @@ -91,7 +94,7 @@ describe('EventuallyQueue', () => { const [savedObject, deleteObject] = await EventuallyQueue.getQueue(); expect(savedObject.id).toEqual(object.id); expect(savedObject.action).toEqual('save'); - expect(savedObject.object).toEqual(object); + expect(savedObject.object).toEqual(object.toJSON()); expect(savedObject.className).toEqual(object.className); expect(savedObject.serverOptions).toEqual({ sessionToken: 'token' }); expect(savedObject.createdAt).toBeDefined(); @@ -99,7 +102,7 @@ describe('EventuallyQueue', () => { expect(deleteObject.id).toEqual(object.id); expect(deleteObject.action).toEqual('destroy'); - expect(deleteObject.object).toEqual(object); + expect(deleteObject.object).toEqual(object.toJSON()); expect(deleteObject.className).toEqual(object.className); expect(deleteObject.serverOptions).toEqual({ sessionToken: 'secret' }); expect(deleteObject.createdAt).toBeDefined(); @@ -135,8 +138,7 @@ describe('EventuallyQueue', () => { expect(length).toBe(1); const queue = await EventuallyQueue.getQueue(); - expect(queue[0].object.attributes.foo).toBe('bar'); - expect(queue[0].object.attributes.test).toBe('1234'); + expect(queue[0].object.test).toBe('1234'); }); it('can remove object from queue', async () => { @@ -165,17 +167,17 @@ describe('EventuallyQueue', () => { }); it('can send queue by object id', async () => { - jest.spyOn(EventuallyQueue.reprocess, 'byId').mockImplementationOnce(() => {}); + 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.reprocess.byId).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.process.byId).toHaveBeenCalledTimes(1); }); it('can send queue by object hash', async () => { - jest.spyOn(EventuallyQueue.reprocess, 'byHash').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue.process, 'byHash').mockImplementationOnce(() => {}); const object = new ParseObject('TestObject'); delete object.id; object.set('hash', 'secret'); @@ -183,18 +185,18 @@ describe('EventuallyQueue', () => { const didSend = await EventuallyQueue.sendQueue(); expect(didSend).toBe(true); - expect(EventuallyQueue.reprocess.byHash).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.process.byHash).toHaveBeenCalledTimes(1); }); it('can send queue by object create', async () => { - jest.spyOn(EventuallyQueue.reprocess, 'create').mockImplementationOnce(() => {}); + 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.reprocess.create).toHaveBeenCalledTimes(1); + expect(EventuallyQueue.process.create).toHaveBeenCalledTimes(1); }); it('can handle send queue destroy callback', async () => { @@ -344,7 +346,7 @@ describe('EventuallyQueue', () => { it('can process new object', async () => { jest.spyOn(EventuallyQueue, 'sendQueueCallback').mockImplementationOnce(() => {}); - await EventuallyQueue.reprocess.create(MockObject, {}); + await EventuallyQueue.process.create(MockObject, {}); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); }); @@ -356,7 +358,7 @@ describe('EventuallyQueue', () => { serverOptions: { sessionToken: 'idToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); - await EventuallyQueue.reprocess.byId(MockObject, queueObject); + await EventuallyQueue.process.byId(MockObject, queueObject); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); @@ -371,7 +373,7 @@ describe('EventuallyQueue', () => { serverOptions: { sessionToken: 'hashToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); - await EventuallyQueue.reprocess.byHash(MockObject, queueObject); + await EventuallyQueue.process.byHash(MockObject, queueObject); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledTimes(1); expect(EventuallyQueue.sendQueueCallback).toHaveBeenCalledWith(object, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); @@ -379,32 +381,32 @@ describe('EventuallyQueue', () => { }); it('can process new object if hash not exists', async () => { - jest.spyOn(EventuallyQueue.reprocess, 'create').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue.process, 'create').mockImplementationOnce(() => {}); const queueObject = { hash: 'secret', serverOptions: { sessionToken: 'hashToken' }, }; mockQueryFind.mockImplementationOnce(() => Promise.resolve([])); - await EventuallyQueue.reprocess.byHash(MockObject, queueObject); + await EventuallyQueue.process.byHash(MockObject, queueObject); expect(mockQueryFind).toHaveBeenCalledTimes(1); expect(mockQueryFind).toHaveBeenCalledWith({ sessionToken: 'hashToken' }); }); it('cannot poll if already polling', () => { - EventuallyQueue.polling = true; + EventuallyQueue._setPolling(true); EventuallyQueue.poll(); - expect(EventuallyQueue.polling).toBe(true); + 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.polling).toBeDefined(); + expect(EventuallyQueue.isPolling()).toBe(true); jest.runOnlyPendingTimers(); await flushPromises(); - expect(EventuallyQueue.polling).toBeUndefined(); + expect(EventuallyQueue.isPolling()).toBe(false); expect(EventuallyQueue.sendQueue).toHaveBeenCalledTimes(1); }); @@ -415,11 +417,11 @@ describe('EventuallyQueue', () => { mockXHR([{ status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }, { status: 0 }]) ); EventuallyQueue.poll(); - expect(EventuallyQueue.polling).toBeDefined(); + expect(EventuallyQueue.isPolling()).toBe(true); jest.runOnlyPendingTimers(); await flushPromises(); - expect(EventuallyQueue.polling).toBeDefined(); + expect(EventuallyQueue.isPolling()).toBe(true); CoreManager.set('REQUEST_ATTEMPT_LIMIT', retry); }); }); From cf8091607f78420efb3ccd136b9ccb71c70d3fc4 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Fri, 5 Feb 2021 17:49:10 -0600 Subject: [PATCH 5/5] oops --- src/EventuallyQueue.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/EventuallyQueue.js b/src/EventuallyQueue.js index 5df37baa5..b84fe9c22 100644 --- a/src/EventuallyQueue.js +++ b/src/EventuallyQueue.js @@ -176,6 +176,7 @@ const EventuallyQueue = { const index = this.queueItemExists(queueData, queueId); if (index > -1) { queueData.splice(index, 1); + await this.setQueue(queueData); } },