From a3efae9f24914df81cd2989d42a23032a7fa4f5a Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 10:32:18 -0800 Subject: [PATCH 01/13] add dotenv support --- package-lock.json | 2 +- package.json | 5 +++-- src/index.ts | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d485f2..d9b0220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@apollo/server": "^4.11.2", + "dotenv": "^16.4.7", "graphql": "^16.10.0", "jsonwebtoken": "^9.0.2" }, @@ -4856,7 +4857,6 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "dev": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index d698ded..bd50fbd 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,16 @@ "@graphql-codegen/typescript": "4.1.2", "@graphql-codegen/typescript-resolvers": "4.4.1", "@jest/globals": "^29.7.0", + "@types/jsonwebtoken": "^9.0.8", "@types/node": "^22.10.2", "jest": "^29.7.0", "ts-jest": "^29.2.5", - "typescript": "^5.7.2", - "@types/jsonwebtoken": "^9.0.8" + "typescript": "^5.7.2" }, "private": true, "dependencies": { "@apollo/server": "^4.11.2", + "dotenv": "^16.4.7", "graphql": "^16.10.0", "jsonwebtoken": "^9.0.2" } diff --git a/src/index.ts b/src/index.ts index 9e93dcc..cd3ae72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,9 @@ import { ServerContext } from "./ServerContext"; import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository"; import { TimedApiBasedRepositoryLoader } from "./loaders/TimedApiBasedRepositoryLoader"; import { NotificationService } from "./services/NotificationService"; +import { configDotenv } from "dotenv"; + +configDotenv(); const typeDefs = readFileSync("./schema.graphqls", "utf8"); From 7e764502a06621df987618c2eada8987de9cb93a Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 10:32:30 -0800 Subject: [PATCH 02/13] add private folder to gitignore for p8 key --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8471424..496b53a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ yarn-error.log* # Testing /coverage/ + +# Keys +private/ From a6138b37cb4274c1d3c9fff9d45ef641aaf48261 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 10:32:46 -0800 Subject: [PATCH 03/13] use seconds instead of ms for claims payload --- src/services/NotificationService.ts | 41 ++++++++++++++++------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 66ec81d..46d9e68 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -54,17 +54,17 @@ export class NotificationService { "kid": keyId, }; - const now = Date.now(); + const nowMs = Date.now(); const claimsPayload = { "iss": teamId, - "iat": now, + "iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch }; this.apnsToken = jwt.sign(claimsPayload, privateKey, { algorithm: "ES256", header: tokenHeader }); - this._lastRefreshedTimeMs = now; + this._lastRefreshedTimeMs = nowMs; } private lastReloadedTimeForAPNsIsTooRecent() { @@ -109,25 +109,30 @@ export class NotificationService { "apns-priority": "10", "apns-topic": bundleId, }; - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - aps: { - alert: { - title: "Shuttle is arriving", - body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + aps: { + alert: { + title: "Shuttle is arriving", + body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` + } } - } - }), - }); - const json = await response.json(); + }), + }); + const json = await response.json(); - if (response.status !== 200) { - console.error(`Notification failed for device ${deviceId}:`, json.reason); + if (response.status !== 200) { + console.error(`Notification failed for device ${deviceId}:`, json.reason); + return false; + } + return true; + } catch(e) { + console.error(e); return false; } - return true; } public static getAPNsFullUrlToUse(deviceId: string) { From 6635a1a89bad433c8a1673d993ee5715dd164040 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 10:36:49 -0800 Subject: [PATCH 04/13] use api.development.push instead of api.sandbox.push --- src/services/NotificationService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 46d9e68..e4e202a 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -137,7 +137,7 @@ export class NotificationService { public static getAPNsFullUrlToUse(deviceId: string) { // Construct the fetch request - const devBaseUrl = "https://api.sandbox.push.apple.com" + const devBaseUrl = "https://api.development.push.apple.com" const prodBaseUrl = "https://api.push.apple.com" const path = "/3/device/" + deviceId; From 2d92370254be2541d14bd0bf987ebc8ca15c989d Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:21:41 -0800 Subject: [PATCH 05/13] reload apns token on startup --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index cd3ae72..39fe3f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ async function main() { await repositoryDataUpdater.start(); const notificationService = new NotificationService(repository); + notificationService.reloadAPNsTokenIfTimePassed(); const { url } = await startStandaloneServer(server, { listen: { From da224c36ed7ea935ff702df0ed4b6b41431037d6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:21:48 -0800 Subject: [PATCH 06/13] use node.js http2 module instead of fetch --- src/services/NotificationService.ts | 49 ++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index e4e202a..c6d7711 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import fs from "fs"; import { TupleKey } from "../types/TupleKey"; import { IEta } from "../entities/entities"; +import * as http2 from "node:http2"; export interface ScheduledNotificationData { deviceId: string; @@ -103,31 +104,57 @@ export class NotificationService { } const headers = { - authorization: `bearer ${this.apnsToken}`, + ':method': 'POST', + ':path': `/3/device/${deviceId}`, + 'authorization': `bearer ${this.apnsToken}`, "apns-push-type": "alert", "apns-expiration": "0", "apns-priority": "10", "apns-topic": bundleId, }; try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ + const client = http2.connect('https://api.development.push.apple.com'); + const req = client.request(headers); + req.setEncoding('utf8'); + + await new Promise((resolve, reject) => { + req.on('response', (headers, flags) => { + if (headers[":status"] !== 200) { + console.error(`APNs request failed with status ${headers[":status"]}`); + reject(); + } + resolve(); + }); + + req.write(JSON.stringify({ aps: { alert: { title: "Shuttle is arriving", body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` } } - }), + })); + req.end(); }); - const json = await response.json(); - if (response.status !== 200) { - console.error(`Notification failed for device ${deviceId}:`, json.reason); - return false; - } + // const response = await fetch(url, { + // method: "POST", + // headers, + // body: JSON.stringify({ + // aps: { + // alert: { + // title: "Shuttle is arriving", + // body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` + // } + // } + // }), + // }); + // const json = await response.json(); + + // if (response.status !== 200) { + // console.error(`Notification failed for device ${deviceId}:`, json.reason); + // return false; + // } return true; } catch(e) { console.error(e); From baa94eeef5cdd9be82678404f732d43e5b916138 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:30:29 -0800 Subject: [PATCH 07/13] update tests and implementation ofr getAPNsFullUrlToUse --- src/services/NotificationService.ts | 24 +++++++++++++++---- .../services/NotificationServiceTests.test.ts | 17 ++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index c6d7711..a6b9162 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -11,6 +11,12 @@ export interface ScheduledNotificationData { stopId: string; } +interface APNsUrl { + fullUrl: string; + path: string; + host: string; +} + export class NotificationService { public readonly secondsThresholdForNotificationToFire = 300; @@ -162,17 +168,25 @@ export class NotificationService { } } - public static getAPNsFullUrlToUse(deviceId: string) { + public static getAPNsFullUrlToUse(deviceId: string): APNsUrl { // Construct the fetch request const devBaseUrl = "https://api.development.push.apple.com" const prodBaseUrl = "https://api.push.apple.com" - const path = "/3/device/" + deviceId; - let urlToUse = prodBaseUrl + path; + let hostToUse = prodBaseUrl; if (process.env.NODE_ENV !== "production") { - urlToUse = devBaseUrl + path; + hostToUse = devBaseUrl; } - return urlToUse; + + const path = "/3/device/" + deviceId; + const fullUrl = hostToUse + path; + + const constructedObject = { + fullUrl, + host: hostToUse, + path, + } + return constructedObject; } private async etaSubscriberCallback(eta: IEta) { diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts index cf5308a..78ffbd3 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/services/NotificationServiceTests.test.ts @@ -185,21 +185,22 @@ describe("NotificationService", () => { process.env.NODE_ENV = 'production'; const deviceId = 'testDeviceId'; const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + + const { fullUrl, host, path } = result; + expect(fullUrl).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + expect(host).toBe("https://api.push.apple.com"); + expect(path).toBe(`/3/device/${deviceId}`); }); it('should return the sandbox URL when NODE_ENV is not set to "production"', () => { process.env.NODE_ENV = 'development'; const deviceId = 'testDeviceId'; const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.sandbox.push.apple.com/3/device/${deviceId}`); - }); - it('should append the correct device ID to the URL', () => { - process.env.NODE_ENV = 'production'; - const deviceId = 'device123'; - const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + const { fullUrl, host, path } = result; + expect(fullUrl).toBe(`https://api.development.push.apple.com/3/device/${deviceId}`); + expect(host).toBe("https://api.development.push.apple.com"); + expect(path).toBe(`/3/device/${deviceId}`); }); }); From 5e186c643cb26af89fb32abeb5cb3af867840dc9 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:32:26 -0800 Subject: [PATCH 08/13] propagate error message down to outer catch statement --- src/services/NotificationService.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index a6b9162..d21726a 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -126,8 +126,7 @@ export class NotificationService { await new Promise((resolve, reject) => { req.on('response', (headers, flags) => { if (headers[":status"] !== 200) { - console.error(`APNs request failed with status ${headers[":status"]}`); - reject(); + reject(`APNs request failed with status ${headers[":status"]}`); } resolve(); }); @@ -142,25 +141,6 @@ export class NotificationService { })); req.end(); }); - - // const response = await fetch(url, { - // method: "POST", - // headers, - // body: JSON.stringify({ - // aps: { - // alert: { - // title: "Shuttle is arriving", - // body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` - // } - // } - // }), - // }); - // const json = await response.json(); - - // if (response.status !== 200) { - // console.error(`Notification failed for device ${deviceId}:`, json.reason); - // return false; - // } return true; } catch(e) { console.error(e); From a880d71c87c05285f1aad1f168334596459fb3a6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:34:48 -0800 Subject: [PATCH 09/13] use updated getAPNs... method --- src/services/NotificationService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index d21726a..1f52eef 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -82,7 +82,6 @@ export class NotificationService { private async sendEtaNotificationImmediately(notificationData: ScheduledNotificationData): Promise { const { deviceId, shuttleId, stopId } = notificationData; this.reloadAPNsTokenIfTimePassed(); - const url = NotificationService.getAPNsFullUrlToUse(deviceId); const shuttle = await this.repository.getShuttleById(shuttleId); const stop = await this.repository.getStopById(stopId); @@ -109,9 +108,11 @@ export class NotificationService { throw new Error("APNS_BUNDLE_ID environment variable is not set correctly"); } + const { path, host } = NotificationService.getAPNsFullUrlToUse(deviceId); + const headers = { ':method': 'POST', - ':path': `/3/device/${deviceId}`, + ':path': path, 'authorization': `bearer ${this.apnsToken}`, "apns-push-type": "alert", "apns-expiration": "0", @@ -119,7 +120,7 @@ export class NotificationService { "apns-topic": bundleId, }; try { - const client = http2.connect('https://api.development.push.apple.com'); + const client = http2.connect(host); const req = client.request(headers); req.setEncoding('utf8'); From b5954e251d930cd27cfb1d82de20c0bad831d27e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:46:07 -0800 Subject: [PATCH 10/13] mock node:http2 module in test --- test/services/NotificationServiceTests.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts index 78ffbd3..32e16cd 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/services/NotificationServiceTests.test.ts @@ -7,6 +7,7 @@ import { resetGlobalFetchMockJson, updateGlobalFetchMockJson } from "../testHelp import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; jest.mock("fs"); +jest.mock("node:http2"); const sampleKey = `-----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsrmSAZIagOfCP8sB @@ -119,8 +120,6 @@ describe("NotificationService", () => { const { eta, notificationData1, notificationData2 } = generateNotificationDataAndEta(shuttle, stop); - // Simulate 200 + empty object for successful push notification - updateGlobalFetchMockJson({}); // Act await notificationService.scheduleNotification(notificationData1); @@ -147,8 +146,6 @@ describe("NotificationService", () => { const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); eta.secondsRemaining = notificationService.secondsThresholdForNotificationToFire + 100; - updateGlobalFetchMockJson({}); - // Act await notificationService.scheduleNotification(notificationData1); await repository.addOrUpdateEta(eta); @@ -165,8 +162,6 @@ describe("NotificationService", () => { const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop) - updateGlobalFetchMockJson({}, 400); - // Act await notificationService.scheduleNotification(notificationData1); await repository.addOrUpdateEta(eta); @@ -211,8 +206,6 @@ describe("NotificationService", () => { const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); - updateGlobalFetchMockJson({}); - await notificationService.scheduleNotification(notificationData1); // Act From 764f6e35f07b4c484ef65c5ba800dfefb6de4f82 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 13:56:42 -0800 Subject: [PATCH 11/13] add http2 mock and update tests --- .../services/NotificationServiceTests.test.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts index 32e16cd..779c615 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/services/NotificationServiceTests.test.ts @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { NotificationService } from "../../src/services/NotificationService"; import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository"; import fs from "fs"; +import http2 from "http2"; import { IEta, IShuttle, IStop } from "../../src/entities/entities"; -import { resetGlobalFetchMockJson, updateGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers"; import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; +import EventEmitter = require("node:events"); jest.mock("fs"); -jest.mock("node:http2"); +jest.mock("http2"); const sampleKey = `-----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsrmSAZIagOfCP8sB @@ -59,9 +60,27 @@ describe("NotificationService", () => { }; (fs.readFileSync as jest.Mock).mockReturnValue(sampleKey); + }); - resetGlobalFetchMockJson(); - }) + beforeEach(() => { + class MockClient extends EventEmitter { + request = jest.fn((headers: any) => { + const mockRequest: any = new EventEmitter(); + mockRequest.setEncoding = jest.fn(); + mockRequest.write = jest.fn(); + mockRequest.end = jest.fn(() => { + setTimeout(() => { + mockRequest.emit('response', { ':status': 200 }); + }, 10); + }); + return mockRequest; + }); + + close() {}; + } + + (http2.connect as jest.Mock) = jest.fn(() => new MockClient()); + }); describe("reloadAPNsTokenIfTimePassed", () => { it("reloads the token if token hasn't been generated yet", async () => { @@ -120,7 +139,6 @@ describe("NotificationService", () => { const { eta, notificationData1, notificationData2 } = generateNotificationDataAndEta(shuttle, stop); - // Act await notificationService.scheduleNotification(notificationData1); await notificationService.scheduleNotification(notificationData2); @@ -131,7 +149,6 @@ describe("NotificationService", () => { // wait for the change to occur first await waitForCondition(() => !notificationService.isNotificationScheduled(notificationData1)); - expect(fetch as jest.Mock).toHaveBeenCalledTimes(2); const isFirstNotificationScheduled = notificationService.isNotificationScheduled(notificationData1); const isSecondNotificationScheduled = notificationService.isNotificationScheduled(notificationData2); // No longer scheduled after being sent @@ -214,7 +231,7 @@ describe("NotificationService", () => { // Assert await waitForMilliseconds(500); - expect(fetch as jest.Mock).toHaveBeenCalledTimes(0); + expect(http2.connect as jest.Mock).toHaveBeenCalledTimes(0); }); }); }); From 4fdf60f9bf551262bbb23b5e8830d0196f4791db Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 14:02:40 -0800 Subject: [PATCH 12/13] update import for testing --- src/services/NotificationService.ts | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 1f52eef..6fee796 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -3,7 +3,7 @@ import jwt from "jsonwebtoken"; import fs from "fs"; import { TupleKey } from "../types/TupleKey"; import { IEta } from "../entities/entities"; -import * as http2 from "node:http2"; +import http2 from "http2"; export interface ScheduledNotificationData { deviceId: string; @@ -43,7 +43,7 @@ export class NotificationService { * stop ID, which can be generated using `TupleKey`. * @private */ - private deviceIdsToDeliverTo: { [key: string]: string[] } = {} + private deviceIdsToDeliverTo: { [key: string]: Set } = {} public reloadAPNsTokenIfTimePassed() { if (this.lastReloadedTimeForAPNsIsTooRecent()) { @@ -176,15 +176,17 @@ export class NotificationService { return; } - const indicesToRemove = new Set(); - await Promise.all(this.deviceIdsToDeliverTo[tuple.toString()].map(async (deviceId, index) => { + const deviceIdsToRemove = new Set(); + for (let deviceId of this.deviceIdsToDeliverTo[tuple.toString()].values()) { const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); if (deliveredSuccessfully) { - indicesToRemove.add(index); + deviceIdsToRemove.add(deviceId); } - })); + } - this.deviceIdsToDeliverTo[tuple.toString()] = this.deviceIdsToDeliverTo[tuple.toString()].filter((_, index) => !indicesToRemove.has(index)); + deviceIdsToRemove.forEach((deviceId) => { + this.deviceIdsToDeliverTo[tuple.toString()].delete(deviceId); + }); } private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { @@ -208,10 +210,9 @@ export class NotificationService { public async scheduleNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { - this.deviceIdsToDeliverTo[tuple.toString()] = [deviceId]; - } else { - this.deviceIdsToDeliverTo[tuple.toString()].push(deviceId); + this.deviceIdsToDeliverTo[tuple.toString()] = new Set(); } + this.deviceIdsToDeliverTo[tuple.toString()].add(deviceId); this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); @@ -227,15 +228,12 @@ export class NotificationService { const tupleKey = new TupleKey(shuttleId, stopId); if ( this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined - || !this.deviceIdsToDeliverTo[tupleKey.toString()].includes(deviceId) + || !this.deviceIdsToDeliverTo[tupleKey.toString()].has(deviceId) ) { return; } - const index = this.deviceIdsToDeliverTo[tupleKey.toString()].findIndex(id => id === deviceId); - if (index !== -1) { - this.deviceIdsToDeliverTo[tupleKey.toString()].splice(index, 1); - } + this.deviceIdsToDeliverTo[tupleKey.toString()].delete(deviceId); } /** @@ -249,6 +247,6 @@ export class NotificationService { if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return false; } - return this.deviceIdsToDeliverTo[tuple.toString()].includes(deviceId); + return this.deviceIdsToDeliverTo[tuple.toString()].has(deviceId); } } From 6d1a85c2b4576242d9182235e25ed932f0e623ee Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 10 Feb 2025 14:02:49 -0800 Subject: [PATCH 13/13] use 403 status for non-successful mock --- .../services/NotificationServiceTests.test.ts | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts index 779c615..9ef66c6 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/services/NotificationServiceTests.test.ts @@ -42,6 +42,26 @@ async function waitForMilliseconds(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function mockHttp2Connect(status: number) { + class MockClient extends EventEmitter { + request = jest.fn((headers: any) => { + const mockRequest: any = new EventEmitter(); + mockRequest.setEncoding = jest.fn(); + mockRequest.write = jest.fn(); + mockRequest.end = jest.fn(() => { + setTimeout(() => { + mockRequest.emit('response', { ':status': status }); + }, 10); + }); + return mockRequest; + }); + + close() {}; + } + + (http2.connect as jest.Mock) = jest.fn(() => new MockClient()); +} + describe("NotificationService", () => { let repository: UnoptimizedInMemoryRepository let notificationService: NotificationService; @@ -63,23 +83,7 @@ describe("NotificationService", () => { }); beforeEach(() => { - class MockClient extends EventEmitter { - request = jest.fn((headers: any) => { - const mockRequest: any = new EventEmitter(); - mockRequest.setEncoding = jest.fn(); - mockRequest.write = jest.fn(); - mockRequest.end = jest.fn(() => { - setTimeout(() => { - mockRequest.emit('response', { ':status': 200 }); - }, 10); - }); - return mockRequest; - }); - - close() {}; - } - - (http2.connect as jest.Mock) = jest.fn(() => new MockClient()); + mockHttp2Connect(200); }); describe("reloadAPNsTokenIfTimePassed", () => { @@ -178,6 +182,7 @@ describe("NotificationService", () => { const shuttle = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop) + mockHttp2Connect(403); // Act await notificationService.scheduleNotification(notificationData1);