diff --git a/src/ServerContext.ts b/src/ServerContext.ts new file mode 100644 index 0000000..d20af65 --- /dev/null +++ b/src/ServerContext.ts @@ -0,0 +1,5 @@ +import { GetterRepository } from "./repositories/GetterRepository"; + +export interface ServerContext { + repository: GetterRepository +} \ No newline at end of file diff --git a/src/entities/entities.ts b/src/entities/entities.ts new file mode 100644 index 0000000..3579c5d --- /dev/null +++ b/src/entities/entities.ts @@ -0,0 +1,51 @@ +export interface IEntityWithOptionalTimestamp { + millisecondsSinceEpoch?: number; +} + +export interface IEntityWithId { + id: string; +} + +export interface ISystem extends IEntityWithId, IEntityWithOptionalTimestamp { + name: string; +} + +export interface ICoordinates { + latitude: number; + longitude: number; +} + +export interface IRoute extends IEntityWithId, IEntityWithOptionalTimestamp { + name: string; + color: string; + polylineCoordinates: ICoordinates[]; + systemId: string; +} + +export interface IStop extends IEntityWithId, IEntityWithOptionalTimestamp { + name: string; + systemId: string; + coordinates: ICoordinates; +} + +export interface IShuttle extends IEntityWithId, IEntityWithOptionalTimestamp { + coordinates: ICoordinates; + name: string; + routeId: string; + systemId: string; +} + +export interface IEta extends IEntityWithOptionalTimestamp { + secondsRemaining: number; + shuttleId: string; + stopId: string; +} + +export interface IOrderedStop extends IEntityWithOptionalTimestamp { + nextStop?: IOrderedStop; + previousStop?: IOrderedStop; + routeId: string; + stopId: string; + position: number; +} + diff --git a/src/index.ts b/src/index.ts index b7fd3fe..da21b2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,10 @@ import { readFileSync } from "fs"; import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; import { resolvers } from "./resolvers"; -import { loadTestData } from "./testData"; -import { ServerContext } from "./serverContext"; -import { UnoptimizedInMemoryRepository } from "./unoptimizedInMemoryRepository"; -import { RepositoryDataLoader } from "./repositoryDataLoader"; +import { loadTestData } from "./loaders/loadTestData"; +import { ServerContext } from "./ServerContext"; +import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository"; +import { RepositoryDataLoader } from "./loaders/RepositoryDataLoader"; const typeDefs = readFileSync("./schema.graphqls", "utf8"); diff --git a/src/repositoryDataLoader.ts b/src/loaders/RepositoryDataLoader.ts similarity index 97% rename from src/repositoryDataLoader.ts rename to src/loaders/RepositoryDataLoader.ts index 2caa4c2..132ed3d 100644 --- a/src/repositoryDataLoader.ts +++ b/src/loaders/RepositoryDataLoader.ts @@ -1,4 +1,5 @@ -import { IOrderedStop, IRoute, IShuttle, IStop, ISystem, Repository } from "./repository"; +import { GetterSetterRepository } from "../repositories/GetterSetterRepository"; +import { IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; const timeout = 10000; const systemIdsToSupport = ["263"]; @@ -23,7 +24,7 @@ export class RepositoryDataLoader { private shouldBeRunning: boolean = false; constructor( - private repository: Repository, + private repository: GetterSetterRepository, ) {} public async start() { @@ -107,7 +108,6 @@ export class RepositoryDataLoader { name: jsonRoute.name, color: jsonRoute.color, id: jsonRoute.myid, - // TODO associate polyline coordinates with routes polylineCoordinates: [], systemId: system.id, }; @@ -122,13 +122,13 @@ export class RepositoryDataLoader { // Fetch from the API // Pass JSON output into two different methods to update repository const systems = await this.repository.getSystems(); - await Promise.all(systems.map(async (system: any) => { + await Promise.all(systems.map(async (system: ISystem) => { const params = { getStops: "2", }; const formDataJsonObject = { - "s0": "263", + "s0": system.id, "sA": 1 }; const formData = new FormData(); diff --git a/src/testData.ts b/src/loaders/loadTestData.ts similarity index 91% rename from src/testData.ts rename to src/loaders/loadTestData.ts index 975308c..9e83e8a 100644 --- a/src/testData.ts +++ b/src/loaders/loadTestData.ts @@ -1,5 +1,6 @@ // Mock data -import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem, Repository } from "./repository"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; +import { GetterSetterRepository } from "../repositories/GetterSetterRepository"; const systems: ISystem[] = [ { @@ -103,7 +104,7 @@ const etas: IEta[] = [ } ]; -export async function loadTestData(repository: Repository) { +export async function loadTestData(repository: GetterSetterRepository) { await Promise.all(systems.map(async (system) => { await repository.addOrUpdateSystem(system); })); diff --git a/src/repositories/ApiBasedRepository.ts b/src/repositories/ApiBasedRepository.ts new file mode 100644 index 0000000..6a24b7f --- /dev/null +++ b/src/repositories/ApiBasedRepository.ts @@ -0,0 +1,376 @@ +import { GetterRepository } from "./GetterRepository"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; + +const baseUrl = "https://passiogo.com/mapGetData.php" + +// TODO: implement TTL functionality +// TODO: add TTL values to everything +// TODO: remove RepositoryDataLoader and UnoptimizedInMemoryRepository +// TODO: make milliseconds (TTL) required on everything +// TODO: extract cache into its own class + +export interface ApiBasedRepositoryCache { + etasForShuttleId?: { + [shuttleId: string]: IEta[], + }, + etasForStopId?: { + [stopId: string]: IEta[], + }, + stopsBySystemId?: { + [systemId: string]: IStop[], + }, + stopByStopId?: { + [stopId: string]: IStop, + }, + shuttleByShuttleId?: { + [shuttleId: string]: IShuttle, + }, + shuttlesBySystemId?: { + [systemId: string]: IShuttle[], + }, + // To speed things up, implement caches for other data later +} + +export interface ApiBasedRepositoryMillisecondTTLs { + etasForShuttleId?: number, + etasForStopId?: number, + shuttleByShuttleId?: number, + shuttlesBySystemId?: number, +} + +const emptyCache: ApiBasedRepositoryCache = { + etasForShuttleId: {}, + etasForStopId: {}, + shuttleByShuttleId: {}, + shuttlesBySystemId: {}, +} + +const defaultTtls: ApiBasedRepositoryMillisecondTTLs = { + etasForShuttleId: 10000, + etasForStopId: 10000, +} + +export class ApiBasedRepository implements GetterRepository { + private cache: ApiBasedRepositoryCache; + + constructor( + initialCache: ApiBasedRepositoryCache | undefined = emptyCache, + private ttls: ApiBasedRepositoryMillisecondTTLs = defaultTtls, + ) { + this.cache = initialCache; + } + + /** + * Seed the initial data in the cache, so data can be updated + * given the correct ID. + * Alternatively, pass in an `initialCache` with existing data + * (useful for testing). + * @param systemId + */ + public async seedCacheForSystemId(systemId: string): Promise { + await this.getShuttlesBySystemId(systemId); + await this.getShuttlesByRouteId(systemId); + await this.getStopsBySystemId(systemId); + await this.getSystemById(systemId); + } + + public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const shuttle = await this.getShuttleById(shuttleId); + const systemId = shuttle?.systemId; + if (!systemId) { + return null; + } + await this.updateEtasForSystemIfTTL(systemId); + + if (this.cache?.etasForStopId && this.cache.etasForStopId[stopId]) { + const etas = this.cache.etasForStopId[stopId]; + const foundEta = etas.find((eta) => eta.stopId === stopId); + if (foundEta) { + return foundEta; + } + } + + return null; + } + + public async getEtasForShuttleId(shuttleId: string): Promise { + const shuttle = await this.getShuttleById(shuttleId); + const systemId = shuttle?.systemId; + if (!systemId) { + return []; + } + await this.updateEtasForSystemIfTTL(systemId); + + if (!this.cache?.etasForShuttleId || !this.cache.etasForShuttleId[shuttleId]) { + return []; + } + + return this.cache.etasForShuttleId[shuttleId]; + } + + public async getEtasForStopId(stopId: string): Promise { + const stop = await this.getStopById(stopId); + const systemId = stop?.systemId; + if (!systemId) { + return []; + } + await this.updateEtasForSystemIfTTL(systemId); + + if (!this.cache?.etasForStopId || !this.cache.etasForStopId[stopId]) { + return []; + } + + return this.cache.etasForStopId[stopId]; + } + + public async updateEtasForSystemIfTTL(systemId: string) { + // TODO: check if TTL + try { + const stops = await this.getStopsBySystemId(systemId); + await Promise.all(stops.map(async (stop) => { + const params = { + eta: "3", + stopIds: stop.id, + }; + + const query = new URLSearchParams(params).toString(); + const response = await fetch(`${baseUrl}?${query}`, { + method: "GET", + }); + const json = await response.json(); + + if (json.ETAs && json.ETAs[stop.id]) { + if (!this.cache.etasForStopId) { + this.cache.etasForStopId = {}; + } + this.cache.etasForStopId[stop.id] = []; + + // This is technically incorrect, the entire shuttle cache + // should not be reset like this + // TODO: restore normal cache behavior + this.cache.etasForShuttleId = {}; + + // Continue with the parsing + json.ETAs[stop.id].forEach((jsonEta: any) => { + // Update cache + if (!this.cache.etasForStopId) { + this.cache.etasForStopId = {}; + } + + if (!this.cache.etasForShuttleId) { + this.cache.etasForShuttleId = {}; + } + + // TODO: create cache abstraction to deal with possibly undefined properties + + const shuttleId: string = jsonEta.busId; + if (!this.cache.etasForShuttleId[shuttleId]) { + this.cache.etasForShuttleId[shuttleId] = []; + } + + const eta: IEta = { + secondsRemaining: jsonEta.secondsSpent, + shuttleId: `${shuttleId}`, + stopId: stop.id, + millisecondsSinceEpoch: Date.now(), + }; + + this.cache.etasForStopId[stop.id].push(eta); + this.cache.etasForShuttleId[shuttleId].push(eta); + }); + } + })); + + } catch (e) { + console.error(e); + } + } + + // TODO: migrate rest of logic over to this class + public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<| null> { + return null; + } + + public async getOrderedStopsByRouteId(routeId: string): Promise<[]> { + return Promise.resolve([]); + } + + public async getOrderedStopsByStopId(stopId: string): Promise<[]> { + return Promise.resolve([]); + } + + public async getRouteById(routeId: string): Promise<| null> { + return Promise.resolve(null); + } + + public async getRoutesBySystemId(systemId: string): Promise<[]> { + return Promise.resolve([]); + } + + public async getShuttleById(shuttleId: string): Promise { + if (!this.cache.shuttleByShuttleId) return null; + let shuttle = this.cache.shuttleByShuttleId[shuttleId]; + if (!shuttle) return null; + + // Call getShuttlesBySystemId to update the data if not TTL + await this.updateShuttlesForSystemIfTTL(shuttle.systemId); + + shuttle = this.cache.shuttleByShuttleId[shuttleId]; + if (!shuttle) return null; + return shuttle; + } + + public async getShuttlesByRouteId(routeId: string): Promise<[]> { + return Promise.resolve([]); + } + + public async getShuttlesBySystemId(systemId: string): Promise { + return Promise.resolve([]); + } + + public async updateShuttlesForSystemIfTTL(systemId: string) { + // TODO: check if TTL + try { + // TODO: update shuttlesByRouteId + // Update shuttleByShuttleId, shuttlesBySystemId + const params = { + getBuses: "2" + }; + + const formDataJsonObject = { + "s0": systemId, + "sA": "1" + }; + + const formData = new FormData(); + formData.set("json", JSON.stringify(formDataJsonObject)); + + const query = new URLSearchParams(params).toString(); + const response = await fetch(`${baseUrl}?${query}`, { + method: "POST", + body: formData, + }); + const json = await response.json(); + + if (json.buses && json.buses["-1"] === undefined) { + const jsonBuses = Object.values(json.buses).map((busesArr: any) => { + return busesArr[0]; + }); + + // Store shuttles by system, with the additional side effect that + // shuttleByShuttleId is updated + const shuttles = 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: systemId, + id: `${jsonBus.busId}` + } + + if (this.cache.shuttleByShuttleId) { + this.cache.shuttleByShuttleId[jsonBus.busId] = constructedShuttle; + } + + return constructedShuttle; + })); + + if (this.cache.shuttlesBySystemId) { + this.cache.shuttlesBySystemId[systemId] = shuttles; + } + } else { + console.warn(`No shuttle data available for system ID ${systemId} and JSON output +${json}`); + } + } catch (e) { + console.error(e); + } + } + + public async getStopById(stopId: string): Promise { + if (!this.cache.stopByStopId) return null; + const oldStop = this.cache.stopByStopId[stopId]; + if (!oldStop) return null; + + await this.updateStopsForSystemIdIfTTL(oldStop.systemId); + + const newStop = this.cache.stopByStopId[stopId]; + if (!newStop) return null; + + return newStop; + } + + public async getStopsBySystemId(systemId: string): Promise { + await this.updateStopsForSystemIdIfTTL(systemId); + + if (!this.cache.stopsBySystemId || this.cache.stopsBySystemId[systemId]) { + return []; + } + return this.cache.stopsBySystemId[systemId]; + } + + public async updateStopsForSystemIdIfTTL(systemId: string) { + // TODO: check if TTL + + try { + const params = { + getStops: "2", + }; + + const formDataJsonObject = { + "s0": systemId, + "sA": 1 + }; + const formData = new FormData(); + formData.set("json", JSON.stringify(formDataJsonObject)); + + const query = new URLSearchParams(params).toString(); + const response = await fetch(`${baseUrl}?${query}`, { + method: "POST", + body: formData, + }); + const json = await response.json(); + + // TODO: update polyline data + // TODO: update ordered stop data + + if (json.stops) { + const jsonStops = Object.values(json.stops); + + await Promise.all(jsonStops.map(async (stop: any) => { + const constructedStop: IStop = { + name: stop.name, + id: stop.id, + systemId: systemId, + coordinates: { + latitude: parseFloat(stop.latitude), + longitude: parseFloat(stop.longitude), + }, + }; + + if (this.cache.stopsBySystemId) { + this.cache.stopsBySystemId[systemId]?.push(constructedStop); + } + + if (this.cache.stopByStopId) { + this.cache.stopByStopId[constructedStop.id] = constructedStop; + } + })); + } + } catch (e) { + console.error(e); + } + } + + public async getSystemById(systemId: string): Promise { + return null; + } + + public async getSystems(): Promise<[]> { + return Promise.resolve([]); + } + +} \ No newline at end of file diff --git a/src/repositories/GetterRepository.ts b/src/repositories/GetterRepository.ts new file mode 100644 index 0000000..4c6a3b0 --- /dev/null +++ b/src/repositories/GetterRepository.ts @@ -0,0 +1,37 @@ +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; + +export interface GetterRepository { + getSystems(): Promise; + getSystemById(systemId: string): Promise; + + getStopsBySystemId(systemId: string): Promise; + getStopById(stopId: string): Promise; + + getRoutesBySystemId(systemId: string): Promise; + getRouteById(routeId: string): Promise; + + getShuttlesBySystemId(systemId: string): Promise; + getShuttleById(shuttleId: string): Promise; + getShuttlesByRouteId(routeId: string): Promise; + + getEtasForShuttleId(shuttleId: string): Promise; + getEtasForStopId(stopId: string): Promise; + + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; + + getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise; + + /** + * Get ordered stops with the given stop ID. + * Returns an empty array if no ordered stops found. + * @param stopId + */ + getOrderedStopsByStopId(stopId: string): Promise; + + /** + * Get ordered stops with the given route ID. + * Returns an empty array if no ordered stops found. + * @param routeId + */ + getOrderedStopsByRouteId(routeId: string): Promise; +} \ No newline at end of file diff --git a/src/repositories/GetterSetterRepository.ts b/src/repositories/GetterSetterRepository.ts new file mode 100644 index 0000000..ce50567 --- /dev/null +++ b/src/repositories/GetterSetterRepository.ts @@ -0,0 +1,21 @@ +// If types match closely, we can use TypeScript "casting" +// to convert from data repo to GraphQL schema + +import { GetterRepository } from "./GetterRepository"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; + +/** + * GetterRepository interface for data derived from Passio API. + * The repository is not designed to have write locks in place. + * Objects passed from/to the repository should be treated + * as disposable. + */ +export interface GetterSetterRepository extends GetterRepository { + // Setter methods + addOrUpdateSystem(system: ISystem): Promise; + addOrUpdateRoute(route: IRoute): Promise; + addOrUpdateShuttle(shuttle: IShuttle): Promise; + addOrUpdateStop(stop: IStop): Promise; + addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise; + addOrUpdateEta(eta: IEta): Promise; +} diff --git a/src/unoptimizedInMemoryRepository.ts b/src/repositories/UnoptimizedInMemoryRepository.ts similarity index 94% rename from src/unoptimizedInMemoryRepository.ts rename to src/repositories/UnoptimizedInMemoryRepository.ts index e7f919f..e992531 100644 --- a/src/unoptimizedInMemoryRepository.ts +++ b/src/repositories/UnoptimizedInMemoryRepository.ts @@ -1,11 +1,20 @@ -import { IEntityWithId, IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem, Repository } from "./repository"; +import { GetterSetterRepository } from "./GetterSetterRepository"; +import { + IEntityWithId, + IEta, + IOrderedStop, + IRoute, + IShuttle, + IStop, + ISystem +} from "../entities/entities"; /** * An unoptimized in memory repository. * (I would optimize it with actual data structures, but I'm * switching to another data store later anyways) */ -export class UnoptimizedInMemoryRepository implements Repository { +export class UnoptimizedInMemoryRepository implements GetterSetterRepository { private systems: ISystem[] = []; private stops: IStop[] = []; private routes: IRoute[] = []; diff --git a/src/repository.ts b/src/repository.ts deleted file mode 100644 index 501c9dc..0000000 --- a/src/repository.ts +++ /dev/null @@ -1,101 +0,0 @@ -// If types match closely, we can use TypeScript "casting" -// to convert from data repo to GraphQL schema - -export interface IEntityWithId { - id: string; -} - -export interface ISystem extends IEntityWithId { - name: string; -} - -export interface ICoordinates { - latitude: number; - longitude: number; -} - -export interface IRoute extends IEntityWithId { - name: string; - color: string; - polylineCoordinates: ICoordinates[]; - systemId: string; -} - -export interface IStop extends IEntityWithId { - name: string; - systemId: string; - coordinates: ICoordinates; -} - -export interface IShuttle extends IEntityWithId { - coordinates: ICoordinates; - name: string; - routeId: string; - systemId: string; -} - -export interface IEta { - secondsRemaining: number; - shuttleId: string; - stopId: string; -} - -export interface IOrderedStop { - nextStop?: IOrderedStop; - previousStop?: IOrderedStop; - routeId: string; - stopId: string; - position: number; -} - -/** - * Repository interface for data derived from Passio API. - * The repository is not designed to have write locks in place. - * Objects passed from/to the repository should be treated - * as disposable. - */ -export interface Repository { - // Getter methods - - getSystems(): Promise; - getSystemById(systemId: string): Promise; - - getStopsBySystemId(systemId: string): Promise; - getStopById(stopId: string): Promise; - - getRoutesBySystemId(systemId: string): Promise; - getRouteById(routeId: string): Promise; - - getShuttlesBySystemId(systemId: string): Promise; - getShuttleById(shuttleId: string): Promise; - getShuttlesByRouteId(routeId: string): Promise; - - getEtasForShuttleId(shuttleId: string): Promise; - getEtasForStopId(stopId: string): Promise; - - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; - - getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise; - - /** - * Get ordered stops with the given stop ID. - * Returns an empty array if no ordered stops found. - * @param stopId - */ - getOrderedStopsByStopId(stopId: string): Promise; - - /** - * Get ordered stops with the given route ID. - * Returns an empty array if no ordered stops found. - * @param routeId - */ - getOrderedStopsByRouteId(routeId: string): Promise; - - // Setter methods - addOrUpdateSystem(system: ISystem): Promise; - addOrUpdateRoute(route: IRoute): Promise; - addOrUpdateShuttle(shuttle: IShuttle): Promise; - addOrUpdateStop(stop: IStop): Promise; - addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise; - addOrUpdateEta(eta: IEta): Promise; -} \ No newline at end of file diff --git a/src/resolvers.ts b/src/resolvers.ts index 950bb6e..29cf156 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -1,5 +1,5 @@ import { Coordinates, Eta, OrderedStop, Resolvers, Route, Shuttle, Stop, System } from "./generated/graphql"; -import { ServerContext } from "./serverContext"; +import { ServerContext } from "./ServerContext"; export const resolvers: Resolvers = { Query: { diff --git a/src/serverContext.ts b/src/serverContext.ts deleted file mode 100644 index abc0a66..0000000 --- a/src/serverContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Repository } from "./repository"; - -export interface ServerContext { - repository: Repository -} \ No newline at end of file diff --git a/test/repositories/ApiBasedRepositoryTests.test.ts b/test/repositories/ApiBasedRepositoryTests.test.ts new file mode 100644 index 0000000..90c82f3 --- /dev/null +++ b/test/repositories/ApiBasedRepositoryTests.test.ts @@ -0,0 +1,632 @@ +import { beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { + ApiBasedRepository, + ApiBasedRepositoryCache, + ApiBasedRepositoryMillisecondTTLs +} from "../../src/repositories/ApiBasedRepository"; +import { IEta, IShuttle, IStop } from "../../src/entities/entities"; + +/** + * Update the global fetch function to return a specific object. + * @param obj + */ +function updateGlobalFetchMockJson(obj: any) { + // @ts-ignore + global.fetch = jest.fn(() => { + return Promise.resolve({ + json: () => Promise.resolve(obj) + }) + }) as jest.Mock; +} + +/** + * Reset the global fetch function mock's JSON to return an empty object. + * @param obj + */ +function resetGlobalFetchMockJson() { + updateGlobalFetchMockJson({}) +} + +beforeEach(() => { + resetGlobalFetchMockJson(); +}) + +// Snapshots taken from the Passio GO! API +const genericEtaDataByStopId = { + "ETAs": { + "177666": [ + { + "OOS": 0, + "busName": "08", + "distance": 1, + "speed": 10.028535400123669, + "routeBlockId": "142270", + "actualRouteBlockId": "142270", + "arrived": null, + "eta": "10 min ", + "color": "#000000", + "bg": "#ffea3f", + "order": 0, + "dwell": null, + "stopsAmount": 2, + "secondsSpent": 587, + "etaR": "10", + "error": null, + "outdated": 0, + "routeId": "53966", + "serviceTime": "", + "scheduleTimes": [], + "goShowSchedule": 0, + "looping": "1", + "routeGroupId": "6703", + "busId": 5577, + "tripId": 751430, + "deviceId": 402840, + "created": "2025-01-07 15:00:09", + "routePointPosition": 6, + "routeStopPosition": 1, + "stopRoutePointPosition": 217, + "timezoneOffset": -10800, + "busLatLng": [ + 33.7933406, + -117.8539321 + ], + "busProjectionLatlng": { + "lat": 33.79331052666975, + "lng": -117.85392945849208 + }, + "busProjectionError": 3, + "stopId": "177666", + "theStop": { + "name": "Chapman Court", + "position": 3, + "userId": "263", + "routeStopId": "1348785", + "busId": 5577, + "routeName": "Red Route", + "shortName": null, + "routeId": "53966", + "stopId": "177666" + } + }, + { + "OOS": 0, + "busName": "07", + "distance": 1, + "speed": 12.160256921380398, + "routeBlockId": "142270", + "actualRouteBlockId": "142270", + "arrived": null, + "eta": "11 min ", + "color": "#000000", + "bg": "#ffea3f", + "order": 0, + "dwell": null, + "stopsAmount": 2, + "secondsSpent": 635, + "etaR": "11", + "error": null, + "outdated": 0, + "routeId": "53966", + "serviceTime": "", + "scheduleTimes": [], + "goShowSchedule": 0, + "looping": "1", + "routeGroupId": "6703", + "busId": 5576, + "tripId": 751430, + "deviceId": 441065, + "created": "2025-01-07 15:00:10", + "routePointPosition": 448, + "routeStopPosition": 4, + "stopRoutePointPosition": 217, + "timezoneOffset": -10800, + "busLatLng": [ + 33.7933284, + -117.855132 + ], + "busProjectionLatlng": { + "lat": 33.79332033922653, + "lng": -117.85513217762522 + }, + "busProjectionError": 1, + "stopId": "177666", + "theStop": { + "name": "Chapman Court", + "position": 3, + "userId": "263", + "routeStopId": "1348785", + "busId": 5576, + "routeName": "Red Route", + "shortName": null, + "routeId": "53966", + "stopId": "177666" + } + }, + ] + }, +} +const genericShuttleDataBySystemId = { + "alertCRC": "23c1b91c", + "buses": { + "402840": [ + { + "deviceId": 402840, + "created": "08:24 PM", + "createdTime": "08:24 PM", + "paxLoad": 0, + "bus": "08", + "busId": 5577, + "userId": "263", + "routeBlockId": "142270", + "latitude": "33.791781800", + "longitude": "-117.858964600", + "calculatedCourse": "351.796001302109", + "outOfService": 0, + "more": "102", + "totalCap": 20, + "color": "#d62728", + "busName": "08", + "busType": "", + "routeId": "53966", + "route": "Red Route", + "outdated": 0 + } + ], + "404873": [ + { + "deviceId": 404873, + "created": "08:24 PM", + "createdTime": "08:24 PM", + "paxLoad": 0, + "bus": "10", + "busId": 7105, + "userId": "263", + "routeBlockId": "142270", + "latitude": "33.789331300", + "longitude": "-117.888790600", + "calculatedCourse": "76.005762226701", + "outOfService": 0, + "more": "101", + "totalCap": 20, + "color": "#d62728", + "busName": "10", + "busType": "", + "routeId": "53966", + "route": "Red Route", + "outdated": 0 + } + ], + "421421": [ + { + "deviceId": 421421, + "created": "08:24 PM", + "createdTime": "08:24 PM", + "paxLoad": 0, + "bus": "17", + "busId": 12502, + "userId": "263", + "routeBlockId": "142660", + "latitude": "33.790699500", + "longitude": "-117.890385500", + "calculatedCourse": "10.65684824528148", + "outOfService": 0, + "more": "101", + "totalCap": 32, + "color": "#bd9e39", + "busName": "17", + "busType": "Shuttle Bus", + "routeId": "54256", + "route": "Gold Route", + "outdated": 0 + } + ], + "441065": [ + { + "deviceId": 441065, + "created": "08:19 PM", + "createdTime": "08:19 PM", + "paxLoad": 0, + "bus": "07", + "busId": 5576, + "userId": "263", + "routeBlockId": "142270", + "latitude": "33.793278900", + "longitude": "-117.852629400", + "calculatedCourse": "299.74488110904485", + "outOfService": 0, + "more": "22", + "totalCap": 20, + "color": "#d62728", + "busName": "07", + "busType": "", + "routeId": "53966", + "route": "Red Route", + "outdated": 0 + } + ] + }, + "microtime": 0.023222923278808594, + "time": { + "263": "08:24 PM" + } +} + +describe("getEtaForShuttleAndStopId", () => { + test("getEtaForShuttleAndStopId returns correct ETA data", async () => { + updateGlobalFetchMockJson(genericEtaDataByStopId); + + const initialCache: ApiBasedRepositoryCache = { + etasForStopId: { + "177666": [ + { + secondsRemaining: 587, + shuttleId: "5577", + stopId: "177666", + millisecondsSinceEpoch: Date.now(), + } + ], + }, + } + + const repository = new ApiBasedRepository(initialCache); + + repository.getShuttleById = jest.fn(async () => { + const shuttle: IShuttle = { + id: "5577", + name: "08", + coordinates: { + latitude: 33.7933406, + longitude: -117.8539321, + }, + routeId: "53966", + systemId: "1", + }; + return shuttle; + }); + + repository.updateEtasForSystemIfTTL = jest.fn(async () => { + }); + + const result = await repository.getEtaForShuttleAndStopId("5577", "177666"); + + expect(result?.secondsRemaining).toEqual(587); + expect(result?.millisecondsSinceEpoch).toBeDefined(); + }); + + test("getEtaForShuttleAndStopId returns null if API call is invalid and cache is empty", async () => { + const repository = new ApiBasedRepository(); + const result = await repository.getEtaForShuttleAndStopId("5577", "177666"); + + expect(result).toEqual(null); + }); +}); + +describe("getEtasForShuttleId", () => { + test("getEtasForShuttleId returns correct ETA data", async () => { + updateGlobalFetchMockJson(genericEtaDataByStopId); + + const initialCache: ApiBasedRepositoryCache = { + etasForStopId: {}, + etasForShuttleId: { + "5577": [ + { + secondsRemaining: 587, + shuttleId: "5577", + stopId: "177666", + millisecondsSinceEpoch: Date.now(), + } + ] + }, + }; + + const ttls: ApiBasedRepositoryMillisecondTTLs = { + etasForShuttleId: 100000, + etasForStopId: 100000, + }; + + const repository = new ApiBasedRepository(initialCache, ttls); + repository.updateEtasForSystemIfTTL = jest.fn(async () => { + }); + repository.getShuttleById = jest.fn(async () => { + const shuttle: IShuttle = { + id: "5577", + name: "08", + coordinates: { + latitude: 33.7933406, + longitude: -117.8539321, + }, + routeId: "53966", + systemId: "1", + }; + return shuttle; + }); + const result = await repository.getEtasForShuttleId("5577"); + + // @ts-ignore + expect(result).toEqual(initialCache.etasForShuttleId["5577"]); + }); + + test("getEtasForShuttleId returns empty array if no data available", async () => { + const repository = new ApiBasedRepository(); + repository.updateEtasForSystemIfTTL = jest.fn(async () => { + }); + const result = await repository.getEtasForShuttleId("5577"); + + expect(result).toEqual([]); + }); +}); + +describe("getEtasForStopId", () => { + test("getEtasForStopId returns correct ETA data", async () => { + // Because I'm testing updateEtasForSystemIfTTL separately, + // stub it out here + + updateGlobalFetchMockJson(genericEtaDataByStopId); + + const initialCache: ApiBasedRepositoryCache = { + etasForStopId: { + "177666": [ + { + secondsRemaining: 587, + shuttleId: "5577", + stopId: "177666", + millisecondsSinceEpoch: Date.now(), + } + ] + }, + etasForShuttleId: {} + }; + + const ttls: ApiBasedRepositoryMillisecondTTLs = { + etasForShuttleId: 100000, + etasForStopId: 100000, + }; + + const repository = new ApiBasedRepository(initialCache, ttls); + repository.getStopById = jest.fn(async () => { + const stop: IStop = { + name: "Chapman Court", + systemId: "1", + id: "177666", + coordinates: { + latitude: 33.796796, + longitude: -117.889293 + }, + }; + return stop; + }); + repository.updateEtasForSystemIfTTL = jest.fn(async () => { + }); + const result = await repository.getEtasForStopId("177666"); + + expect(result).toEqual(initialCache.etasForStopId!["177666"]); + }); + + test("getEtasForStopId returns empty array if no data available", async () => { + const repository = new ApiBasedRepository(); + repository.updateEtasForSystemIfTTL = jest.fn(async () => { + }); + const result = await repository.getEtasForShuttleId("5577"); + + expect(result).toEqual([]); + }); +}); + +describe("updateEtasForSystemIfTTL", () => { + // test("updateEtasForSystemIfTTL does nothing if data is not TTL", async () => { + // updateGlobalFetchMockJson(genericEtaDataByStopId); + // + // // If ETA data is not TTL, then don't do anything + // const expectedEta: IEta = { + // secondsRemaining: 587, + // shuttleId: "5577", + // stopId: "177666", + // millisecondsSinceEpoch: Date.now() - 1000, + // }; + // + // const initialCache: ApiBasedRepositoryCache = { + // etasForShuttleId: { + // "5577": [ + // expectedEta, + // ], + // }, + // etasForStopId: { + // "177666": [ + // expectedEta, + // ], + // }, + // stopsBySystemId: { + // "1": [ + // { + // systemId: "1", + // millisecondsSinceEpoch: Date.now() - 1000, + // name: "Chapman Court", + // id: "177666", + // coordinates: { + // latitude: 33.796796, + // longitude: -117.889293 + // }, + // } + // ], + // }, + // }; + // + // const ttls: ApiBasedRepositoryMillisecondTTLs = { + // etasForShuttleId: 100000, + // etasForStopId: 100000, + // }; + // + // const repository = new ApiBasedRepository(initialCache, ttls); + // await repository.updateEtasForSystemIfTTL("1"); + // + // const updatedResult = await repository.getEtaForShuttleAndStopId( + // "5577", + // "177666", + // ); + // expect(updatedResult?.millisecondsSinceEpoch).toEqual(expectedEta.millisecondsSinceEpoch); + // }); + + test("updateEtasForSystemIfTTL updates all ETA data if data is TTL", async () => { + updateGlobalFetchMockJson(genericEtaDataByStopId); + + const sampleStop: IStop = { + name: "Chapman Court", + systemId: "1", + id: "177666", + coordinates: { + latitude: 33.796796, + longitude: -117.889293 + }, + } + + const repository = new ApiBasedRepository(); + repository.getStopsBySystemId = jest.fn(async () => { + return [ + sampleStop + ]; + }); + + repository.getStopById = jest.fn(async () => { + return sampleStop; + }); + + await repository.updateEtasForSystemIfTTL("1"); + + const updatedResult = await repository.getEtasForStopId("177666"); + expect(updatedResult.length).toEqual(2); + }); +}); + +describe("getShuttleById", () => { + test("getShuttleById returns null if unseeded cache", async () => { + updateGlobalFetchMockJson(genericShuttleDataBySystemId); + + const initialCache: ApiBasedRepositoryCache = {}; + const repository = new ApiBasedRepository(initialCache); + + const shuttle = await repository.getShuttleById("5577"); + expect(shuttle).toBeNull(); + }); + + test("getShuttleById returns data if present", async () => { + updateGlobalFetchMockJson(genericShuttleDataBySystemId); + + const initialCacheShuttle = { + coordinates: { + latitude: 33.7917818, + longitude: -117.8589646, + }, + name: "08", + routeId: "53966", + systemId: "1", + id: "5577", + } + + const initialCache: ApiBasedRepositoryCache = { + shuttleByShuttleId: { + "5577": initialCacheShuttle + } + }; + + const ttls: ApiBasedRepositoryMillisecondTTLs = { + shuttleByShuttleId: 1000, + }; + + const repository = new ApiBasedRepository(initialCache, ttls); + repository.updateStopsForSystemIdIfTTL = jest.fn(async () => { + }) + + const shuttle = await repository.getShuttleById("5577"); + expect(shuttle).toEqual(initialCacheShuttle); + }); +}); + +// TODO: enable when implemented +// describe("getShuttlesBySystemId", () => { +// test("getShuttlesBySystemId returns old data if not expired", async () => { +// updateGlobalFetchMockJson(genericShuttleDataBySystemId); +// +// const initialCacheShuttle = { +// coordinates: { +// latitude: 33.791781, +// longitude: -117.8589646, +// }, +// name: "08", +// routeId: "53966", +// systemId: "1", +// id: "5577", +// millisecondsSinceEpoch: Date.now() - 1000, +// }; +// +// const initialCache: ApiBasedRepositoryCache = { +// shuttlesBySystemId: { +// "1": [ +// initialCacheShuttle, +// ] +// }, +// shuttleByShuttleId: { +// "5577": initialCacheShuttle, +// } +// }; +// +// const ttls: ApiBasedRepositoryMillisecondTTLs = { +// shuttleByShuttleId: 100000, +// shuttlesBySystemId: 100000, +// }; +// +// const repository = new ApiBasedRepository(initialCache, ttls); +// const shuttles = await repository.getShuttlesBySystemId("1"); +// expect(shuttles.length).toEqual(1); +// expect(shuttles[0].id).toEqual(initialCacheShuttle.id); +// }); +// +// test("getShuttlesBySystemId returns fresh data if expired", async () => { +// updateGlobalFetchMockJson(genericShuttleDataBySystemId); +// +// // TODO: move construction of shuttle into method +// const initialCacheShuttle = { +// coordinates: { +// latitude: 33.791781, +// longitude: -117.8589646, +// }, +// name: "08", +// routeId: "53966", +// systemId: "1", +// id: "5577", +// millisecondsSinceEpoch: Date.now() - 100000, +// }; +// +// const initialCache: ApiBasedRepositoryCache = { +// shuttlesBySystemId: { +// "1": [ +// initialCacheShuttle, +// ] +// }, +// shuttleByShuttleId: { +// "5577": initialCacheShuttle, +// } +// }; +// +// const ttls: ApiBasedRepositoryMillisecondTTLs = { +// shuttleByShuttleId: 1000, +// shuttlesBySystemId: 1000, +// }; +// +// const repository = new ApiBasedRepository(initialCache, ttls); +// const shuttles = await repository.getShuttlesBySystemId("1"); +// +// expect(shuttles.length).toEqual(1); +// expect(shuttles[0].id).toEqual("5577"); +// expect(shuttles[0].millisecondsSinceEpoch).not.toEqual(initialCacheShuttle.millisecondsSinceEpoch); +// }); +// +// test("getShuttlesBySystemId returns fresh data if no seeded data", async () => { +// updateGlobalFetchMockJson(genericShuttleDataBySystemId); +// +// const repository = new ApiBasedRepository(); +// const shuttles = await repository.getShuttlesBySystemId("1"); +// +// expect(shuttles.length).toEqual(1); +// }); +// });