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 { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; import EventEmitter = require("node:events"); jest.mock("fs"); jest.mock("http2"); 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)); } 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; 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); }); 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, 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); // 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)); 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; // 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) mockHttp2Connect(403); // 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); 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); 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 () => { // Arrange const shuttle = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); await notificationService.scheduleNotification(notificationData1); // Act await notificationService.cancelNotificationIfExists(notificationData1); await repository.addOrUpdateEta(eta); // Assert await waitForMilliseconds(500); expect(http2.connect as jest.Mock).toHaveBeenCalledTimes(0); }); }); });