Merge pull request #2 from brendan-ch/feat/api-based-repository

feat/api-based-repository
This commit is contained in:
2025-01-08 18:50:37 -08:00
committed by GitHub
13 changed files with 1146 additions and 120 deletions

5
src/ServerContext.ts Normal file
View File

@@ -0,0 +1,5 @@
import { GetterRepository } from "./repositories/GetterRepository";
export interface ServerContext {
repository: GetterRepository
}

51
src/entities/entities.ts Normal file
View File

@@ -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;
}

View File

@@ -2,10 +2,10 @@ import { readFileSync } from "fs";
import { ApolloServer } from "@apollo/server"; import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone"; import { startStandaloneServer } from "@apollo/server/standalone";
import { resolvers } from "./resolvers"; import { resolvers } from "./resolvers";
import { loadTestData } from "./testData"; import { loadTestData } from "./loaders/loadTestData";
import { ServerContext } from "./serverContext"; import { ServerContext } from "./ServerContext";
import { UnoptimizedInMemoryRepository } from "./unoptimizedInMemoryRepository"; import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository";
import { RepositoryDataLoader } from "./repositoryDataLoader"; import { RepositoryDataLoader } from "./loaders/RepositoryDataLoader";
const typeDefs = readFileSync("./schema.graphqls", "utf8"); const typeDefs = readFileSync("./schema.graphqls", "utf8");

View File

