Edit File: client.js
"use strict"; const sinon = require("sinon"); const stream = require("stream"); const EventEmitter = require("events"); function builtNotification() { return { headers: {}, body: JSON.stringify({ aps: { badge: 1 } }), }; } function FakeStream(deviceId, statusCode, response) { const fakeStream = new stream.Transform({ transform: sinon.spy(function(chunk, encoding, callback) { expect(this.headers).to.be.calledOnce; const headers = this.headers.firstCall.args[0]; expect(headers[":path"].substring(10)).to.equal(deviceId); this.emit("headers", { ":status": statusCode }); callback(null, Buffer.from(JSON.stringify(response) || "")); }) }); fakeStream.headers = sinon.stub(); return fakeStream; } describe("Client", function () { let fakes, Client; beforeEach(function () { fakes = { config: sinon.stub(), EndpointManager: sinon.stub(), endpointManager: new EventEmitter(), }; fakes.EndpointManager.returns(fakes.endpointManager); fakes.endpointManager.shutdown = sinon.stub(); Client = require("../lib/client")(fakes); }); describe("constructor", function () { it("prepares the configuration with passed options", function () { let options = { production: true }; let client = new Client(options); expect(fakes.config).to.be.calledWith(options); }); describe("EndpointManager instance", function() { it("is created", function () { let client = new Client(); expect(fakes.EndpointManager).to.be.calledOnce; expect(fakes.EndpointManager).to.be.calledWithNew; }); it("is passed the prepared configuration", function () { const returnSentinel = { "configKey": "configValue"}; fakes.config.returns(returnSentinel); let client = new Client({}); expect(fakes.EndpointManager).to.be.calledWith(returnSentinel); }); }); }); describe("write", function () { beforeEach(function () { fakes.config.returnsArg(0); fakes.endpointManager.getStream = sinon.stub(); fakes.EndpointManager.returns(fakes.endpointManager); }); context("a stream is available", function () { let client; context("transmission succeeds", function () { beforeEach( function () { client = new Client( { address: "testapi" } ); fakes.stream = new FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); }); it("attempts to acquire one stream", function () { return client.write(builtNotification(), "abcd1234") .then(function () { expect(fakes.endpointManager.getStream).to.be.calledOnce; }); }); describe("headers", function () { it("sends the required HTTP/2 headers", function () { return client.write(builtNotification(), "abcd1234") .then(function () { expect(fakes.stream.headers).to.be.calledWithMatch( { ":scheme": "https", ":method": "POST", ":authority": "testapi", ":path": "/3/device/abcd1234", }); }); }); it("does not include apns headers when not required", function () { return client.write(builtNotification(), "abcd1234") .then(function () { ["apns-id", "apns-priority", "apns-expiration", "apns-topic"].forEach( header => { expect(fakes.stream.headers).to.not.be.calledWithMatch(sinon.match.has(header)); }); }); }); it("sends the notification-specific apns headers when specified", function () { let notification = builtNotification(); notification.headers = { "apns-id": "123e4567-e89b-12d3-a456-42665544000", "apns-priority": 5, "apns-expiration": 123, "apns-topic": "io.apn.node", }; return client.write(notification, "abcd1234") .then(function () { expect(fakes.stream.headers).to.be.calledWithMatch( { "apns-id": "123e4567-e89b-12d3-a456-42665544000", "apns-priority": 5, "apns-expiration": 123, "apns-topic": "io.apn.node", }); }); }); context("when token authentication is enabled", function () { beforeEach(function () { fakes.token = { generation: 0, current: "fake-token", regenerate: sinon.stub(), isExpired: sinon.stub() }; client = new Client( { address: "testapi", token: fakes.token } ); fakes.stream = new FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); }); it("sends the bearer token", function () { let notification = builtNotification(); return client.write(notification, "abcd1234").then(function () { expect(fakes.stream.headers).to.be.calledWithMatch({ authorization: "bearer fake-token", }); }); }); }); context("when token authentication is disabled", function () { beforeEach(function () { client = new Client( { address: "testapi" } ); fakes.stream = new FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); }); it("does not set an authorization header", function () { let notification = builtNotification(); return client.write(notification, "abcd1234").then(function () { expect(fakes.stream.headers.firstCall.args[0]).to.not.have.property("authorization"); }) }); }) }); it("writes the notification data to the pipe", function () { const notification = builtNotification(); return client.write(notification, "abcd1234") .then(function () { expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body))); }); }); it("ends the stream", function () { sinon.spy(fakes.stream, "end"); return client.write(builtNotification(), "abcd1234") .then(function () { expect(fakes.stream.end).to.be.calledOnce; }); }); it("resolves with the device token", function () { return expect(client.write(builtNotification(), "abcd1234")) .to.become({ device: "abcd1234" }); }); }); context("error occurs", function () { let promise; context("general case", function () { beforeEach(function () { const client = new Client( { address: "testapi" } ); fakes.stream = new FakeStream("abcd1234", "400", { "reason" : "BadDeviceToken" }); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); promise = client.write(builtNotification(), "abcd1234"); }); it("resolves with the device token, status code and response", function () { return expect(promise).to.eventually.deep.equal({ status: "400", device: "abcd1234", response: { reason: "BadDeviceToken" }}); }); }) context("ExpiredProviderToken", function () { beforeEach(function () { let tokenGenerator = sinon.stub().returns("fake-token"); const client = new Client( { address: "testapi", token: tokenGenerator }); }) }); }); context("stream ends without completing request", function () { let promise; beforeEach(function () { const client = new Client( { address: "testapi" } ); fakes.stream = new stream.Transform({ transform: function(chunk, encoding, callback) {} }); fakes.stream.headers = sinon.stub(); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); promise = client.write(builtNotification(), "abcd1234"); fakes.stream.push(null); }); it("resolves with an object containing the device token", function () { return expect(promise).to.eventually.have.property("device", "abcd1234"); }); it("resolves with an object containing an error", function () { return promise.then( (response) => { expect(response).to.have.property("error"); expect(response.error).to.be.an.instanceOf(Error); expect(response.error).to.match(/stream ended unexpectedly/); }); }); }); context("stream is unprocessed", function () { let promise; beforeEach(function () { const client = new Client( { address: "testapi" } ); fakes.stream = new stream.Transform({ transform: function(chunk, encoding, callback) {} }); fakes.stream.headers = sinon.stub(); fakes.secondStream = FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); fakes.endpointManager.getStream.onCall(1).returns(fakes.secondStream); promise = client.write(builtNotification(), "abcd1234"); setImmediate(() => { fakes.stream.emit("unprocessed"); }); }); it("attempts to resend on a new stream", function (done) { setImmediate(() => { expect(fakes.endpointManager.getStream).to.be.calledTwice; done(); }); }); it("fulfills the promise", function () { return expect(promise).to.eventually.deep.equal({ device: "abcd1234" }); }); }); context("stream error occurs", function () { let promise; beforeEach(function () { const client = new Client( { address: "testapi" } ); fakes.stream = new stream.Transform({ transform: function(chunk, encoding, callback) {} }); fakes.stream.headers = sinon.stub(); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); promise = client.write(builtNotification(), "abcd1234"); }); context("passing an Error", function () { beforeEach(function () { fakes.stream.emit("error", new Error("stream error")); }); it("resolves with an object containing the device token", function () { return expect(promise).to.eventually.have.property("device", "abcd1234"); }); it("resolves with an object containing a wrapped error", function () { return promise.then( (response) => { expect(response.error).to.be.an.instanceOf(Error); expect(response.error).to.match(/apn write failed/); expect(response.error.cause()).to.be.an.instanceOf(Error).and.match(/stream error/); }); }); }); context("passing a string", function () { it("resolves with the device token and an error", function () { fakes.stream.emit("error", "stream error"); return promise.then( (response) => { expect(response).to.have.property("device", "abcd1234"); expect(response.error).to.to.be.an.instanceOf(Error); expect(response.error).to.match(/apn write failed/); expect(response.error).to.match(/stream error/); }); }); }); }); }); context("no new stream is returned but the endpoint later wakes up", function () { let notification, promise; beforeEach( function () { const client = new Client( { address: "testapi" } ); fakes.stream = new FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(null); fakes.endpointManager.getStream.onCall(1).returns(fakes.stream); notification = builtNotification(); promise = client.write(notification, "abcd1234"); expect(fakes.stream.headers).to.not.be.called; fakes.endpointManager.emit("wakeup"); return promise; }); it("sends the required headers to the newly available stream", function () { expect(fakes.stream.headers).to.be.calledWithMatch( { ":scheme": "https", ":method": "POST", ":authority": "testapi", ":path": "/3/device/abcd1234", }); }); it("writes the notification data to the pipe", function () { expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body))); }); }); context("when 5 successive notifications are sent", function () { beforeEach(function () { fakes.streams = [ new FakeStream("abcd1234", "200"), new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }), new FakeStream("bcfe4433", "200"), new FakeStream("aabbc788", "413", { reason: "PayloadTooLarge" }), ]; }); context("streams are always returned", function () { let promises; beforeEach( function () { const client = new Client( { address: "testapi" } ); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]); fakes.endpointManager.getStream.onCall(3).returns(fakes.streams[3]); fakes.endpointManager.getStream.onCall(4).returns(fakes.streams[4]); promises = Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), client.write(builtNotification(), "bcfe4433"), client.write(builtNotification(), "aabbc788"), ]); return promises; }); it("sends the required headers for each stream", function () { expect(fakes.streams[0].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1234" } ); expect(fakes.streams[1].headers).to.be.calledWithMatch( { ":path": "/3/device/adfe5969" } ); expect(fakes.streams[2].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1335" } ); expect(fakes.streams[3].headers).to.be.calledWithMatch( { ":path": "/3/device/bcfe4433" } ); expect(fakes.streams[4].headers).to.be.calledWithMatch( { ":path": "/3/device/aabbc788" } ); }); it("writes the notification data for each stream", function () { fakes.streams.forEach( stream => { expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body))); }); }); it("resolves with the notification outcomes", function () { return expect(promises).to.eventually.deep.equal([ { device: "abcd1234"}, { device: "adfe5969", status: "400", response: { reason: "MissingTopic" } }, { device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 } }, { device: "bcfe4433"}, { device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" } }, ]); }); }); context("some streams return, others wake up later", function () { let promises; beforeEach( function() { const client = new Client( { address: "testapi" } ); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); promises = Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), client.write(builtNotification(), "bcfe4433"), client.write(builtNotification(), "aabbc788"), ]); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]); fakes.endpointManager.getStream.onCall(1).returns(null); fakes.endpointManager.emit("wakeup"); }, 1); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); fakes.endpointManager.emit("wakeup"); }, 2); return promises; }); it("sends the correct device ID for each stream", function () { expect(fakes.streams[0].headers).to.be.calledWithMatch({":path": "/3/device/abcd1234"}); expect(fakes.streams[1].headers).to.be.calledWithMatch({":path": "/3/device/adfe5969"}); expect(fakes.streams[2].headers).to.be.calledWithMatch({":path": "/3/device/abcd1335"}); expect(fakes.streams[3].headers).to.be.calledWithMatch({":path": "/3/device/bcfe4433"}); expect(fakes.streams[4].headers).to.be.calledWithMatch({":path": "/3/device/aabbc788"}); }); it("writes the notification data for each stream", function () { fakes.streams.forEach( stream => { expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body))); }); }); it("resolves with the notification reponses", function () { return expect(promises).to.eventually.deep.equal([ { device: "abcd1234"}, { device: "adfe5969", status: "400", response: { reason: "MissingTopic" } }, { device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 } }, { device: "bcfe4433"}, { device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" } }, ]); }); }); context("connection fails", function () { let promises, client; beforeEach( function() { client = new Client( { address: "testapi" } ); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); promises = Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), ]); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.emit("error", new Error("endpoint failed")); }, 1); return promises; }); it("resolves with 1 success", function () { return promises.then( response => { expect(response[0]).to.deep.equal({ device: "abcd1234" }); }); }); it("resolves with 2 errors", function () { return promises.then( response => { expect(response[1]).to.have.property("device", "adfe5969"); expect(response[1]).to.have.nested.property("error.message", "endpoint failed"); expect(response[2]).to.have.property("device", "abcd1335"); expect(response[2]).to.have.nested.property("error.message", "endpoint failed"); }) }); it("clears the queue", function () { return promises.then( () => { expect(client.queue.length).to.equal(0); }); }); }); }); describe("token generator behaviour", function () { beforeEach(function () { fakes.token = { generation: 0, current: "fake-token", regenerate: sinon.stub(), isExpired: sinon.stub() } fakes.streams = [ new FakeStream("abcd1234", "200"), new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }), ]; }); it("reuses the token", function () { const client = new Client( { address: "testapi", token: fakes.token } ); fakes.token.regenerate = function () { fakes.token.generation = 1; fakes.token.current = "second-token"; } fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]); return Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), ]).then(function () { expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); }); }); context("token expires", function () { beforeEach(function () { fakes.token.regenerate = function (generation) { if (generation === fakes.token.generation) { fakes.token.generation += 1; fakes.token.current = "token-" + fakes.token.generation; } } }); it("resends the notification with a new token", function () { fakes.streams = [ new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), new FakeStream("adfe5969", "200"), ]; fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); const client = new Client( { address: "testapi", token: fakes.token } ); const promise = client.write(builtNotification(), "adfe5969"); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[1]); fakes.endpointManager.emit("wakeup"); }, 1); return promise.then(function () { expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer token-1" }); }); }); it("only regenerates the token once per-expiry", function () { fakes.streams = [ new FakeStream("abcd1234", "200"), new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), new FakeStream("abcd1335", "403", { reason: "ExpiredProviderToken" }), new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }), ]; fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]); const client = new Client( { address: "testapi", token: fakes.token } ); const promises = Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), ]); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); fakes.endpointManager.emit("wakeup"); }, 1); return promises.then(function () { expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); expect(fakes.streams[3].headers).to.be.calledWithMatch({ authorization: "bearer token-1" }); expect(fakes.streams[4].headers).to.be.calledWithMatch({ authorization: "bearer token-1" }); }); }); it("abandons sending after 3 ExpiredProviderToken failures", function () { fakes.streams = [ new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), ]; fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]); const client = new Client( { address: "testapi", token: fakes.token } ); return expect(client.write(builtNotification(), "adfe5969")).to.eventually.have.property("status", "403"); }); it("regenerate token", function () { fakes.stream = new FakeStream("abcd1234", "200"); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); fakes.token.isExpired = function (current, validSeconds) { return true; } let client = new Client({ address: "testapi", token: fakes.token }); return client.write(builtNotification(), "abcd1234") .then(function () { expect(fakes.token.generation).to.equal(1); }); }); it("internal server error", function () { fakes.stream = new FakeStream("abcd1234", "500", { reason: "InternalServerError" }); fakes.stream.connection = sinon.stub(); fakes.stream.connection.close = sinon.stub(); fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); let client = new Client({ address: "testapi", token: fakes.token }); return expect(client.write(builtNotification(), "abcd1234")).to.eventually.have.nested.property("error.jse_shortmsg","Error 500, stream ended unexpectedly"); }); }); }); }); describe("shutdown", function () { beforeEach(function () { fakes.config.returnsArg(0); fakes.endpointManager.getStream = sinon.stub(); fakes.EndpointManager.returns(fakes.endpointManager); }); context("with no pending notifications", function () { it("invokes shutdown on endpoint manager", function () { let client = new Client(); client.shutdown(); expect(fakes.endpointManager.shutdown).to.be.calledOnce; }); }); context("with pending notifications", function () { it("invokes shutdown on endpoint manager after queue drains", function () { let client = new Client({ address: "none" }); fakes.streams = [ new FakeStream("abcd1234", "200"), new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }), new FakeStream("bcfe4433", "200"), new FakeStream("aabbc788", "413", { reason: "PayloadTooLarge" }), ]; fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); let promises = Promise.all([ client.write(builtNotification(), "abcd1234"), client.write(builtNotification(), "adfe5969"), client.write(builtNotification(), "abcd1335"), client.write(builtNotification(), "bcfe4433"), client.write(builtNotification(), "aabbc788"), ]); client.shutdown(); expect(fakes.endpointManager.shutdown).to.not.be.called; setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]); fakes.endpointManager.getStream.onCall(1).returns(null); fakes.endpointManager.emit("wakeup"); }, 1); setTimeout(function () { fakes.endpointManager.getStream.reset(); fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); fakes.endpointManager.emit("wakeup"); }, 2); return promises.then( () => { expect(fakes.endpointManager.shutdown).to.have.been.called; }); }); }); }); });
Back to File Manager