diff --git a/package.json b/package.json index 9a71451..219b714 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "@superhuman/push-receiver", - "version": "2.1.7", - "description": "A module to subscribe to GCM/FCM and receive notifications within a node process.", + "version": "2.1.8", + "description": + "A module to subscribe to GCM/FCM and receive notifications within a node process.", "main": "src/index.js", "scripts": { "start": "node scripts/listen", "register": "node scripts/register", "send": "node scripts/send", - "pretty": "prettier-eslint --single-quote --trailing-comma es5 --write \"**/*.js\" \"**/*.json\"", - "pretty:check": "prettier-eslint --single-quote --trailing-comma es5 --list-different --log-level silent \"**/*.js\" \"**/*.json\"", + "pretty": + "prettier-eslint --single-quote --trailing-comma es5 --write \"**/*.js\" \"**/*.json\"", + "pretty:check": + "prettier-eslint --single-quote --trailing-comma es5 --list-different --log-level silent \"**/*.js\" \"**/*.json\"", "lint": "eslint 'src/**/*.js'", "lint:fix": "eslint 'src/**/*.js' --fix", "test": "jest", @@ -19,9 +22,7 @@ "eslint --fix", "prettier-eslint --single-quote --trailing-comma es5 --write" ], - "*.json": [ - "prettier-eslint --single-quote --trailing-comma es5 --write" - ] + "*.json": ["prettier-eslint --single-quote --trailing-comma es5 --write"] }, "repository": { "type": "git", diff --git a/src/client.js b/src/client.js index a36e90d..13eecd3 100644 --- a/src/client.js +++ b/src/client.js @@ -174,14 +174,24 @@ module.exports = class Client extends EventEmitter { _onMessage({ tag, object }) { if (tag === kLoginResponseTag) { - // clear persistent ids, as we just sent them to the server while logging - // in - this._persistentIds = []; + // A LoginResponse with an error means the server did not process our + // receivedPersistentId list, so the ids are not acked yet. + if (!object.error) { + this._persistentIds = []; + this._emitPersistentIds(); + } + this.emit('loginResponse', object); } else if (tag === kDataMessageStanzaTag) { this._onDataMessage(object); } } + // Emits a snapshot so consumers can mirror the list (e.g. to disk) without + // sharing the mutable array. + _emitPersistentIds() { + this.emit('persistentIds', this._persistentIds.slice()); + } + _onDataMessage(object) { if (this._persistentIds.includes(object.persistentId)) { return; @@ -193,6 +203,7 @@ module.exports = class Client extends EventEmitter { } catch (error) { if (isReportableDecryptionError(error)) { this._persistentIds.push(object.persistentId); + this._emitPersistentIds(); } this._emitError(error); return; @@ -200,6 +211,9 @@ module.exports = class Client extends EventEmitter { // Maintain persistentIds updated with the very last received value this._persistentIds.push(object.persistentId); + // Deliver the id before the notification so a consumer that persists ids + // never handles a message it has not recorded yet. + this._emitPersistentIds(); // Send notification this.emit('ON_NOTIFICATION_RECEIVED', { notification : message, diff --git a/test/client.test.js b/test/client.test.js index c0ce588..425a967 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -1,6 +1,7 @@ jest.mock('../src/utils/decrypt'); const Client = require('../src/client'); +const { kLoginResponseTag } = require('../src/constants'); const decrypt = require('../src/utils/decrypt'); const KEYS = { @@ -13,11 +14,11 @@ function createClient(persistentIds = []) { } describe('Client', () => { - describe('_onDataMessage', () => { - beforeEach(() => { - decrypt.mockReset(); - }); + beforeEach(() => { + decrypt.mockReset(); + }); + describe('_onDataMessage', () => { [ 'Unsupported state or unable to authenticate data', 'crypto-key is missing', @@ -81,4 +82,101 @@ describe('Client', () => { }); }); }); + + describe('persistentIds event', () => { + it('clears and emits on a successful login response', () => { + const client = createClient(['a', 'b']); + const onPersistentIds = jest.fn(); + const onLoginResponse = jest.fn(); + client.on('persistentIds', onPersistentIds); + client.on('loginResponse', onLoginResponse); + + client._onMessage({ tag : kLoginResponseTag, object : {} }); + + expect(client._persistentIds).toEqual([]); + expect(onPersistentIds).toHaveBeenCalledWith([]); + expect(onLoginResponse).toHaveBeenCalledWith({}); + }); + + it('does not clear on an errored login response', () => { + const client = createClient(['a', 'b']); + const onPersistentIds = jest.fn(); + const onLoginResponse = jest.fn(); + client.on('persistentIds', onPersistentIds); + client.on('loginResponse', onLoginResponse); + const object = { error : { code : 1 } }; + + client._onMessage({ tag : kLoginResponseTag, object }); + + expect(client._persistentIds).toEqual(['a', 'b']); + expect(onPersistentIds).not.toHaveBeenCalled(); + expect(onLoginResponse).toHaveBeenCalledWith(object); + }); + + it('emits before the notification on a decrypted message', () => { + const client = createClient(); + const order = []; + decrypt.mockReturnValue({ title : 'Hello' }); + client.on('persistentIds', ids => order.push(['persistentIds', ids])); + client.on('ON_NOTIFICATION_RECEIVED', () => order.push(['notification'])); + + client._onDataMessage({ persistentId : 'persistent-id' }); + + expect(order).toEqual([ + ['persistentIds', ['persistent-id']], + ['notification'], + ]); + }); + + it('emits after recording a reportable decryption error', () => { + const client = createClient(); + const onPersistentIds = jest.fn(); + client.on('persistentIds', onPersistentIds); + client.on('error', () => {}); + decrypt.mockImplementation(() => { + throw new Error('salt is missing'); + }); + + client._onDataMessage({ persistentId : 'persistent-id' }); + + expect(onPersistentIds).toHaveBeenCalledWith(['persistent-id']); + }); + + it('does not emit for an unreportable decryption error', () => { + const client = createClient(); + const onPersistentIds = jest.fn(); + client.on('persistentIds', onPersistentIds); + client.on('error', () => {}); + decrypt.mockImplementation(() => { + throw new Error('unexpected decrypt failure'); + }); + + client._onDataMessage({ persistentId : 'persistent-id' }); + + expect(onPersistentIds).not.toHaveBeenCalled(); + }); + + it('does not emit for an already-recorded persistent id', () => { + const client = createClient(['persistent-id']); + const onPersistentIds = jest.fn(); + client.on('persistentIds', onPersistentIds); + + client._onDataMessage({ persistentId : 'persistent-id' }); + + expect(onPersistentIds).not.toHaveBeenCalled(); + }); + + it('emits a snapshot copy of the full id list', () => { + const client = createClient(); + const snapshots = []; + decrypt.mockReturnValue({ title : 'Hello' }); + client.on('persistentIds', ids => snapshots.push(ids)); + + client._onDataMessage({ persistentId : 'a' }); + snapshots[0].push('mutated'); + client._onDataMessage({ persistentId : 'b' }); + + expect(snapshots[1]).toEqual(['a', 'b']); + }); + }); });