@@ -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 timeout = 10000;
const systemIdsToSupport = ["263"]; const systemIdsToSupport = ["263"];
@@ -23,7 +24,7 @@ export class RepositoryDataLoader {
private shouldBeRunning: boolean = false; private shouldBeRunning: boolean = false;
constructor( constructor(
private repository: Repository, private repository: GetterSetterRepository,
) {} ) {}
public async start() { public async start() {
@@ -107,7 +108,6 @@ export class RepositoryDataLoader {
name: jsonRoute.name, name: jsonRoute.name,
color: jsonRoute.color, color: jsonRoute.color,
id: jsonRoute.myid, id: jsonRoute.myid,
// TODO associate polyline coordinates with routes
polylineCoordinates: [], polylineCoordinates: [],
systemId: system.id, systemId: system.id,
}; };
@@ -122,13 +122,13 @@ export class RepositoryDataLoader {
// Fetch from the API // Fetch from the API
// Pass JSON output into two different methods to update repository // Pass JSON output into two different methods to update repository
const systems = await this.repository.getSystems(); const systems = await this.repository.getSystems();
await Promise.all(systems.map(async (system: any) => { await Promise.all(systems.map(async (system: ISystem) => {
const params = { const params = {
getStops: "2", getStops: "2",
}; };
const formDataJsonObject = { const formDataJsonObject = {
"s0": "263", "s0": system.id,
"sA": 1 "sA": 1
}; };
const formData = new FormData(); const formData = new FormData();

View File

@@ -1,5 +1,6 @@
// Mock data // 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[] = [ 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 Promise.all(systems.map(async (system) => {
await repository.addOrUpdateSystem(system); await repository.addOrUpdateSystem(system);
})); }));

View File

@@ -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<void> {
await this.getShuttlesBySystemId(systemId);
await this.getShuttlesByRouteId(systemId);
await this.getStopsBySystemId(systemId);
await this.getSystemById(systemId);
}
public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null> {
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<IEta[]> {
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<IEta[]> {
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<IShuttle | null> {
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<IShuttle[]> {
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<IStop | null> {
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<IStop[]> {
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<ISystem| null> {
return null;
}
public async getSystems(): Promise<[]> {
return Promise.resolve([]);
}
}

View File

@@ -0,0 +1,37 @@
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
export interface GetterRepository {
getSystems(): Promise<ISystem[]>;
getSystemById(systemId: string): Promise<ISystem | null>;
getStopsBySystemId(systemId: string): Promise<IStop[]>;
getStopById(stopId: string): Promise<IStop | null>;
getRoutesBySystemId(systemId: string): Promise<IRoute[]>;
getRouteById(routeId: string): Promise<IRoute | null>;
getShuttlesBySystemId(systemId: string): Promise<IShuttle[]>;
getShuttleById(shuttleId: string): Promise<IShuttle | null>;
getShuttlesByRouteId(routeId: string): Promise<IShuttle[]>;
getEtasForShuttleId(shuttleId: string): Promise<IEta[]>;
getEtasForStopId(stopId: string): Promise<IEta[]>;
getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null>;
getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null>;
/**
* Get ordered stops with the given stop ID.
* Returns an empty array if no ordered stops found.
* @param stopId
*/
getOrderedStopsByStopId(stopId: string): Promise<IOrderedStop[]>;
/**
* Get ordered stops with the given route ID.
* Returns an empty array if no ordered stops found.
* @param routeId
*/
getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]>;
}

View File

@@ -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<void>;
addOrUpdateRoute(route: IRoute): Promise<void>;
addOrUpdateShuttle(shuttle: IShuttle): Promise<void>;
addOrUpdateStop(stop: IStop): Promise<void>;
addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void>;
addOrUpdateEta(eta: IEta): Promise<void>;
}

View File

@@ -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. * An unoptimized in memory repository.
* (I would optimize it with actual data structures, but I'm * (I would optimize it with actual data structures, but I'm
* switching to another data store later anyways) * switching to another data store later anyways)
*/ */
export class UnoptimizedInMemoryRepository implements Repository { export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
private systems: ISystem[] = []; private systems: ISystem[] = [];
private stops: IStop[] = []; private stops: IStop[] = [];
private routes: IRoute[] = []; private routes: IRoute[] = [];

View File

@@ -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<ISystem[]>;
getSystemById(systemId: string): Promise<ISystem | null>;
getStopsBySystemId(systemId: string): Promise<IStop[]>;
getStopById(stopId: string): Promise<IStop | null>;
getRoutesBySystemId(systemId: string): Promise<IRoute[]>;
getRouteById(routeId: string): Promise<IRoute | null>;
getShuttlesBySystemId(systemId: string): Promise<IShuttle[]>;
getShuttleById(shuttleId: string): Promise<IShuttle | null>;
getShuttlesByRouteId(routeId: string): Promise<IShuttle[]>;
getEtasForShuttleId(shuttleId: string): Promise<IEta[]>;
getEtasForStopId(stopId: string): Promise<IEta[]>;
getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null>;
getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null>;
/**
* Get ordered stops with the given stop ID.
* Returns an empty array if no ordered stops found.
* @param stopId
*/
getOrderedStopsByStopId(stopId: string): Promise<IOrderedStop[]>;
/**
* Get ordered stops with the given route ID.
* Returns an empty array if no ordered stops found.
* @param routeId
*/
getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]>;
// Setter methods
addOrUpdateSystem(system: ISystem): Promise<void>;
addOrUpdateRoute(route: IRoute): Promise<void>;
addOrUpdateShuttle(shuttle: IShuttle): Promise<void>;
addOrUpdateStop(stop: IStop): Promise<void>;
addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void>;
addOrUpdateEta(eta: IEta): Promise<void>;
}

View File

@@ -1,5 +1,5 @@
import { Coordinates, Eta, OrderedStop, Resolvers, Route, Shuttle, Stop, System } from "./generated/graphql"; import { Coordinates, Eta, OrderedStop, Resolvers, Route, Shuttle, Stop, System } from "./generated/graphql";
import { ServerContext } from "./serverContext"; import { ServerContext } from "./ServerContext";
export const resolvers: Resolvers<ServerContext> = { export const resolvers: Resolvers<ServerContext> = {
Query: { Query: {

View File

@@ -1,5 +0,0 @@
import { Repository } from "./repository";
export interface ServerContext {
repository: Repository
}

View File

@@ -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);
// });
// });