diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index abd045f..d1a33c4 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -1,13 +1,12 @@ -import { ShuttleRepositoryLoader } from "../loaders/ShuttleRepositoryLoader"; import { ETANotificationScheduler } from "../notifications/schedulers/ETANotificationScheduler"; -import { TimedApiBasedShuttleRepositoryLoader } from "../loaders/TimedApiBasedShuttleRepositoryLoader"; +import { TimedApiBasedRepositoryLoader } from "../loaders/TimedApiBasedRepositoryLoader"; import { UnoptimizedInMemoryShuttleRepository } from "../repositories/UnoptimizedInMemoryShuttleRepository"; import { RedisNotificationRepository } from "../repositories/RedisNotificationRepository"; import { NotificationRepository } from "../repositories/NotificationRepository"; import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSetterRepository"; import { InMemoryNotificationRepository } from "../repositories/InMemoryNotificationRepository"; import { AppleNotificationSender } from "../notifications/senders/AppleNotificationSender"; -import { ApiBasedShuttleRepositoryLoader } from "../loaders/ApiBasedShuttleRepositoryLoader"; +import { ApiBasedShuttleRepositoryLoader } from "../loaders/shuttle/ApiBasedShuttleRepositoryLoader"; export interface InterchangeSystemBuilderArguments { name: string; @@ -27,7 +26,7 @@ export class InterchangeSystem { constructor( public name: string, public id: string, - public shuttleDataLoader: ShuttleRepositoryLoader, + public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader, public shuttleRepository: ShuttleGetterSetterRepository, public notificationScheduler: ETANotificationScheduler, public notificationRepository: NotificationRepository, @@ -43,12 +42,15 @@ export class InterchangeSystem { args: InterchangeSystemBuilderArguments, ) { const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); - const shuttleDataLoader = new TimedApiBasedShuttleRepositoryLoader( + const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( args.passioSystemId, args.id, shuttleRepository ); - await shuttleDataLoader.start(); + const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader, + ); + await timedShuttleDataLoader.start(); const notificationRepository = new RedisNotificationRepository(); await notificationRepository.connect(); @@ -62,7 +64,7 @@ export class InterchangeSystem { return new InterchangeSystem( args.name, args.id, - shuttleDataLoader, + timedShuttleDataLoader, shuttleRepository, notificationScheduler, notificationRepository, @@ -84,6 +86,11 @@ export class InterchangeSystem { args.id, shuttleRepository ); + // Note that this loader should not be started, + // so the test data doesn't get overwritten + const timedShuttleLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader, + ); const notificationRepository = new InMemoryNotificationRepository(); const notificationScheduler = new ETANotificationScheduler( @@ -96,7 +103,7 @@ export class InterchangeSystem { return new InterchangeSystem( args.name, args.id, - shuttleDataLoader, + timedShuttleLoader, shuttleRepository, notificationScheduler, notificationRepository, diff --git a/src/index.ts b/src/index.ts index a370258..00cd04b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; import { MergedResolvers } from "./MergedResolvers"; import { ServerContext } from "./ServerContext"; -import { loadShuttleTestData, supportedIntegrationTestSystems } from "./loaders/loadShuttleTestData"; +import { loadShuttleTestData, supportedIntegrationTestSystems } from "./loaders/shuttle/loadShuttleTestData"; import { InterchangeSystem, InterchangeSystemBuilderArguments } from "./entities/InterchangeSystem"; const typeDefs = readFileSync("./schema.graphqls", "utf8"); diff --git a/src/loaders/ApiResponseError.ts b/src/loaders/ApiResponseError.ts new file mode 100644 index 0000000..5db1f99 --- /dev/null +++ b/src/loaders/ApiResponseError.ts @@ -0,0 +1,6 @@ +export class ApiResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiResponseError"; + } +} diff --git a/src/loaders/RepositoryLoader.ts b/src/loaders/RepositoryLoader.ts new file mode 100644 index 0000000..668aa26 --- /dev/null +++ b/src/loaders/RepositoryLoader.ts @@ -0,0 +1,3 @@ +export interface RepositoryLoader { + fetchAndUpdateAll(): Promise; +} diff --git a/src/loaders/TimedApiBasedRepositoryLoader.ts b/src/loaders/TimedApiBasedRepositoryLoader.ts new file mode 100644 index 0000000..8daf3be --- /dev/null +++ b/src/loaders/TimedApiBasedRepositoryLoader.ts @@ -0,0 +1,43 @@ +import { RepositoryLoader } from "./RepositoryLoader"; + +// To break down timed loading in the future: +// Add flags to the repository indicating which data users are subscribed to +// In the loader's `fetchAll` method, check flags and update only needed data + +export class TimedApiBasedRepositoryLoader { + private shouldBeRunning: boolean = false; + private timer: any; + + constructor( + public loader: RepositoryLoader, + public readonly timeoutMs: number = 10000, + ) { + this.startFetchDataAndUpdate = this.startFetchDataAndUpdate.bind(this); + } + + public async start() { + if (this.shouldBeRunning) { + console.warn("DataLoader timer is already running"); + return; + } + + this.shouldBeRunning = true; + await this.startFetchDataAndUpdate(); + } + + public stop() { + this.shouldBeRunning = false; + } + + private async startFetchDataAndUpdate() { + if (!this.shouldBeRunning) return; + + try { + await this.loader.fetchAndUpdateAll(); + } catch (e) { + console.error(e); + } + + this.timer = setTimeout(this.startFetchDataAndUpdate, this.timeoutMs); + } +} diff --git a/src/loaders/TimedApiBasedShuttleRepositoryLoader.ts b/src/loaders/TimedApiBasedShuttleRepositoryLoader.ts deleted file mode 100644 index 15a7d59..0000000 --- a/src/loaders/TimedApiBasedShuttleRepositoryLoader.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSetterRepository"; -import { ApiBasedShuttleRepositoryLoader } from "./ApiBasedShuttleRepositoryLoader"; - -// Ideas to break this into smaller pieces in the future: -// Have one repository data loader running for each supported system -// Each data loader independently updates data based on frequency of usage - -// Notes on this: we only need to reload ETA data frequently -// Other data can be reloaded periodically -// Detailed list: -// - ETA: reload frequently or switch to write-through approach -// - Shuttles: reload every minute -// - Routes: reload every few minutes -// - Stops: reload every few minutes -// - OrderedStops: reload every few minutes -// - Systems: reload once a day - -export class TimedApiBasedShuttleRepositoryLoader extends ApiBasedShuttleRepositoryLoader { - private shouldBeRunning: boolean = false; - private timer: any; - - readonly timeout = 10000; - - constructor( - public passioSystemId: string, - public systemIdForConstructedData: string, - repository: ShuttleGetterSetterRepository, - ) { - super(passioSystemId, systemIdForConstructedData, repository); - this.startFetchDataAndUpdate = this.startFetchDataAndUpdate.bind(this); - } - - public async start() { - if (this.shouldBeRunning) { - console.warn("DataLoader timer is already running"); - return; - } - - this.shouldBeRunning = true; - await this.startFetchDataAndUpdate(); - } - - public stop() { - this.shouldBeRunning = false; - } - - private async startFetchDataAndUpdate() { - if (!this.shouldBeRunning) return; - - try { - await this.fetchAndUpdateRouteDataForSystem(); - await this.fetchAndUpdateStopAndPolylineDataForRoutesInSystem(); - await this.fetchAndUpdateShuttleDataForSystem(); - - // Because ETA method doesn't support pruning yet, - // add a call to the clear method here - await this.repository.clearEtaData(); - await this.fetchAndUpdateEtaDataForExistingStopsForSystem(); - } catch (e) { - console.error(e); - } - - this.timer = setTimeout(this.startFetchDataAndUpdate, this.timeout); - } -} diff --git a/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts new file mode 100644 index 0000000..f922c5b --- /dev/null +++ b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts @@ -0,0 +1,83 @@ +import { ParkingRepositoryLoader } from "./ParkingRepositoryLoader"; +import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; +import { createHash } from "node:crypto"; +import { ApiResponseError } from "../ApiResponseError"; +import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; + +class ApiParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiParseError"; + } +} + +export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepositoryLoader { + public static readonly id = "chapman-parking-loader"; + private readonly fetchUrl = "https://webfarm.chapman.edu/ParkingService/ParkingService/counts"; + + constructor( + public repository: ParkingGetterSetterRepository + ) { + this.fetchAndUpdateParkingStructures = this.fetchAndUpdateParkingStructures.bind(this); + } + + async fetchAndUpdateAll() { + await this.fetchAndUpdateParkingStructures(); + } + + async fetchAndUpdateParkingStructures(): Promise { + let json: any; + + try { + const response = await fetch(this.fetchUrl); + json = await response.json(); + } catch(e: any) { + throw new ApiResponseError(e.message); + } + + try { + if (typeof json.Structures === "object") { + const parkingStructures: IParkingStructure[] = json.Structures.map(this.constructIParkingStructureFromJson); + + await Promise.all(parkingStructures.map(async (structure: IParkingStructure) => { + await this.repository.addOrUpdateParkingStructure(structure); + })); + } + } catch(e: any) { + throw new ApiParseError(e.message); + } + } + + private constructIParkingStructureFromJson(jsonStructure: any) { + const structureToReturn: IParkingStructure = { + capacity: jsonStructure.Capacity, + coordinates: { + latitude: jsonStructure.Latitude, + longitude: jsonStructure.Longitude, + }, + id: ChapmanApiBasedParkingRepositoryLoader.generateId(jsonStructure.Address), + name: jsonStructure.Name, + spotsAvailable: jsonStructure.CurrentCount, + address: jsonStructure.Address + } + + return structureToReturn; + } + + private static normalizeAddress(address: string): string { + return address + .toLowerCase() + .split(/\s+/) + .filter(part => part.length > 0) + .join(' '); + } + + public static generateId(address: string): string { + const normalized = this.normalizeAddress(address); + const hash = createHash('sha256') + .update(normalized) + .digest('hex'); + + return hash.substring(0, 32); + } +} diff --git a/src/loaders/parking/ParkingRepositoryLoader.ts b/src/loaders/parking/ParkingRepositoryLoader.ts new file mode 100644 index 0000000..9068221 --- /dev/null +++ b/src/loaders/parking/ParkingRepositoryLoader.ts @@ -0,0 +1,5 @@ +import { RepositoryLoader } from "../RepositoryLoader"; + +export interface ParkingRepositoryLoader extends RepositoryLoader { + fetchAndUpdateParkingStructures(): Promise; +} diff --git a/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts b/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts new file mode 100644 index 0000000..cea713b --- /dev/null +++ b/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts @@ -0,0 +1,15 @@ +import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; +import { ChapmanApiBasedParkingRepositoryLoader } from "./ChapmanApiBasedParkingRepositoryLoader"; + +interface ParkingRepositoryBuilderArguments { + id: string; + repository: ParkingGetterSetterRepository; +} + +export function buildParkingRepositoryLoaderIfExists(args: ParkingRepositoryBuilderArguments) { + if (args.id === ChapmanApiBasedParkingRepositoryLoader.id) { + return new ChapmanApiBasedParkingRepositoryLoader(args.repository); + } + + return null; +} diff --git a/src/loaders/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts similarity index 93% rename from src/loaders/ApiBasedShuttleRepositoryLoader.ts rename to src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index ff145cf..062ff43 100644 --- a/src/loaders/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -1,14 +1,8 @@ -import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSetterRepository"; -import { IEta, IRoute, IShuttle, IStop } from "../entities/ShuttleRepositoryEntities"; +import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository"; +import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; -import { IEntityWithId } from "../entities/SharedEntities"; - -export class ApiResponseError extends Error { - constructor(message: string) { - super(message); - this.name = "ApiResponseError"; - } -} +import { IEntityWithId } from "../../entities/SharedEntities"; +import { ApiResponseError } from "../ApiResponseError"; /** * Class which can load data into a repository from the @@ -34,6 +28,17 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader return ids; } + public async fetchAndUpdateAll() { + await this.fetchAndUpdateRouteDataForSystem(); + await this.fetchAndUpdateStopAndPolylineDataForRoutesInSystem(); + await this.fetchAndUpdateShuttleDataForSystem(); + + // Because ETA method doesn't support pruning yet, + // add a call to the clear method here + await this.repository.clearEtaData(); + await this.fetchAndUpdateEtaDataForExistingStopsForSystem(); + } + public async fetchAndUpdateRouteDataForSystem() { const systemId = this.passioSystemId; const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => { diff --git a/src/loaders/ShuttleRepositoryLoader.ts b/src/loaders/shuttle/ShuttleRepositoryLoader.ts similarity index 71% rename from src/loaders/ShuttleRepositoryLoader.ts rename to src/loaders/shuttle/ShuttleRepositoryLoader.ts index 588d06f..382c97e 100644 --- a/src/loaders/ShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ShuttleRepositoryLoader.ts @@ -1,4 +1,6 @@ -export interface ShuttleRepositoryLoader { +import { RepositoryLoader } from "../RepositoryLoader"; + +export interface ShuttleRepositoryLoader extends RepositoryLoader { fetchAndUpdateRouteDataForSystem(): Promise; fetchAndUpdateStopAndPolylineDataForRoutesInSystem(): Promise; fetchAndUpdateShuttleDataForSystem(): Promise; diff --git a/src/loaders/loadShuttleTestData.ts b/src/loaders/shuttle/loadShuttleTestData.ts similarity index 99% rename from src/loaders/loadShuttleTestData.ts rename to src/loaders/shuttle/loadShuttleTestData.ts index b97c99d..c7603bf 100644 --- a/src/loaders/loadShuttleTestData.ts +++ b/src/loaders/shuttle/loadShuttleTestData.ts @@ -1,7 +1,7 @@ // Mock data -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../entities/ShuttleRepositoryEntities"; -import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSetterRepository"; -import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository"; +import { InterchangeSystemBuilderArguments } from "../../entities/InterchangeSystem"; export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [ { diff --git a/test/jsonSnapshots/chapmanParkingStructureData/chapmanParkingStructureData.ts b/test/jsonSnapshots/chapmanParkingStructureData/chapmanParkingStructureData.ts new file mode 100644 index 0000000..07199d7 --- /dev/null +++ b/test/jsonSnapshots/chapmanParkingStructureData/chapmanParkingStructureData.ts @@ -0,0 +1,92 @@ +export const chapmanParkingStructureData = { + "CenterOnLatitude": 33.793379, + "CenterOnLongitude": -117.853099, + "LatitudeZoom": 0.0045, + "LongitudeZoom": 0.0045, + "Structures": [ + { + "Address": "300 E Walnut, Orange, CA 92867", + "Capacity": 871, + "CurrentCount": 211, + "HdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/lastinger-hdpi.png", + "Latitude": 33.7945513, + "LdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/lastinger-ldpi.png", + "Levels": [ + { + "Capacity": 871, + "CurrentCount": 211, + "FriendlyName": "All Levels", + "SystemName": "All" + }, + { + "Capacity": 401, + "CurrentCount": 26, + "FriendlyName": "B1 Level", + "SystemName": "L1" + }, + { + "Capacity": 470, + "CurrentCount": 185, + "FriendlyName": "B2 Level", + "SystemName": "L2" + } + ], + "Longitude": -117.8518707, + "MdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/lastinger-mdpi.png", + "Name": "Anderson Structure", + "Timestamp": "/Date(1744327687147-0700)/", + "XhdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/lastinger-xhdpi.png" + }, + { + "Address": "200 W Sycamore Ave, Orange, CA 92866-1053", + "Capacity": 692, + "CurrentCount": 282, + "HdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/barrera-hdpi.png", + "Latitude": 33.792937, + "LdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/barrera-ldpi.png", + "Levels": [ + { + "Capacity": 692, + "CurrentCount": 282, + "FriendlyName": "All Levels", + "SystemName": "All" + }, + { + "Capacity": 85, + "CurrentCount": 0, + "FriendlyName": "Level 1", + "SystemName": "L1" + }, + { + "Capacity": 145, + "CurrentCount": 21, + "FriendlyName": "Level 2", + "SystemName": "L2" + }, + { + "Capacity": 150, + "CurrentCount": 50, + "FriendlyName": "Level 3", + "SystemName": "L3" + }, + { + "Capacity": 150, + "CurrentCount": 79, + "FriendlyName": "Level 4", + "SystemName": "L4" + }, + { + "Capacity": 162, + "CurrentCount": 132, + "FriendlyName": "Level 5", + "SystemName": "L5" + } + ], + "Longitude": -117.854782, + "MdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/barrera-mdpi.png", + "Name": "Barrera", + "Timestamp": "/Date(1744327647113-0700)/", + "XhdpiDetailImage": "https://webfarm.chapman.edu/ParkingService/barrera-xhdpi.png" + } + ] +} diff --git a/test/loaders/TimedApiBasedShuttleRepositoryLoaderTests.test.ts b/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts similarity index 57% rename from test/loaders/TimedApiBasedShuttleRepositoryLoaderTests.test.ts rename to test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts index 80a8c4d..a57557d 100644 --- a/test/loaders/TimedApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { TimedApiBasedShuttleRepositoryLoader } from "../../src/loaders/TimedApiBasedShuttleRepositoryLoader"; +import { TimedApiBasedRepositoryLoader } from "../../src/loaders/TimedApiBasedRepositoryLoader"; import { resetGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers"; import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { ApiBasedShuttleRepositoryLoader } from "../../src/loaders/shuttle/ApiBasedShuttleRepositoryLoader"; describe("TimedApiBasedRepositoryLoader", () => { - let loader: TimedApiBasedShuttleRepositoryLoader; + let timedLoader: TimedApiBasedRepositoryLoader; let spies: any; beforeAll(() => { @@ -15,17 +16,17 @@ describe("TimedApiBasedRepositoryLoader", () => { beforeEach(() => { resetGlobalFetchMockJson(); - loader = new TimedApiBasedShuttleRepositoryLoader( + const mockLoader = new ApiBasedShuttleRepositoryLoader( "1", "1", - new UnoptimizedInMemoryShuttleRepository() + new UnoptimizedInMemoryShuttleRepository(), + ); + timedLoader = new TimedApiBasedRepositoryLoader( + mockLoader, ); spies = { - fetchAndUpdateRouteDataForSystem: jest.spyOn(loader, 'fetchAndUpdateRouteDataForSystem'), - fetchAndUpdateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, 'fetchAndUpdateStopAndPolylineDataForRoutesInSystem'), - fetchAndUpdateShuttleDataForSystem: jest.spyOn(loader, 'fetchAndUpdateShuttleDataForSystem'), - fetchAndUpdateEtaDataForExistingStopsForSystem: jest.spyOn(loader, 'fetchAndUpdateEtaDataForExistingStopsForSystem') + fetchAndUpdateAll: jest.spyOn(mockLoader, 'fetchAndUpdateAll'), }; Object.values(spies).forEach((spy: any) => { @@ -40,20 +41,20 @@ describe("TimedApiBasedRepositoryLoader", () => { describe("start", () => { it("should update internal state, call data fetching methods, and start a timer", async () => { - await loader.start(); - expect(loader["shouldBeRunning"]).toBe(true); + await timedLoader.start(); + expect(timedLoader["shouldBeRunning"]).toBe(true); Object.values(spies).forEach((spy: any) => { expect(spy).toHaveBeenCalled(); }); - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), loader.timeout); - expect(loader.timeout).not.toBeUndefined(); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), timedLoader.timeoutMs); + expect(timedLoader.timeoutMs).not.toBeUndefined(); }); it("does nothing if timer is already running", async () => { - await loader.start(); - await loader.start(); + await timedLoader.start(); + await timedLoader.start(); Object.values(spies).forEach((spy: any) => { expect(spy).toHaveBeenCalledTimes(1); @@ -63,8 +64,8 @@ describe("TimedApiBasedRepositoryLoader", () => { describe("stop", () => { it("should update internal state", async () => { - loader.stop(); - expect(loader['shouldBeRunning']).toBe(false); + timedLoader.stop(); + expect(timedLoader['shouldBeRunning']).toBe(false); }); }); }); diff --git a/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts b/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts new file mode 100644 index 0000000..55d82c5 --- /dev/null +++ b/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + ChapmanApiBasedParkingRepositoryLoader +} from "../../../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader"; +import { InMemoryParkingRepository } from "../../../src/repositories/InMemoryParkingRepository"; +import { + resetGlobalFetchMockJson, + updateGlobalFetchMockJson, + updateGlobalFetchMockJsonToThrowSyntaxError +} from "../../testHelpers/fetchMockHelpers"; +import { + chapmanParkingStructureData +} from "../../jsonSnapshots/chapmanParkingStructureData/chapmanParkingStructureData"; +import { IParkingStructure } from "../../../src/entities/ParkingRepositoryEntities"; +import { assertAsyncCallbackThrowsApiResponseError } from "../../testHelpers/assertAsyncCallbackThrowsApiResponseError"; + +describe("ChapmanApiBasedParkingRepositoryLoader", () => { + let loader: ChapmanApiBasedParkingRepositoryLoader; + + beforeEach(() => { + loader = new ChapmanApiBasedParkingRepositoryLoader( + new InMemoryParkingRepository(), + ); + resetGlobalFetchMockJson(); + }); + + describe("fetchAndUpdateAll", () => { + it("calls all the correct methods", async () => { + const spies = { + fetchAndUpdateParkingStructures: jest.spyOn(loader, "fetchAndUpdateParkingStructures"), + }; + + Object.values(spies).forEach((spy: any) => { + spy.mockResolvedValue(undefined); + }); + + await loader.fetchAndUpdateAll(); + + Object.values(spies).forEach((spy: any) => { + expect(spy).toHaveBeenCalled(); + }); + }); + }); + + + + describe("fetchAndUpdateParkingStructures", () => { + it("fetches and update parking structures with unique IDs", async () => { + updateGlobalFetchMockJson(chapmanParkingStructureData); + + await loader.fetchAndUpdateParkingStructures(); + + let expectedStructures: IParkingStructure[] = [ + { + address: "300 E Walnut, Orange, CA 92867", + capacity: 871, + spotsAvailable: 211, + coordinates: { + latitude: 33.7945513, + longitude: -117.8518707, + }, + name: "Anderson Structure", + id: "", + }, + { + address: "200 W Sycamore Ave, Orange, CA 92866-1053", + capacity: 692, + spotsAvailable: 282, + coordinates: { + latitude: 33.792937, + longitude: -117.854782 + }, + name: "Barrera", + id: "", + } + ]; + expectedStructures[0].id = ChapmanApiBasedParkingRepositoryLoader.generateId(expectedStructures[0].address); + expectedStructures[1].id = ChapmanApiBasedParkingRepositoryLoader.generateId(expectedStructures[1].address); + + const structuresFromLoader = await loader.repository.getParkingStructures(); + expect(structuresFromLoader).toEqual(expectedStructures); + }); + + it("throws ApiResponseError if data is incorrect", async () => { + updateGlobalFetchMockJsonToThrowSyntaxError(); + + await assertAsyncCallbackThrowsApiResponseError(async () => { + await loader.fetchAndUpdateParkingStructures(); + }); + }) + }); +}); diff --git a/test/loaders/ApiBasedShuttleRepositoryLoaderTests.test.ts b/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts similarity index 74% rename from test/loaders/ApiBasedShuttleRepositoryLoaderTests.test.ts rename to test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts index 7ff472d..d829732 100644 --- a/test/loaders/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -1,26 +1,23 @@ -import { beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { ApiBasedShuttleRepositoryLoader, ApiResponseError } from "../../src/loaders/ApiBasedShuttleRepositoryLoader"; -import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/UnoptimizedInMemoryShuttleRepository"; -import { fetchRouteDataSuccessfulResponse } from "../jsonSnapshots/fetchRouteData/fetchRouteDataSuccessfulResponse"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { ApiBasedShuttleRepositoryLoader } from "../../../src/loaders/shuttle/ApiBasedShuttleRepositoryLoader"; +import { UnoptimizedInMemoryShuttleRepository } from "../../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { fetchRouteDataSuccessfulResponse } from "../../jsonSnapshots/fetchRouteData/fetchRouteDataSuccessfulResponse"; import { fetchStopAndPolylineDataSuccessfulResponse -} from "../jsonSnapshots/fetchStopAndPolylineData/fetchStopAndPolylineDataSuccessfulResponse"; -import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../testHelpers/mockDataGenerators"; +} from "../../jsonSnapshots/fetchStopAndPolylineData/fetchStopAndPolylineDataSuccessfulResponse"; +import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../../testHelpers/mockDataGenerators"; import { fetchShuttleDataSuccessfulResponse -} from "../jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse"; -import { fetchEtaDataSuccessfulResponse } from "../jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse"; +} from "../../jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse"; +import { fetchEtaDataSuccessfulResponse } from "../../jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse"; import { resetGlobalFetchMockJson, updateGlobalFetchMockJson, updateGlobalFetchMockJsonToThrowSyntaxError -} from "../testHelpers/fetchMockHelpers"; +} from "../../testHelpers/fetchMockHelpers"; +import { assertAsyncCallbackThrowsApiResponseError } from "../../testHelpers/assertAsyncCallbackThrowsApiResponseError"; -async function assertAsyncCallbackThrowsApiResponseError(callback: () => Promise) { - await expect(callback).rejects.toThrow(ApiResponseError); -} - -describe("ApiBasedRepositoryLoader", () => { +describe("ApiBasedShuttleRepositoryLoader", () => { let loader: ApiBasedShuttleRepositoryLoader; beforeEach(() => { @@ -28,7 +25,33 @@ describe("ApiBasedRepositoryLoader", () => { resetGlobalFetchMockJson(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + const systemId = "1"; + + describe("fetchAndUpdateAll", () => { + it("calls all the correct methods", async () => { + const spies = { + fetchAndUpdateRouteDataForSystem: jest.spyOn(loader, "fetchAndUpdateRouteDataForSystem"), + fetchAndUpdateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "fetchAndUpdateStopAndPolylineDataForRoutesInSystem"), + fetchAndUpdateShuttleDataForSystem: jest.spyOn(loader, "fetchAndUpdateShuttleDataForSystem"), + fetchAndUpdateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "fetchAndUpdateEtaDataForExistingStopsForSystem"), + }; + + Object.values(spies).forEach((spy: any) => { + spy.mockResolvedValue(undefined); + }); + + await loader.fetchAndUpdateAll(); + + Object.values(spies).forEach((spy: any) => { + expect(spy).toHaveBeenCalled(); + }); + }); + }); + describe("fetchAndUpdateRouteDataForSystem", () => { it("updates route data in repository if response received", async () => { // Arrange diff --git a/test/testHelpers/assertAsyncCallbackThrowsApiResponseError.ts b/test/testHelpers/assertAsyncCallbackThrowsApiResponseError.ts new file mode 100644 index 0000000..d1107a9 --- /dev/null +++ b/test/testHelpers/assertAsyncCallbackThrowsApiResponseError.ts @@ -0,0 +1,6 @@ +import { expect } from "@jest/globals"; +import { ApiResponseError } from "../../src/loaders/ApiResponseError"; + +export async function assertAsyncCallbackThrowsApiResponseError(callback: () => Promise) { + await expect(callback).rejects.toThrow(ApiResponseError); +}