diff --git a/src/ServerContext.ts b/src/ServerContext.ts index 6579453..8dade11 100644 --- a/src/ServerContext.ts +++ b/src/ServerContext.ts @@ -1,7 +1,7 @@ -import { NotificationService } from "./services/NotificationService"; +import { ETANotificationScheduler } from "./notifications/schedulers/ETANotificationScheduler"; import { GetterSetterRepository } from "./repositories/GetterSetterRepository"; export interface ServerContext { repository: GetterSetterRepository; - notificationService: NotificationService; + notificationService: ETANotificationScheduler; } diff --git a/src/index.ts b/src/index.ts index b3376c9..88f4c61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,10 @@ import { MergedResolvers } from "./MergedResolvers"; import { ServerContext } from "./ServerContext"; import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository"; import { TimedApiBasedRepositoryLoader } from "./loaders/TimedApiBasedRepositoryLoader"; -import { NotificationService } from "./services/NotificationService"; +import { ETANotificationScheduler } from "./notifications/schedulers/ETANotificationScheduler"; import { configDotenv } from "dotenv"; import { loadTestData } from "./loaders/loadTestData"; +import { AppleNotificationSender } from "./notifications/senders/AppleNotificationSender"; configDotenv(); @@ -21,19 +22,19 @@ async function main() { }); const repository = new UnoptimizedInMemoryRepository(); - let notificationService: NotificationService; + let notificationService: ETANotificationScheduler; if (process.argv.length > 2 && process.argv[2] == "integration-testing") { console.log("Using integration testing setup") await loadTestData(repository); - notificationService = new NotificationService(repository, false); + const appleNotificationSender = new AppleNotificationSender(false); + notificationService = new ETANotificationScheduler(repository, appleNotificationSender); } else { const repositoryDataUpdater = new TimedApiBasedRepositoryLoader( repository ); await repositoryDataUpdater.start(); - notificationService = new NotificationService(repository); + notificationService = new ETANotificationScheduler(repository); } - notificationService.reloadAPNsTokenIfTimePassed(); const { url } = await startStandaloneServer(server, { listen: { diff --git a/src/services/NotificationService.ts b/src/notifications/schedulers/ETANotificationScheduler.ts similarity index 58% rename from src/services/NotificationService.ts rename to src/notifications/schedulers/ETANotificationScheduler.ts index bb15e46..4ccf110 100644 --- a/src/services/NotificationService.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -1,9 +1,7 @@ -import { GetterRepository } from "../repositories/GetterRepository"; -import jwt from "jsonwebtoken"; -import fs from "fs"; -import { TupleKey } from "../types/TupleKey"; -import { IEta } from "../entities/entities"; -import http2 from "http2"; +import { GetterRepository } from "../../repositories/GetterRepository"; +import { TupleKey } from "../../types/TupleKey"; +import { IEta } from "../../entities/entities"; +import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; export interface ScheduledNotificationData { deviceId: string; @@ -11,26 +9,13 @@ export interface ScheduledNotificationData { stopId: string; } -interface APNsUrl { - fullUrl: string; - path: string; - host: string; -} - -export class NotificationService { +export class ETANotificationScheduler { public readonly secondsThresholdForNotificationToFire = 180; - private apnsToken: string | undefined = undefined; - - private _lastRefreshedTimeMs: number | undefined = undefined; - get lastRefreshedTimeMs() { - return this._lastRefreshedTimeMs; - } - - constructor(private repository: GetterRepository, private shouldActuallySendNotifications = true) { + constructor(private repository: GetterRepository, + private appleNotificationSender = new AppleNotificationSender() + ) { 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); @@ -45,48 +30,8 @@ export class NotificationService { */ private deviceIdsToDeliverTo: { [key: string]: Set } = {} - public reloadAPNsTokenIfTimePassed() { - if (this.lastReloadedTimeForAPNsIsTooRecent()) { - return; - } - - const keyId = process.env.APNS_KEY_ID; - const teamId = process.env.APNS_TEAM_ID; - - const privateKeyBase64 = process.env.APNS_PRIVATE_KEY; - if (!privateKeyBase64) return; - const privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); - - const tokenHeader = { - alg: "ES256", - "kid": keyId, - }; - - const nowMs = Date.now(); - const claimsPayload = { - "iss": teamId, - "iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch - }; - - this.apnsToken = jwt.sign(claimsPayload, privateKey, { - algorithm: "ES256", - header: tokenHeader - }); - this._lastRefreshedTimeMs = nowMs; - } - - private lastReloadedTimeForAPNsIsTooRecent() { - const thirtyMinutesMs = 1800000; - return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs; - } - private async sendEtaNotificationImmediately(notificationData: ScheduledNotificationData): Promise { - if (!this.shouldActuallySendNotifications) { - return true; // pretend that the notification sent - } - const { deviceId, shuttleId, stopId } = notificationData; - this.reloadAPNsTokenIfTimePassed(); const shuttle = await this.repository.getShuttleById(shuttleId); const stop = await this.repository.getStopById(stopId); @@ -107,72 +52,11 @@ export class NotificationService { 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 notificationAlertArguments: NotificationAlertArguments = { + title: "Shuttle is arriving", + body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.`, } - - const { path, host } = NotificationService.getAPNsFullUrlToUse(deviceId); - - const headers = { - ':method': 'POST', - ':path': path, - 'authorization': `bearer ${this.apnsToken}`, - "apns-push-type": "alert", - "apns-expiration": "0", - "apns-priority": "10", - "apns-topic": bundleId, - }; - try { - const client = http2.connect(host); - const req = client.request(headers); - req.setEncoding('utf8'); - - await new Promise((resolve, reject) => { - req.on('response', (headers, flags) => { - if (headers[":status"] !== 200) { - reject(`APNs request failed with status ${headers[":status"]}`); - } - 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(); - }); - return true; - } catch(e) { - console.error(e); - return false; - } - } - - 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" - - let hostToUse = devBaseUrl; - if (process.env.APNS_IS_PRODUCTION === "1") { - hostToUse = prodBaseUrl; - } - - const path = "/3/device/" + deviceId; - const fullUrl = hostToUse + path; - - const constructedObject = { - fullUrl, - host: hostToUse, - path, - } - return constructedObject; + return this.appleNotificationSender.sendNotificationImmediately(deviceId, notificationAlertArguments); } private async etaSubscriberCallback(eta: IEta) { diff --git a/src/notifications/senders/AppleNotificationSender.ts b/src/notifications/senders/AppleNotificationSender.ts new file mode 100644 index 0000000..80c6f2a --- /dev/null +++ b/src/notifications/senders/AppleNotificationSender.ts @@ -0,0 +1,141 @@ +import jwt from "jsonwebtoken"; +import http2 from "http2"; + +interface APNsUrl { + fullUrl: string; + path: string; + host: string; +} + +export interface NotificationAlertArguments { + title: string; + body: string; +} + +export class AppleNotificationSender { + private apnsToken: string | undefined = undefined; + private _lastRefreshedTimeMs: number | undefined = undefined; + + constructor(private shouldActuallySendNotifications = true) { + } + + get lastRefreshedTimeMs(): number | undefined { + return this._lastRefreshedTimeMs; + } + + private lastReloadedTimeForAPNsIsTooRecent() { + const thirtyMinutesMs = 1800000; + return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs; + } + + public reloadAPNsTokenIfTimePassed() { + if (this.lastReloadedTimeForAPNsIsTooRecent()) { + return; + } + + const keyId = process.env.APNS_KEY_ID; + const teamId = process.env.APNS_TEAM_ID; + + const privateKeyBase64 = process.env.APNS_PRIVATE_KEY; + if (!privateKeyBase64) return; + const privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); + + const tokenHeader = { + alg: "ES256", + "kid": keyId, + }; + + const nowMs = Date.now(); + const claimsPayload = { + "iss": teamId, + "iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch + }; + + this.apnsToken = jwt.sign(claimsPayload, privateKey, { + algorithm: "ES256", + header: tokenHeader + }); + this._lastRefreshedTimeMs = nowMs; + } + + /** + * Send a notification immediately. + * @param deviceId + * @param notificationAlertArguments + * + * @return Boolean promise indicating whether the + * notification was sent successfully. + */ + public async sendNotificationImmediately(deviceId: string, notificationAlertArguments: NotificationAlertArguments) { + if (!this.shouldActuallySendNotifications) { + // pretend that the notification sent + return true; + } + + this.reloadAPNsTokenIfTimePassed(); + + const bundleId = process.env.APNS_BUNDLE_ID; + if (typeof bundleId !== "string") { + throw new Error("APNS_BUNDLE_ID environment variable is not set correctly"); + } + + const { path, host } = AppleNotificationSender.getAPNsFullUrlToUse(deviceId); + + const headers = { + ':method': 'POST', + ':path': path, + 'authorization': `bearer ${this.apnsToken}`, + "apns-push-type": "alert", + "apns-expiration": "0", + "apns-priority": "10", + "apns-topic": bundleId, + }; + try { + const client = http2.connect(host); + const req = client.request(headers); + req.setEncoding('utf8'); + + await new Promise((resolve, reject) => { + req.on('response', (headers, flags) => { + if (headers[":status"] !== 200) { + reject(`APNs request failed with status ${headers[":status"]}`); + } + resolve(); + }); + + req.write(JSON.stringify({ + aps: { + alert: notificationAlertArguments, + } + })); + req.end(); + }); + return true; + } catch(e) { + console.error(e); + return false; + } + } + + 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" + + let hostToUse = devBaseUrl; + if (process.env.APNS_IS_PRODUCTION === "1") { + hostToUse = prodBaseUrl; + } + + const path = "/3/device/" + deviceId; + const fullUrl = hostToUse + path; + + const constructedObject = { + fullUrl, + host: hostToUse, + path, + } + return constructedObject; + } + +} diff --git a/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts b/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts index 67b77e5..449c5c6 100644 --- a/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts +++ b/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts @@ -2094,9 +2094,9 @@ export const fetchSystemDataSuccessfulResponse = { "logo": 1, "goRoutePlannerEnabled": "0", "goColor": null, - "goSupportEmail": "transit.services@pepperdine.edu", + "goSupportEmail": "transit.schedulers@pepperdine.edu", "goAuthenticationType": "0", - "email": "transit.services@pepperdine.edu" + "email": "transit.schedulers@pepperdine.edu" }, { "fullname": "Perimeter Summit Shuttle", @@ -3347,4 +3347,4 @@ export const fetchSystemDataSuccessfulResponse = { "fromCache": 1, "myip": "206.211.154.147" } -}; \ No newline at end of file +}; diff --git a/test/services/NotificationServiceTests.test.ts b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts similarity index 64% rename from test/services/NotificationServiceTests.test.ts rename to test/notifications/schedulers/ETANotificationSchedulerTests.test.ts index 8517727..60262c8 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts @@ -1,14 +1,19 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { NotificationService } from "../../src/services/NotificationService"; -import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository"; +import { ETANotificationScheduler } from "../../../src/notifications/schedulers/ETANotificationScheduler"; +import { UnoptimizedInMemoryRepository } from "../../../src/repositories/UnoptimizedInMemoryRepository"; import http2 from "http2"; -import { IEta, IShuttle, IStop } from "../../src/entities/entities"; -import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; -import EventEmitter = require("node:events"); +import { IEta, IShuttle, IStop } from "../../../src/entities/entities"; +import { addMockShuttleToRepository, addMockStopToRepository } from "../../testHelpers/repositorySetupHelpers"; +import { AppleNotificationSender } from "../../../src/notifications/senders/AppleNotificationSender"; jest.mock("http2"); +jest.mock("../../../src/notifications/senders/AppleNotificationSender"); -const sampleKeyBase64 = "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa3dkd0lCQVFRZ3NybVNBWklhZ09mQ1A4c0IKV2kyQ0JYRzFPbzd2MWJpc3BJWkN3SXI0UkRlZ0NnWUlLb1pJemowREFRZWhSQU5DQUFUWkh4VjJ3UUpMTUJxKwp5YSt5ZkdpM2cyWlV2NmhyZmUrajA4eXRla1BIalhTMHF6Sm9WRUx6S0hhNkVMOVlBb1pEWEJ0QjZoK2ZHaFhlClNPY09OYmFmCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"; +const MockAppleNotificationSender = AppleNotificationSender as jest.MockedClass; + +function mockNotificationSenderMethods(shouldSimulateNotificationSend: boolean) { + MockAppleNotificationSender.prototype.sendNotificationImmediately = jest.fn(async () => shouldSimulateNotificationSend); +} /** * Wait for a condition to become true until the timeout @@ -35,64 +40,20 @@ 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", () => { +describe("ETANotificationScheduler", () => { let repository: UnoptimizedInMemoryRepository - let notificationService: NotificationService; + let notificationService: ETANotificationScheduler; 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_BUNDLE_ID: "dev.bchen.ProjectInter", - APNS_PRIVATE_KEY: sampleKeyBase64, - }; + mockNotificationSenderMethods(true); + + const appleNotificationSender = new MockAppleNotificationSender(false); + notificationService = new ETANotificationScheduler(repository, appleNotificationSender); }); - beforeEach(() => { - mockHttp2Connect(200); - }); - - 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, @@ -173,7 +134,12 @@ describe("NotificationService", () => { const shuttle = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop) - mockHttp2Connect(403); + + mockNotificationSenderMethods(false); + notificationService = new ETANotificationScheduler( + repository, + new MockAppleNotificationSender(), + ) // Act await notificationService.scheduleNotification(notificationData1); @@ -188,29 +154,6 @@ describe("NotificationService", () => { }); }); - describe('getAPNsFullUrlToUse', () => { - it('should return the production URL when APNS_IS_PRODUCTION is set to "1"', () => { - process.env.APNS_IS_PRODUCTION = "1"; - const deviceId = 'testDeviceId'; - const result = NotificationService.getAPNsFullUrlToUse(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 APNS_IS_PRODUCTION is set to something other than 1', () => { - process.env.APNS_IS_PRODUCTION = "0"; - const deviceId = 'testDeviceId'; - const result = NotificationService.getAPNsFullUrlToUse(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}`); - }); - }); describe("cancelNotification", () => { it("stops notification from sending to given shuttle/stop ID", async () => { @@ -236,7 +179,7 @@ describe("NotificationService", () => { // Arrange const shuttle1 = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); - const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle1, stop); + const { notificationData1 } = generateNotificationDataAndEta(shuttle1, stop); await notificationService.scheduleNotification(notificationData1); const shuttle2 = { diff --git a/test/notifications/senders/AppleNotificationSenderTests.test.ts b/test/notifications/senders/AppleNotificationSenderTests.test.ts new file mode 100644 index 0000000..9cbc0cb --- /dev/null +++ b/test/notifications/senders/AppleNotificationSenderTests.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import http2 from "http2"; +import { EventEmitter } from "node:events"; +import { + AppleNotificationSender, + NotificationAlertArguments +} from "../../../src/notifications/senders/AppleNotificationSender"; +import { ETANotificationScheduler } from "../../../src/notifications/schedulers/ETANotificationScheduler"; + +jest.mock("http2"); + +const sampleKeyBase64 = "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa3dkd0lCQVFRZ3NybVNBWklhZ09mQ1A4c0IKV2kyQ0JYRzFPbzd2MWJpc3BJWkN3SXI0UkRlZ0NnWUlLb1pJemowREFRZWhSQU5DQUFUWkh4VjJ3UUpMTUJxKwp5YSt5ZkdpM2cyWlV2NmhyZmUrajA4eXRla1BIalhTMHF6Sm9WRUx6S0hhNkVMOVlBb1pEWEJ0QjZoK2ZHaFhlClNPY09OYmFmCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"; + +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("AppleNotificationSender", () => { + let notificationSender: AppleNotificationSender; + + beforeEach(() => { + notificationSender = new AppleNotificationSender(); + + // Ensure that tests don't hit the server + process.env = { + ...process.env, + APNS_KEY_ID: "1", + APNS_TEAM_ID: "1", + APNS_BUNDLE_ID: "dev.bchen.ProjectInter", + APNS_PRIVATE_KEY: sampleKeyBase64, + }; + }); + + beforeEach(() => { + mockHttp2Connect(200); + }); + + describe("reloadAPNsTokenIfTimePassed", () => { + it("reloads the token if token hasn't been generated yet", async () => { + notificationSender.reloadAPNsTokenIfTimePassed(); + expect(notificationSender.lastRefreshedTimeMs).toBeDefined(); + }); + + it("doesn't reload the token if last refreshed time is recent", async () => { + notificationSender.reloadAPNsTokenIfTimePassed(); + const lastRefreshedTimeMs = notificationSender.lastRefreshedTimeMs; + + notificationSender.reloadAPNsTokenIfTimePassed(); + // Expect no change to have occurred + expect(lastRefreshedTimeMs).toEqual(notificationSender.lastRefreshedTimeMs); + }); + }); + + describe('getAPNsFullUrlToUse', () => { + it('should return the production URL when APNS_IS_PRODUCTION is set to "1"', () => { + process.env.APNS_IS_PRODUCTION = "1"; + const deviceId = 'testDeviceId'; + const result = AppleNotificationSender.getAPNsFullUrlToUse(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 APNS_IS_PRODUCTION is set to something other than 1', () => { + process.env.APNS_IS_PRODUCTION = "0"; + const deviceId = 'testDeviceId'; + const result = AppleNotificationSender.getAPNsFullUrlToUse(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}`); + }); + }); + + describe("sendNotificationImmediately", () => { + it('makes the connection to the http server if the notification should be sent', async () => { + const notificationArguments: NotificationAlertArguments = { + title: 'Test notification', + body: 'This notification will send', + } + + const result = await notificationSender.sendNotificationImmediately('1', notificationArguments); + + expect(http2.connect).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('throws an error if the bundle ID is not set correctly', async () => { + process.env = { + ...process.env, + APNS_BUNDLE_ID: undefined, + } + const notificationArguments: NotificationAlertArguments = { + title: 'Test notification', + body: 'This notification will not send', + } + + await expect(async () => { + await notificationSender.sendNotificationImmediately('1', notificationArguments); + }).rejects.toThrow(); + }); + + it('returns false if there is an error sending the notification', async () => { + mockHttp2Connect(403); + + const notificationArguments: NotificationAlertArguments = { + title: 'Test notification', + body: 'This notification will not send', + } + + const result = await notificationSender.sendNotificationImmediately('1', notificationArguments); + + expect(http2.connect).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('does not send notification if shouldActuallySendNotifications is false', async () => { + notificationSender = new AppleNotificationSender(false); + + const notificationArguments: NotificationAlertArguments = { + title: 'Test notification', + body: 'This notification should not send', + } + + const result = await notificationSender.sendNotificationImmediately('1', notificationArguments); + + expect(http2.connect).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); +}); diff --git a/test/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index bb53d5b..8b0b60e 100644 --- a/test/resolvers/QueryResolverTests.test.ts +++ b/test/resolvers/QueryResolverTests.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { generateMockSystems } from "../testHelpers/mockDataGenerators"; import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers"; import assert = require("node:assert"); -import { ScheduledNotificationData } from "../../src/services/NotificationService"; +import { ScheduledNotificationData } from "../../src/notifications/schedulers/ETANotificationScheduler"; import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; // See Apollo documentation for integration test guide diff --git a/test/testHelpers/apolloTestServerHelpers.ts b/test/testHelpers/apolloTestServerHelpers.ts index a8af9d4..f9fa2d9 100644 --- a/test/testHelpers/apolloTestServerHelpers.ts +++ b/test/testHelpers/apolloTestServerHelpers.ts @@ -4,7 +4,7 @@ import { MergedResolvers } from "../../src/MergedResolvers"; import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository"; import { beforeEach } from "@jest/globals"; import { ServerContext } from "../../src/ServerContext"; -import { NotificationService } from "../../src/services/NotificationService"; +import { ETANotificationScheduler } from "../../src/notifications/schedulers/ETANotificationScheduler"; function setUpTestServer() { @@ -26,7 +26,7 @@ export function setupTestServerContext() { beforeEach(() => { context.repository = new UnoptimizedInMemoryRepository(); - context.notificationService = new NotificationService(context.repository); + context.notificationService = new ETANotificationScheduler(context.repository); }); return context as ServerContext;