diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cb83045 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/loaders/ApiBasedRepositoryLoader.ts b/src/loaders/ApiBasedRepositoryLoader.ts index 01e9cec..c5b252d 100644 --- a/src/loaders/ApiBasedRepositoryLoader.ts +++ b/src/loaders/ApiBasedRepositoryLoader.ts @@ -1,6 +1,13 @@ import { GetterSetterRepository } from "../repositories/GetterSetterRepository"; import { IEta, IRoute, IShuttle, IStop, ISystem } from "../entities/entities"; +export class ApiResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiResponseError"; + } +} + export class ApiBasedRepositoryLoader { readonly supportedSystemIds = ["263"]; readonly baseUrl = "https://passiogo.com/mapGetData.php"; @@ -15,20 +22,32 @@ export class ApiBasedRepositoryLoader { getSystems: "2", }; const query = new URLSearchParams(params).toString(); - const response = await fetch(`${this.baseUrl}?${query}`); - const json = await response.json() - if (typeof json.all === "object") { - // filter down to supported systems - const filteredSystems = json.all.filter((jsonSystem: any) => this.supportedSystemIds.includes(jsonSystem.id)); - await Promise.all(filteredSystems.map(async (system: any) => { - const constructedSystem: ISystem = { - id: system.id, - name: system.fullname, - }; + try { + const response = await fetch(`${this.baseUrl}?${query}`); + const json = await response.json(); - await this.repository.addOrUpdateSystem(constructedSystem); - })); + if (!response.ok) { + throw new Error(`HTTP error with status ${response.status}`) + } + + if (typeof json.all === "object") { + // filter down to supported systems + const filteredSystems = json.all.filter((jsonSystem: any) => this.supportedSystemIds.includes(jsonSystem.id)); + await Promise.all(filteredSystems.map(async (system: any) => { + const constructedSystem: ISystem = { + id: system.id, + name: system.fullname, + }; + + await this.repository.addOrUpdateSystem(constructedSystem); + })); + } else { + throw new Error("Received JSON object does not contain `all` field") + } + } catch(e: any) { + console.error("fetchAndUpdateSystemData call failed: ", e); + throw new ApiResponseError(e.message); } } diff --git a/test/jsonSnapshots/fetchSystemData/fetchSystemDataFailedResponse.ts b/test/jsonSnapshots/fetchSystemData/fetchSystemDataFailedResponse.ts new file mode 100644 index 0000000..ba00384 --- /dev/null +++ b/test/jsonSnapshots/fetchSystemData/fetchSystemDataFailedResponse.ts @@ -0,0 +1,5 @@ +// Not an actual response snapshot +// Simulate a case where `all` property of response is unavailable +export const fetchSystemDataFailedResponse = { + "error": "no systems", +}; \ No newline at end of file diff --git a/test/jsonSnapshots/getAllSystemsResponse.ts b/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts similarity index 99% rename from test/jsonSnapshots/getAllSystemsResponse.ts rename to test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts index f1e134c..67b77e5 100644 --- a/test/jsonSnapshots/getAllSystemsResponse.ts +++ b/test/jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse.ts @@ -1,4 +1,4 @@ -export const getAllSystemsResponse = { +export const fetchSystemDataSuccessfulResponse = { "all": [ { "fullname": "Chapman University", diff --git a/test/loaders/ApiBasedRepositoryLoaderTests.test.ts b/test/loaders/ApiBasedRepositoryLoaderTests.test.ts index 177cf8b..45a0742 100644 --- a/test/loaders/ApiBasedRepositoryLoaderTests.test.ts +++ b/test/loaders/ApiBasedRepositoryLoaderTests.test.ts @@ -1,13 +1,25 @@ import { beforeEach, describe, expect, it, jest, test } from "@jest/globals"; -import { ApiBasedRepositoryLoader } from "../../src/loaders/ApiBasedRepositoryLoader"; +import { ApiBasedRepositoryLoader, ApiResponseError } from "../../src/loaders/ApiBasedRepositoryLoader"; import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository"; -import { getAllSystemsResponse } from "../jsonSnapshots/getAllSystemsResponse"; +import { fetchSystemDataSuccessfulResponse } from "../jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse"; +import { fetchSystemDataFailedResponse } from "../jsonSnapshots/fetchSystemData/fetchSystemDataFailedResponse"; -function updateGlobalFetchMockJson(obj: any) { +/** + * Function to update behavior of the global `fetch` function. + * Note that the Passio GO API returns status code 200 for failed responses. + * @param obj + * @param status + */ +function updateGlobalFetchMockJson( + obj: any, + status: number = 200 +) { // @ts-ignore global.fetch = jest.fn(() => { return Promise.resolve({ - json: () => Promise.resolve(obj) + json: () => Promise.resolve(obj), + status, + ok: status.toString().startsWith("2"), // 200-level codes are OK }) }) as jest.Mock; } @@ -30,8 +42,8 @@ describe("ApiBasedRepositoryLoader", () => { describe("fetchAndUpdateSystemData", () => { it("updates system data in repository if response received", async () => { - const numberOfSystemsInResponse = getAllSystemsResponse.all.length; - updateGlobalFetchMockJson(getAllSystemsResponse); + const numberOfSystemsInResponse = fetchSystemDataSuccessfulResponse.all.length; + updateGlobalFetchMockJson(fetchSystemDataSuccessfulResponse); await loader.fetchAndUpdateSystemData(); @@ -44,7 +56,12 @@ describe("ApiBasedRepositoryLoader", () => { }); it("throws the correct error if the API response contains no data", async () => { + updateGlobalFetchMockJson(fetchSystemDataFailedResponse); + // Jest is so confusing + await expect(async () => { + await loader.fetchAndUpdateSystemData(); + }).rejects.toThrow(ApiResponseError); }); }); diff --git a/test/loaders/temp.ts b/test/loaders/temp.ts new file mode 100644 index 0000000..3248494 --- /dev/null +++ b/test/loaders/temp.ts @@ -0,0 +1,422 @@ +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"; +import { genericEtaDataByStopId } from "../jsonSnapshots/genericEtaDataBySystemId"; +import { genericShuttleDataBySystemId } from "../jsonSnapshots/genericShuttleDataBySystemId"; + +/** + * 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(); +}) + + +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(), + }, + { + secondsRemaining: 226, + shuttleId: "9909", + 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(); + expect(result?.shuttleId).toEqual("5577"); + expect(result?.stopId).toEqual("177666"); + }); + + 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); +// }); +// });