Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -193,13 +203,17 @@ module.exports = class Client extends EventEmitter {
} catch (error) {
if (isReportableDecryptionError(error)) {
this._persistentIds.push(object.persistentId);
this._emitPersistentIds();
}
this._emitError(error);
return;
}

// 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,
Expand Down
106 changes: 102 additions & 4 deletions test/client.test.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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',
Expand Down Expand Up @@ -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']);
});
});
});