diff --git a/src/environment.ts b/src/environment.ts index b47673a..b99c149 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -22,3 +22,5 @@ export const RATE_LIMIT_DELAY_MULTIPLIER_MS = process.env.RATE_LIMIT_DELAY_MULTI : 1000; export const REDIS_RECONNECT_INTERVAL = 30000; + +export const SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES = 5; diff --git a/src/loaders/RepositoryLoader.ts b/src/loaders/RepositoryLoader.ts index 668aa26..95c96a6 100644 --- a/src/loaders/RepositoryLoader.ts +++ b/src/loaders/RepositoryLoader.ts @@ -1,3 +1,3 @@ export interface RepositoryLoader { - fetchAndUpdateAll(): Promise; + updateAll(): Promise; } diff --git a/src/loaders/TimedApiBasedRepositoryLoader.ts b/src/loaders/TimedApiBasedRepositoryLoader.ts index 8daf3be..3bd8a52 100644 --- a/src/loaders/TimedApiBasedRepositoryLoader.ts +++ b/src/loaders/TimedApiBasedRepositoryLoader.ts @@ -33,7 +33,7 @@ export class TimedApiBasedRepositoryLoader { if (!this.shouldBeRunning) return; try { - await this.loader.fetchAndUpdateAll(); + await this.loader.updateAll(); } catch (e) { console.error(e); } diff --git a/src/loaders/__tests__/TimedApiBasedRepositoryLoaderTests.test.ts b/src/loaders/__tests__/TimedApiBasedRepositoryLoaderTests.test.ts index fc94ad3..02e6d1f 100644 --- a/src/loaders/__tests__/TimedApiBasedRepositoryLoaderTests.test.ts +++ b/src/loaders/__tests__/TimedApiBasedRepositoryLoaderTests.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { TimedApiBasedRepositoryLoader } from "../TimedApiBasedRepositoryLoader"; import { resetGlobalFetchMockJson } from "../../../testHelpers/fetchMockHelpers"; import { UnoptimizedInMemoryShuttleRepository } from "../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; @@ -23,7 +23,7 @@ describe("TimedApiBasedRepositoryLoader", () => { ); spies = { - fetchAndUpdateAll: jest.spyOn(mockLoader, 'fetchAndUpdateAll'), + updateAll: jest.spyOn(mockLoader, 'updateAll'), }; Object.values(spies).forEach((spy: any) => { diff --git a/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts index 5377c4c..18881cc 100644 --- a/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts +++ b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts @@ -21,7 +21,7 @@ export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepository this.fetchAndUpdateParkingStructures = this.fetchAndUpdateParkingStructures.bind(this); } - async fetchAndUpdateAll() { + async updateAll() { await this.fetchAndUpdateParkingStructures(); } diff --git a/src/loaders/parking/__tests__/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts b/src/loaders/parking/__tests__/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts index ebc33a8..580d77c 100644 --- a/src/loaders/parking/__tests__/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts +++ b/src/loaders/parking/__tests__/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts @@ -34,7 +34,7 @@ describe("ChapmanApiBasedParkingRepositoryLoader", () => { spy.mockResolvedValue(undefined); }); - await loader.fetchAndUpdateAll(); + await loader.updateAll(); Object.values(spies).forEach((spy: any) => { expect(spy).toHaveBeenCalled(); diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index 72e3d95..7e6f64e 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -1,8 +1,9 @@ import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; -import { IEntityWithId } from "../../entities/SharedEntities"; +import { ICoordinates, IEntityWithId } from "../../entities/SharedEntities"; import { ApiResponseError } from "../ApiResponseError"; +import { SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES } from "../../environment"; /** * Class which can load data into a repository from the @@ -10,12 +11,13 @@ import { ApiResponseError } from "../ApiResponseError"; * which inherit from `IEntityWithId`. */ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader { - baseUrl = "https://passiogo.com/mapGetData.php"; + readonly baseUrl = "https://passiogo.com/mapGetData.php"; constructor( public passioSystemId: string, public systemIdForConstructedData: string, public repository: ShuttleGetterSetterRepository, + readonly shuttleToRouteCoordinateMaximumDistanceMiles = SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES, ) { } @@ -28,23 +30,49 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader return ids; } - public async fetchAndUpdateAll() { - await this.fetchAndUpdateRouteDataForSystem(); - await this.fetchAndUpdateStopAndPolylineDataForRoutesInSystem(); - await this.fetchAndUpdateShuttleDataForSystem(); + public async updateAll() { + await this.updateRouteDataForSystem(); + await this.updateStopAndPolylineDataForRoutesInSystem(); + await this.updateShuttleDataForSystemBasedOnProximityToRoutes(); // Because ETA method doesn't support pruning yet, // add a call to the clear method here await this.repository.clearEtaData(); - await this.fetchAndUpdateEtaDataForExistingStopsForSystem(); + await this.updateEtaDataForExistingStopsForSystem(); } - public async fetchAndUpdateRouteDataForSystem() { - const systemId = this.passioSystemId; + public async updateRouteDataForSystem() { + try { + const json = await this.fetchRouteDataJson(); + const routes = this.constructRoutesFromJson(json); + if (routes !== null) { + await this.updateRouteDataInRepository(routes); + } else { + console.warn(`Route update failed for the following JSON: ${JSON.stringify(json)}`); + } + } catch(e: any) { + throw new ApiResponseError(e.message); + } + } + + private async updateRouteDataInRepository(routes: IRoute[]) { const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => { return await this.repository.getRoutes(); }); + await Promise.all(routes.map(async (route) => { + await this.repository.addOrUpdateRoute(route); + routeIdsToPrune.delete(route.id); + })); + + await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => { + await this.repository.removeRouteIfExists(routeId); + })); + } + + private async fetchRouteDataJson() { + const systemId = this.passioSystemId; + const params = { getRoutes: "2", }; @@ -52,53 +80,63 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const formDataJsonObject = { "systemSelected0": systemId, "amount": "1", - } + }; const formData = new FormData(); formData.set("json", JSON.stringify(formDataJsonObject)); const query = new URLSearchParams(params).toString(); - try { - const response = await fetch(`${this.baseUrl}?${query}`, { - method: "POST", - body: formData, + const response = await fetch(`${this.baseUrl}?${query}`, { + method: "POST", + body: formData, + }); + return await response.json(); + } + + private constructRoutesFromJson(json: any): IRoute[] | null { + if (typeof json.all === "object") { + return json.all.map((jsonRoute: any) => { + const constructedRoute: IRoute = { + name: jsonRoute.name, + color: jsonRoute.color, + id: jsonRoute.myid, + polylineCoordinates: [], + systemId: this.systemIdForConstructedData, + updatedTime: new Date(), + }; + return constructedRoute; }); - const json = await response.json(); + } - if (typeof json.all === "object") { - await Promise.all(json.all.map(async (jsonRoute: any) => { - const constructedRoute: IRoute = { - name: jsonRoute.name, - color: jsonRoute.color, - id: jsonRoute.myid, - polylineCoordinates: [], - systemId: this.systemIdForConstructedData, - updatedTime: new Date(), - }; + return null; + } - await this.repository.addOrUpdateRoute(constructedRoute); - - routeIdsToPrune.delete(constructedRoute.id); - })) - } - - await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => { - await this.repository.removeRouteIfExists(routeId); - })); + public async updateStopAndPolylineDataForRoutesInSystem() { + try { + const json = await this.fetchStopAndPolylineDataJson(); + await this.updateStopAndPolylineDataInRepository(json); } catch(e: any) { throw new ApiResponseError(e.message); } } - public async fetchAndUpdateStopAndPolylineDataForRoutesInSystem() { - const passioSystemId = this.passioSystemId; - - // Fetch from the API - // Pass JSON output into two different methods to update repository + private async updateStopAndPolylineDataInRepository(json: any) { const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => { return await this.repository.getStops(); }); + await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune); + await this.updateOrderedStopDataForExistingStops(json); + await this.updatePolylineDataForExistingRoutesAndApiResponse(json); + + await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => { + await this.repository.removeStopIfExists(stopId); + })); + } + + private async fetchStopAndPolylineDataJson() { + const passioSystemId = this.passioSystemId; + const params = { getStops: "2", }; @@ -112,31 +150,46 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const query = new URLSearchParams(params).toString(); + const response = await fetch(`${this.baseUrl}?${query}`, { + method: "POST", + body: formData, + }); + return await response.json(); + } + + public async updateShuttleDataForSystemBasedOnProximityToRoutes() { try { - const response = await fetch(`${this.baseUrl}?${query}`, { - method: "POST", - body: formData, - }); - const json = await response.json(); - - await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune); - await this.updateOrderedStopDataForExistingStops(json); - await this.updatePolylineDataForExistingRoutesAndApiResponse(json); - - await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => { - await this.repository.removeStopIfExists(stopId); - })); + const json = await this.fetchShuttleDataJson(); + let shuttles = this.constructInServiceShuttlesFromJson(json); + if (shuttles !== null) { + shuttles = await this.filterShuttlesByDistanceFromCorrespondingRoute(shuttles); + await this.updateShuttleDataInRepository(shuttles); + } else { + console.warn(`Shuttle update failed for the following JSON: ${JSON.stringify(json)}`) + } } catch(e: any) { throw new ApiResponseError(e.message); } } - public async fetchAndUpdateShuttleDataForSystem() { - const systemId = this.passioSystemId; + private async updateShuttleDataInRepository(shuttles: IShuttle[]) { const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => { return await this.repository.getShuttles(); }); + await Promise.all(shuttles.map(async (shuttle) => { + await this.repository.addOrUpdateShuttle(shuttle); + shuttleIdsToPrune.delete(shuttle.id); + })); + + await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => { + await this.repository.removeShuttleIfExists(shuttleId); + })); + } + + private async fetchShuttleDataJson() { + const systemId = this.passioSystemId; + const params = { getBuses: "2" }; @@ -151,55 +204,69 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const query = new URLSearchParams(params).toString(); - try { - const response = await fetch(`${this.baseUrl}?${query}`, { - method: "POST", - body: formData, + const response = await fetch(`${this.baseUrl}?${query}`, { + method: "POST", + body: formData, + }); + return await response.json(); + } + + private constructInServiceShuttlesFromJson(json: any): IShuttle[] | null { + if (json.buses && json.buses["-1"] === undefined) { + const jsonBuses = Object.values(json.buses).map((busesArr: any) => { + return busesArr[0]; }); - const json = await response.json(); + jsonBuses.filter((bus) => bus.outOfService != 0) - if (json.buses && json.buses["-1"] === undefined) { - const jsonBuses = Object.values(json.buses).map((busesArr: any) => { - return busesArr[0]; - }); + return jsonBuses.map((jsonBus: any) => { + const constructedShuttle: IShuttle = { + name: jsonBus.bus, + coordinates: { + latitude: parseFloat(jsonBus.latitude), + longitude: parseFloat(jsonBus.longitude), + }, + routeId: jsonBus.routeId, + systemId: this.systemIdForConstructedData, + id: `${jsonBus.busId}`, + orientationInDegrees: parseFloat(jsonBus.calculatedCourse), + updatedTime: new Date(), + } + return constructedShuttle; + }); + } - await Promise.all(jsonBuses.map(async (jsonBus: any) => { - const constructedShuttle: IShuttle = { - name: jsonBus.bus, - coordinates: { - latitude: parseFloat(jsonBus.latitude), - longitude: parseFloat(jsonBus.longitude), - }, - routeId: jsonBus.routeId, - systemId: this.systemIdForConstructedData, - id: `${jsonBus.busId}`, - orientationInDegrees: parseFloat(jsonBus.calculatedCourse), - updatedTime: new Date(), - } + return null; + } - await this.repository.addOrUpdateShuttle(constructedShuttle); + public async updateEtaDataForExistingStopsForSystem() { + const stops = await this.repository.getStops(); + await Promise.all(stops.map(async (stop) => { + let stopId = stop.id; + await this.updateEtaDataForStopId(stopId); + })); + } - shuttleIdsToPrune.delete(constructedShuttle.id); - })); + public async updateEtaDataForStopId(stopId: string) { + try { + const json = await this.fetchEtaDataJson(stopId); + const etas = this.constructEtasFromJson(json, stopId); + if (etas !== null) { + await this.updateEtaDataInRepository(etas); + } else { + console.warn(`ETA update failed for stop ${stopId} with the following JSON: ${JSON.stringify(json)}`); } - - await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => { - await this.repository.removeShuttleIfExists(shuttleId); - })); } catch(e: any) { throw new ApiResponseError(e.message); } } - public async fetchAndUpdateEtaDataForExistingStopsForSystem() { - const stops = await this.repository.getStops(); - await Promise.all(stops.map(async (stop) => { - let stopId = stop.id; - await this.fetchAndUpdateEtaDataForStopId(stopId); + private async updateEtaDataInRepository(etas: IEta[]) { + await Promise.all(etas.map(async (eta) => { + await this.repository.addOrUpdateEta(eta); })); } - public async fetchAndUpdateEtaDataForStopId(stopId: string) { + private async fetchEtaDataJson(stopId: string) { const params = { eta: "3", stopIds: stopId, @@ -207,32 +274,28 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const query = new URLSearchParams(params).toString(); - try { - const response = await fetch(`${this.baseUrl}?${query}`, { - method: "GET", + const response = await fetch(`${this.baseUrl}?${query}`, { + method: "GET", + }); + return await response.json(); + } + + private constructEtasFromJson(json: any, stopId: string): IEta[] | null { + if (json.ETAs && json.ETAs[stopId]) { + return json.ETAs[stopId].map((jsonEta: any) => { + const shuttleId: string = jsonEta.busId; + const eta: IEta = { + secondsRemaining: jsonEta.secondsSpent, + shuttleId: `${shuttleId}`, + stopId: stopId, + updatedTime: new Date(), + systemId: this.systemIdForConstructedData, + }; + return eta; }); - const json = await response.json(); - - if (json.ETAs && json.ETAs[stopId]) { - // Continue with the parsing - json.ETAs[stopId].forEach((jsonEta: any) => { - // Update cache - const shuttleId: string = jsonEta.busId; - - const eta: IEta = { - secondsRemaining: jsonEta.secondsSpent, - shuttleId: `${shuttleId}`, - stopId: stopId, - updatedTime: new Date(), - systemId: this.systemIdForConstructedData, - }; - - this.repository.addOrUpdateEta(eta); - }); - } - } catch(e: any) { - throw new ApiResponseError(e.message); } + + return null; } protected async updateStopDataForSystemAndApiResponse( @@ -331,4 +394,47 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader })) } } + + private filterShuttlesByDistanceFromCorrespondingRoute = async (shuttles: IShuttle[]) => { + let filteredShuttles: IShuttle[] = []; + + await Promise.all(shuttles.map(async (shuttle) => { + const route = await this.repository.getRouteById(shuttle.routeId); + if (route != null) { + let closestDistanceMiles = Number.MAX_VALUE; + + route.polylineCoordinates.forEach((coordinate) => { + const calculatedDistance = ApiBasedShuttleRepositoryLoader.convertDistanceBetweenCoordinatesToMiles(coordinate, shuttle.coordinates) + if (closestDistanceMiles > calculatedDistance) { + closestDistanceMiles = calculatedDistance; + } + }); + + if (closestDistanceMiles <= this.shuttleToRouteCoordinateMaximumDistanceMiles) { + filteredShuttles.push(shuttle); + } + } else { + console.warn(`No corresponding route found for ID ${shuttle.routeId} of shuttle ${shuttle.id}`) + } + })); + + return filteredShuttles; + }; + + public static convertDistanceBetweenCoordinatesToMiles = (fromCoordinate: ICoordinates, toCoordinate: ICoordinates): number => { + const earthRadiusMiles = 3959; + + const lat1Rad = fromCoordinate.latitude * Math.PI / 180; + const lat2Rad = toCoordinate.latitude * Math.PI / 180; + const deltaLatRad = (toCoordinate.latitude - fromCoordinate.latitude) * Math.PI / 180; + const deltaLonRad = (toCoordinate.longitude - fromCoordinate.longitude) * Math.PI / 180; + + const a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) * + Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return earthRadiusMiles * c; + }; } diff --git a/src/loaders/shuttle/ShuttleRepositoryLoader.ts b/src/loaders/shuttle/ShuttleRepositoryLoader.ts index 382c97e..d556455 100644 --- a/src/loaders/shuttle/ShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ShuttleRepositoryLoader.ts @@ -1,9 +1,9 @@ import { RepositoryLoader } from "../RepositoryLoader"; export interface ShuttleRepositoryLoader extends RepositoryLoader { - fetchAndUpdateRouteDataForSystem(): Promise; - fetchAndUpdateStopAndPolylineDataForRoutesInSystem(): Promise; - fetchAndUpdateShuttleDataForSystem(): Promise; - fetchAndUpdateEtaDataForExistingStopsForSystem(): Promise; - fetchAndUpdateEtaDataForStopId(stopId: string): Promise; + updateRouteDataForSystem(): Promise; + updateStopAndPolylineDataForRoutesInSystem(): Promise; + updateShuttleDataForSystemBasedOnProximityToRoutes(): Promise; + updateEtaDataForExistingStopsForSystem(): Promise; + updateEtaDataForStopId(stopId: string): Promise; } diff --git a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts index 7770064..799b2bd 100644 --- a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -16,6 +16,7 @@ import { updateGlobalFetchMockJsonToThrowSyntaxError } from "../../../../testHelpers/fetchMockHelpers"; import { assertAsyncCallbackThrowsApiResponseError } from "../../../../testHelpers/assertAsyncCallbackThrowsApiResponseError"; +import { IRoute } from "../../../entities/ShuttleRepositoryEntities"; describe("ApiBasedShuttleRepositoryLoader", () => { let loader: ApiBasedShuttleRepositoryLoader; @@ -31,20 +32,20 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const systemId = "1"; - describe("fetchAndUpdateAll", () => { + describe("updateAll", () => { 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"), + updateRouteDataForSystem: jest.spyOn(loader, "updateRouteDataForSystem"), + updateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "updateStopAndPolylineDataForRoutesInSystem"), + updateShuttleDataForSystem: jest.spyOn(loader, "updateShuttleDataForSystemBasedOnProximityToRoutes"), + updateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "updateEtaDataForExistingStopsForSystem"), }; Object.values(spies).forEach((spy: any) => { spy.mockResolvedValue(undefined); }); - await loader.fetchAndUpdateAll(); + await loader.updateAll(); Object.values(spies).forEach((spy: any) => { expect(spy).toHaveBeenCalled(); @@ -52,7 +53,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { }); }); - describe("fetchAndUpdateRouteDataForSystem", () => { + describe("updateRouteDataForSystem", () => { it("updates route data in repository if response received", async () => { // Arrange // Test pruning @@ -65,7 +66,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse); // Act - await loader.fetchAndUpdateRouteDataForSystem(); + await loader.updateRouteDataForSystem(); // Assert const routes = await loader.repository.getRoutes(); @@ -80,12 +81,12 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateGlobalFetchMockJsonToThrowSyntaxError(); await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.fetchAndUpdateRouteDataForSystem(); + await loader.updateRouteDataForSystem(); }); }); }); - describe("fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId", () => { + describe("updateStopAndPolylineDataForRoutesInSystem", () => { it("updates stop and polyline data if response received", async () => { // Arrange // Test pruning of stops only @@ -99,7 +100,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops); - await loader.fetchAndUpdateStopAndPolylineDataForRoutesInSystem(); + await loader.updateStopAndPolylineDataForRoutesInSystem(); const stops = await loader.repository.getStops(); expect(stops.length).toEqual(stopsArray.length); @@ -119,41 +120,147 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateGlobalFetchMockJsonToThrowSyntaxError(); await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.fetchAndUpdateStopAndPolylineDataForRoutesInSystem(); + await loader.updateStopAndPolylineDataForRoutesInSystem(); }); }) }); - describe("fetchAndUpdateShuttleDataForSystem", () => { - it("updates shuttle data in repository if response received", async () => { + describe("updateShuttleDataForSystemBasedOnProximityToRoutes", () => { + function generateMockRoutesWithPolylineCoordinates() { + const routes = generateMockRoutes(); + routes[0].polylineCoordinates = [ + {latitude: 33.78792, longitude: -117.86187}, + {latitude: 33.78792, longitude: -117.86200}, + {latitude: 33.78792, longitude: -117.86245} + ]; + return routes; + } + + function getMockJsonResponseMatchingRouteAndCoordinates(route: IRoute, longitude: string, latitude: string) { + const modifiedSuccessfulResponse = { + ...fetchShuttleDataSuccessfulResponse, + }; + + Object.keys(modifiedSuccessfulResponse.buses).forEach((busId) => { + const bus = (modifiedSuccessfulResponse.buses as any)[busId][0]; + bus.latitude = latitude; + bus.longitude = longitude; + bus.routeId = route.id; + }); + return modifiedSuccessfulResponse; + } + + async function addMockRoutes(routes: IRoute[]) { + await Promise.all(routes.map(async (route) => { + await loader.repository.addOrUpdateRoute(route); + })); + } + + it("updates shuttle data in repository from API if shuttles close enough to route", async () => { + const distanceMiles = 1; + loader = new ApiBasedShuttleRepositoryLoader( + "263", + "1", + new UnoptimizedInMemoryShuttleRepository(), + distanceMiles, + ); + + const routes = generateMockRoutesWithPolylineCoordinates(); + await addMockRoutes(routes); + const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates( + routes[0], + "-117.86187", + "33.78792" + ); + updateGlobalFetchMockJson(modifiedSuccessfulResponse); + const busesInResponse = Object.values(modifiedSuccessfulResponse.buses); + + await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); + + const shuttles = await loader.repository.getShuttles(); + expect(shuttles.length).toEqual(busesInResponse.length); + }); + + it("does not update shuttle data in repository from API if shuttles are not close enough to route", async () => { + const distanceMiles = 1; + loader = new ApiBasedShuttleRepositoryLoader( + "263", + "1", + new UnoptimizedInMemoryShuttleRepository(), + distanceMiles, + ); + + const routes = generateMockRoutesWithPolylineCoordinates(); + await addMockRoutes(routes); + + const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates( + routes[0], + "-116.86187", + "32.78792" + ); + updateGlobalFetchMockJson(modifiedSuccessfulResponse); + + await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); + + const shuttles = await loader.repository.getShuttles(); + expect(shuttles.length).toEqual(0); + }); + + it("prunes shuttles correctly", async () => { + const distanceMiles = 1; + loader = new ApiBasedShuttleRepositoryLoader( + "263", + "1", + new UnoptimizedInMemoryShuttleRepository(), + distanceMiles, + ); + + // Add mock shuttles to repository (these should be pruned) const shuttlesToPrune = generateMockShuttles(); await Promise.all(shuttlesToPrune.map(async (shuttle) => { shuttle.systemId = systemId; await loader.repository.addOrUpdateShuttle(shuttle); - })) + })); - updateGlobalFetchMockJson(fetchShuttleDataSuccessfulResponse); - const busesInResponse = Object.values(fetchShuttleDataSuccessfulResponse.buses); + const routes = generateMockRoutesWithPolylineCoordinates(); + await addMockRoutes(routes); - await loader.fetchAndUpdateShuttleDataForSystem(); + const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates( + routes[0], + "-117.86187", + "33.78792" + ); + updateGlobalFetchMockJson(modifiedSuccessfulResponse); + + // Update shuttles from API + await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); + + // Old shuttles should be pruned, only API shuttles should remain const shuttles = await loader.repository.getShuttles(); + const busesInResponse = Object.values(modifiedSuccessfulResponse.buses); expect(shuttles.length).toEqual(busesInResponse.length); + + // Verify none of the original mock shuttles remain + shuttlesToPrune.forEach((originalShuttle) => { + const foundShuttle = shuttles.find(s => s.id === originalShuttle.id); + expect(foundShuttle).toBeUndefined(); + }); }); it("throws the correct error if the API response contains no data", async () => { updateGlobalFetchMockJsonToThrowSyntaxError(); await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.fetchAndUpdateShuttleDataForSystem(); + await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); }); }); }); - describe("fetchAndUpdateEtaDataForExistingStopsForSystem", () => { - it("calls fetchAndUpdateEtaDataForStopId for every stop in repository", async () => { - const spy = jest.spyOn(loader, "fetchAndUpdateEtaDataForStopId"); + describe("updateEtaDataForExistingStopsForSystem", () => { + it("calls updateEtaDataForStopId for every stop in repository", async () => { + const spy = jest.spyOn(loader, "updateEtaDataForStopId"); const stops = generateMockStops(); stops.forEach((stop) => { @@ -164,20 +271,20 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.repository.addOrUpdateStop(stop); })); - await loader.fetchAndUpdateEtaDataForExistingStopsForSystem(); + await loader.updateEtaDataForExistingStopsForSystem(); expect(spy.mock.calls.length).toEqual(stops.length); }); }); - describe("fetchAndUpdateEtaDataForStopId", () => { + describe("updateEtaDataForStopId", () => { const stopId = "177666"; it("updates ETA data for stop id if response received", async () => { updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse); // @ts-ignore const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId] - await loader.fetchAndUpdateEtaDataForStopId(stopId); + await loader.updateEtaDataForStopId(stopId); const etas = await loader.repository.getEtasForStopId(stopId); expect(etas.length).toEqual(etasFromResponse.length); @@ -187,7 +294,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateGlobalFetchMockJsonToThrowSyntaxError(); await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.fetchAndUpdateEtaDataForStopId("263"); + await loader.updateEtaDataForStopId("263"); }); }); });