diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92650db --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= +APNS_KEY_PATH= diff --git a/package-lock.json b/package-lock.json index acfc543..f9544cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "dependencies": { "@apollo/server": "^4.11.2", - "graphql": "^16.10.0" + "@types/jsonwebtoken": "^9.0.8", + "graphql": "^16.10.0", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "@graphql-codegen/cli": "5.0.3", @@ -3565,6 +3567,15 @@ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.8.tgz", + "integrity": "sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -3575,6 +3586,11 @@ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", @@ -4173,6 +4189,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4863,6 +4884,14 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6895,6 +6924,62 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6981,12 +7066,47 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/package.json b/package.json index 199a457..d698ded 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,13 @@ "@types/node": "^22.10.2", "jest": "^29.7.0", "ts-jest": "^29.2.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "@types/jsonwebtoken": "^9.0.8" }, "private": true, "dependencies": { "@apollo/server": "^4.11.2", - "graphql": "^16.10.0" + "graphql": "^16.10.0", + "jsonwebtoken": "^9.0.2" } } diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts new file mode 100644 index 0000000..66ec81d --- /dev/null +++ b/src/services/NotificationService.ts @@ -0,0 +1,227 @@ +import { GetterRepository } from "../repositories/GetterRepository"; +import jwt from "jsonwebtoken"; +import fs from "fs"; +import { TupleKey } from "../types/TupleKey"; +import { IEta } from "../entities/entities"; + +export interface ScheduledNotificationData { + deviceId: string; + shuttleId: string; + stopId: string; +} + +export class NotificationService { + public readonly secondsThresholdForNotificationToFire = 300; + + private apnsToken: string | undefined = undefined; + + private _lastRefreshedTimeMs: number | undefined = undefined; + get lastRefreshedTimeMs() { + return this._lastRefreshedTimeMs; + } + + constructor(private repository: GetterRepository) { + this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); + this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this); + this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this); + this.sendEtaNotificationImmediately = this.sendEtaNotificationImmediately.bind(this); + this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this); + this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold = this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold.bind(this); + this.scheduleNotification = this.scheduleNotification.bind(this); + } + + /** + * An object of device ID arrays to deliver notifications to. + * The key should be a combination of the shuttle ID and + * stop ID, which can be generated using `TupleKey`. + * @private + */ + private deviceIdsToDeliverTo: { [key: string]: string[] } = {} + + public reloadAPNsTokenIfTimePassed() { + if (this.lastReloadedTimeForAPNsIsTooRecent()) { + return; + } + + const keyId = process.env.APNS_KEY_ID; + const teamId = process.env.APNS_TEAM_ID; + const privateKeyPath = process.env.APNS_KEY_PATH; + if (!privateKeyPath) return; + const privateKey = fs.readFileSync(privateKeyPath); + + const tokenHeader = { + alg: "ES256", + "kid": keyId, + }; + + const now = Date.now(); + const claimsPayload = { + "iss": teamId, + "iat": now, + }; + + this.apnsToken = jwt.sign(claimsPayload, privateKey, { + algorithm: "ES256", + header: tokenHeader + }); + this._lastRefreshedTimeMs = now; + } + + private lastReloadedTimeForAPNsIsTooRecent() { + const thirtyMinutesMs = 1800000; + return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs; + } + + 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); + const eta = await this.repository.getEtaForShuttleAndStopId(shuttleId, stopId); + if (!shuttle) { + console.warn(`Notification ${notificationData} fell through; no associated shuttle`); + return false; + } + if (!stop) { + console.warn(`Notification ${notificationData} fell through; no associated stop`); + return false; + } + + // Notification may not be sent if ETA is unavailable at the moment; + // this is fine because it will be sent again when ETA becomes available + if (!eta) { + console.warn(`Notification ${notificationData} fell through; no associated ETA`); + return false; + } + + // Send the fetch request + const bundleId = process.env.APNS_BUNDLE_ID; + if (typeof bundleId !== "string") { + throw new Error("APNS_BUNDLE_ID environment variable is not set correctly"); + } + + const headers = { + authorization: `bearer ${this.apnsToken}`, + "apns-push-type": "alert", + "apns-expiration": "0", + "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.` + } + } + }), + }); + const json = await response.json(); + + if (response.status !== 200) { + console.error(`Notification failed for device ${deviceId}:`, json.reason); + return false; + } + return true; + } + + public static getAPNsFullUrlToUse(deviceId: string) { + // Construct the fetch request + const devBaseUrl = "https://api.sandbox.push.apple.com" + const prodBaseUrl = "https://api.push.apple.com" + const path = "/3/device/" + deviceId; + + let urlToUse = prodBaseUrl + path; + if (process.env.NODE_ENV !== "production") { + urlToUse = devBaseUrl + path; + } + return urlToUse; + } + + private async etaSubscriberCallback(eta: IEta) { + const tuple = new TupleKey(eta.shuttleId, eta.stopId); + if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { + return; + } + + const indicesToRemove = new Set(); + await Promise.all(this.deviceIdsToDeliverTo[tuple.toString()].map(async (deviceId, index) => { + const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); + if (deliveredSuccessfully) { + indicesToRemove.add(index); + } + })); + + this.deviceIdsToDeliverTo[tuple.toString()] = this.deviceIdsToDeliverTo[tuple.toString()].filter((_, index) => !indicesToRemove.has(index)); + } + + private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { + if (eta.secondsRemaining > this.secondsThresholdForNotificationToFire) { + return false; + } + + return await this.sendEtaNotificationImmediately({ + deviceId, + shuttleId: eta.shuttleId, + stopId: eta.stopId, + }); + } + + /** + * Queue a notification to be sent. + * @param deviceId The device ID to send the notification to. + * @param shuttleId Shuttle ID of ETA object to check. + * @param stopId Stop ID of ETA object to check. + */ + 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.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); + this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); + } + + /** + * Cancel a pending notification. + * @param deviceId The device ID of the notification. + * @param shuttleId Shuttle ID of the ETA object. + * @param stopId Stop ID of the ETA object. + */ + public async cancelNotificationIfExists({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { + const tupleKey = new TupleKey(shuttleId, stopId); + if ( + this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined + || !this.deviceIdsToDeliverTo[tupleKey.toString()].includes(deviceId) + ) { + return; + } + + const index = this.deviceIdsToDeliverTo[tupleKey.toString()].findIndex(id => id === deviceId); + if (index !== -1) { + this.deviceIdsToDeliverTo[tupleKey.toString()].splice(index, 1); + } + } + + /** + * Check whether the notification is scheduled. + * @param deviceId + * @param shuttleId + * @param stopId + */ + public isNotificationScheduled({ deviceId, shuttleId, stopId }: ScheduledNotificationData): boolean { + const tuple = new TupleKey(shuttleId, stopId); + if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { + return false; + } + return this.deviceIdsToDeliverTo[tuple.toString()].includes(deviceId); + } +} diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts new file mode 100644 index 0000000..cf5308a --- /dev/null +++ b/test/services/NotificationServiceTests.test.ts @@ -0,0 +1,226 @@ +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 { IEta, IShuttle, IStop } from "../../src/entities/entities"; +import { resetGlobalFetchMockJson, updateGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers"; +import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; + +jest.mock("fs"); + +const sampleKey = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsrmSAZIagOfCP8sB +Wi2CBXG1Oo7v1bispIZCwIr4RDegCgYIKoZIzj0DAQehRANCAATZHxV2wQJLMBq+ +ya+yfGi3g2ZUv6hrfe+j08ytekPHjXS0qzJoVELzKHa6EL9YAoZDXBtB6h+fGhXe +SOcONbaf +-----END PRIVATE KEY-----` + +/** + * Wait for a condition to become true until the timeout + * is hit. + * @param condition + * @param timeoutMilliseconds + * @param intervalMilliseconds + */ +async function waitForCondition(condition: () => boolean, timeoutMilliseconds = 5000, intervalMilliseconds = 500) { + const startTime = Date.now(); + while (!condition()) { + if (Date.now() - startTime > timeoutMilliseconds) { + throw new Error("Timeout waiting for condition"); + } + await new Promise((resolve) => setTimeout(resolve, intervalMilliseconds)); + } +} + +/** + * Wait for a specified number of milliseconds. + * @param ms + */ +async function waitForMilliseconds(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("NotificationService", () => { + let repository: UnoptimizedInMemoryRepository + let notificationService: NotificationService; + + beforeEach(() => { + repository = new UnoptimizedInMemoryRepository(); + notificationService = new NotificationService(repository); + + // Ensure that tests don't hit the server + process.env = { + ...process.env, + APNS_KEY_ID: "1", + APNS_TEAM_ID: "1", + APNS_KEY_PATH: "./dummy-path.p8", + APNS_BUNDLE_ID: "dev.bchen.ProjectInter" + }; + + (fs.readFileSync as jest.Mock).mockReturnValue(sampleKey); + + resetGlobalFetchMockJson(); + }) + + describe("reloadAPNsTokenIfTimePassed", () => { + it("reloads the token if token hasn't been generated yet", async () => { + notificationService.reloadAPNsTokenIfTimePassed(); + expect(notificationService.lastRefreshedTimeMs).toBeDefined(); + }); + + it("doesn't reload the token if last refreshed time is recent", async () => { + notificationService.reloadAPNsTokenIfTimePassed(); + const lastRefreshedTimeMs = notificationService.lastRefreshedTimeMs; + + notificationService.reloadAPNsTokenIfTimePassed(); + // Expect no change to have occurred + expect(lastRefreshedTimeMs).toEqual(notificationService.lastRefreshedTimeMs); + }); + }) + + function generateNotificationDataAndEta(shuttle: IShuttle, stop: IStop) { + const eta: IEta = { + shuttleId: shuttle.id, + stopId: stop.id, + secondsRemaining: 120, + }; + + const notificationData1 = { + deviceId: "1", + shuttleId: eta.shuttleId, + stopId: eta.stopId, + } + const notificationData2 = { + ...notificationData1, + deviceId: "2", + } + return { eta, notificationData1, notificationData2 }; + } + + describe("scheduleNotification", () => { + it("schedules the notification", async () => { + // arrange + const notificationData = { + deviceId: "1", + shuttleId: "1", + stopId: "1" + }; + + await notificationService.scheduleNotification(notificationData); + + const isNotificationScheduled = notificationService.isNotificationScheduled(notificationData); + expect(isNotificationScheduled).toEqual(true); + }); + + it("sends and clears correct notification after ETA changed", async () => { + // Arrange + const shuttle = await addMockShuttleToRepository(repository, "1"); + const stop = await addMockStopToRepository(repository, "1"); + + const { eta, notificationData1, notificationData2 } = generateNotificationDataAndEta(shuttle, stop); + + // Simulate 200 + empty object for successful push notification + updateGlobalFetchMockJson({}); + + // Act + await notificationService.scheduleNotification(notificationData1); + await notificationService.scheduleNotification(notificationData2); + await repository.addOrUpdateEta(eta); + + // Assert + // Because repository publisher calls subscriber without await + // 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 + expect(isFirstNotificationScheduled).toBe(false); + expect(isSecondNotificationScheduled).toBe(false); + }); + + it("doesn't send notification if seconds threshold not exceeded", async () => { + // Arrange + const shuttle = await addMockShuttleToRepository(repository, "1"); + const stop = await addMockStopToRepository(repository, "1"); + const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); + eta.secondsRemaining = notificationService.secondsThresholdForNotificationToFire + 100; + + updateGlobalFetchMockJson({}); + + // Act + await notificationService.scheduleNotification(notificationData1); + await repository.addOrUpdateEta(eta); + + // Assert + await waitForMilliseconds(500); + const isNotificationScheduled = notificationService.isNotificationScheduled(notificationData1); + expect(isNotificationScheduled).toBe(true); + }); + + it("leaves notification in array if delivery unsuccessful", async () => { + // Arrange + const shuttle = await addMockShuttleToRepository(repository, "1"); + const stop = await addMockStopToRepository(repository, "1"); + const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop) + + updateGlobalFetchMockJson({}, 400); + + // Act + await notificationService.scheduleNotification(notificationData1); + await repository.addOrUpdateEta(eta); + + // Assert + // The notification should stay scheduled to be retried once + // the ETA updates again + await waitForMilliseconds(500); + const isNotificationScheduled = notificationService.isNotificationScheduled(notificationData1); + expect(isNotificationScheduled).toBe(true); + }); + }); + + describe('getAPNsFullUrlToUse', () => { + it('should return the production URL when NODE_ENV is set to "production"', () => { + process.env.NODE_ENV = 'production'; + const deviceId = 'testDeviceId'; + const result = NotificationService.getAPNsFullUrlToUse(deviceId); + expect(result).toBe(`https://api.push.apple.com/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}`); + }); + }); + + describe("cancelNotification", () => { + it("stops notification from sending to given shuttle/stop ID", async () => { + // Arrange + const shuttle = await addMockShuttleToRepository(repository, "1"); + const stop = await addMockStopToRepository(repository, "1"); + const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); + + updateGlobalFetchMockJson({}); + + await notificationService.scheduleNotification(notificationData1); + + // Act + await notificationService.cancelNotificationIfExists(notificationData1); + await repository.addOrUpdateEta(eta); + + // Assert + await waitForMilliseconds(500); + expect(fetch as jest.Mock).toHaveBeenCalledTimes(0); + }); + }); +});