diff --git a/.env.example b/.env.example index bcf9bdc..6f1a51e 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,5 @@ APNS_BUNDLE_ID= APNS_PRIVATE_KEY= PARKING_LOGGING_INTERVAL_MS= +PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL= +PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN= diff --git a/jest.config.js b/jest.config.js index eae6a94..0a5ba27 100644 --- a/jest.config.js +++ b/jest.config.js @@ -110,7 +110,7 @@ const config = { // reporters: undefined, // Automatically reset mock state before every test - // resetMocks: false, + resetMocks: true, // Reset the module registry before running each individual test // resetModules: false, @@ -119,7 +119,7 @@ const config = { // resolver: undefined, // Automatically restore mock state and implementation before every test - // restoreMocks: false, + restoreMocks: true, // The root directory that Jest should scan for tests and modules within // rootDir: undefined, diff --git a/schema.graphqls b/schema.graphqls index b37b8d4..a7464ee 100644 --- a/schema.graphqls +++ b/schema.graphqls @@ -30,6 +30,21 @@ type ParkingStructure { coordinates: Coordinates! address: String! updatedTime: DateTime + systemId: ID! + + historicalAverages(input: HistoricalParkingAverageQueryInput): [HistoricalParkingAverageQueryResult!] +} + +type HistoricalParkingAverageQueryResult { + from: DateTime! + to: DateTime! + averageSpotsAvailable: Float! +} + +input HistoricalParkingAverageQueryInput { + from: DateTime! + to: DateTime! + intervalMs: Int! } type Route { diff --git a/src/MergedResolvers.ts b/src/MergedResolvers.ts index c1925ff..ab1124e 100644 --- a/src/MergedResolvers.ts +++ b/src/MergedResolvers.ts @@ -10,6 +10,7 @@ import { RouteResolvers } from "./resolvers/RouteResolvers"; import { MutationResolvers } from "./resolvers/MutationResolvers"; import { ParkingSystemResolvers } from "./resolvers/ParkingSystemResolvers"; import { DateTime } from "./scalars/DateTime"; +import { ParkingStructureResolvers } from "./resolvers/ParkingStructureResolvers"; export const MergedResolvers: Resolvers = { ...QueryResolvers, @@ -21,5 +22,6 @@ export const MergedResolvers: Resolvers = { ...OrderedStopResolvers, ...EtaResolvers, ...MutationResolvers, + ...ParkingStructureResolvers, DateTime: DateTime, }; diff --git a/src/__mocks__/environment.ts b/src/__mocks__/environment.ts new file mode 100644 index 0000000..6f0b850 --- /dev/null +++ b/src/__mocks__/environment.ts @@ -0,0 +1,5 @@ +export const PARKING_LOGGING_INTERVAL_MS = 10000; + +export const PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL = 1000; + +export const PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN = 60000 * 60 * 24; diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index af9faed..03428a2 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -1,19 +1,19 @@ import { ETANotificationScheduler } from "../notifications/schedulers/ETANotificationScheduler"; import { TimedApiBasedRepositoryLoader } from "../loaders/TimedApiBasedRepositoryLoader"; -import { UnoptimizedInMemoryShuttleRepository } from "../repositories/UnoptimizedInMemoryShuttleRepository"; -import { RedisNotificationRepository } from "../repositories/RedisNotificationRepository"; -import { NotificationRepository } from "../repositories/NotificationRepository"; -import { ShuttleGetterSetterRepository } from "../repositories/ShuttleGetterSetterRepository"; -import { InMemoryNotificationRepository } from "../repositories/InMemoryNotificationRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; +import { RedisNotificationRepository } from "../repositories/notifications/RedisNotificationRepository"; +import { NotificationRepository } from "../repositories/notifications/NotificationRepository"; +import { ShuttleGetterSetterRepository } from "../repositories/shuttle/ShuttleGetterSetterRepository"; +import { InMemoryNotificationRepository } from "../repositories/notifications/InMemoryNotificationRepository"; import { AppleNotificationSender } from "../notifications/senders/AppleNotificationSender"; import { ApiBasedShuttleRepositoryLoader } from "../loaders/shuttle/ApiBasedShuttleRepositoryLoader"; -import { ParkingGetterSetterRepository } from "../repositories/ParkingGetterSetterRepository"; -import { InMemoryParkingRepository } from "../repositories/InMemoryParkingRepository"; +import { ParkingGetterSetterRepository } from "../repositories/parking/ParkingGetterSetterRepository"; +import { InMemoryParkingRepository } from "../repositories/parking/InMemoryParkingRepository"; import { buildParkingRepositoryLoaderIfExists, ParkingRepositoryLoaderBuilderArguments } from "../loaders/parking/buildParkingRepositoryLoaderIfExists"; -import { RedisParkingRepository } from "../repositories/RedisParkingRepository"; +import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository"; export interface InterchangeSystemBuilderArguments { name: string; diff --git a/src/entities/ParkingRepositoryEntities.ts b/src/entities/ParkingRepositoryEntities.ts index 36ca488..1f722c6 100644 --- a/src/entities/ParkingRepositoryEntities.ts +++ b/src/entities/ParkingRepositoryEntities.ts @@ -9,7 +9,7 @@ export interface IParkingStructure extends IEntityWithTimestamp, IEntityWithId { } export interface IParkingStructureTimestampRecord { - timestampMs: number; + timestampMs: Date; id: string; spotsAvailable: number; } diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 0000000..6037926 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,11 @@ +export const PARKING_LOGGING_INTERVAL_MS = process.env.PARKING_LOGGING_INTERVAL_MS + ? parseInt(process.env.PARKING_LOGGING_INTERVAL_MS) + : 600000; // Every 10 minutes + +export const PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL = process.env.PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL + ? parseInt(process.env.PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL) + : 60000 * 60 * 2; + +export const PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN = process.env.PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN + ? parseInt(process.env.PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN) + : 60000 * 60 * 24; diff --git a/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts index 5cfafea..5377c4c 100644 --- a/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts +++ b/src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader.ts @@ -1,5 +1,5 @@ import { ParkingRepositoryLoader } from "./ParkingRepositoryLoader"; -import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; +import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository"; import { createHash } from "node:crypto"; import { ApiResponseError } from "../ApiResponseError"; import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; diff --git a/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts b/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts index ae0383e..a70b2f5 100644 --- a/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts +++ b/src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts @@ -1,4 +1,4 @@ -import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; +import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository"; import { ChapmanApiBasedParkingRepositoryLoader } from "./ChapmanApiBasedParkingRepositoryLoader"; export interface ParkingRepositoryLoaderBuilderArguments { diff --git a/src/loaders/parking/loadParkingTestData.ts b/src/loaders/parking/loadParkingTestData.ts index be7663d..600a76e 100644 --- a/src/loaders/parking/loadParkingTestData.ts +++ b/src/loaders/parking/loadParkingTestData.ts @@ -1,4 +1,4 @@ -import { ParkingGetterSetterRepository } from "../../repositories/ParkingGetterSetterRepository"; +import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository"; import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; const parkingStructures: IParkingStructure[] = [ diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index c7735aa..72e3d95 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -1,4 +1,4 @@ -import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository"; +import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; import { IEntityWithId } from "../../entities/SharedEntities"; diff --git a/src/loaders/shuttle/loadShuttleTestData.ts b/src/loaders/shuttle/loadShuttleTestData.ts index 636efae..4713716 100644 --- a/src/loaders/shuttle/loadShuttleTestData.ts +++ b/src/loaders/shuttle/loadShuttleTestData.ts @@ -1,6 +1,6 @@ // Mock data import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; -import { ShuttleGetterSetterRepository } from "../../repositories/ShuttleGetterSetterRepository"; +import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; import { supportedIntegrationTestSystems } from "../supportedIntegrationTestSystems"; const redRoutePolylineCoordinates = [ diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 328f36c..2ecdb3f 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -1,11 +1,11 @@ -import { ShuttleGetterRepository } from "../../repositories/ShuttleGetterRepository"; +import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository"; import { IEta } from "../../entities/ShuttleRepositoryEntities"; import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; import { NotificationRepository, ScheduledNotification -} from "../../repositories/NotificationRepository"; -import { InMemoryNotificationRepository } from "../../repositories/InMemoryNotificationRepository"; +} from "../../repositories/notifications/NotificationRepository"; +import { InMemoryNotificationRepository } from "../../repositories/notifications/InMemoryNotificationRepository"; export class ETANotificationScheduler { public static readonly defaultSecondsThresholdForNotificationToFire = 180; diff --git a/src/notifications/senders/AppleNotificationSender.ts b/src/notifications/senders/AppleNotificationSender.ts index ba03989..3ed298d 100644 --- a/src/notifications/senders/AppleNotificationSender.ts +++ b/src/notifications/senders/AppleNotificationSender.ts @@ -16,7 +16,7 @@ export interface NotificationAlertArguments { export class AppleNotificationSender { private apnsToken: string | undefined = undefined; - private _lastRefreshedTimeMs: number | undefined = undefined; + private _lastRefreshedTimeMs: Date | undefined = undefined; constructor( private shouldActuallySendNotifications = true, @@ -34,13 +34,13 @@ export class AppleNotificationSender { } } - get lastRefreshedTimeMs(): number | undefined { + get lastRefreshedTimeMs(): Date | undefined { return this._lastRefreshedTimeMs; } private lastReloadedTimeForAPNsIsTooRecent() { const thirtyMinutesMs = 1800000; - return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs; + return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs.getTime() < thirtyMinutesMs; } public reloadAPNsTokenIfTimePassed() { @@ -70,7 +70,7 @@ export class AppleNotificationSender { algorithm: "ES256", header: tokenHeader }); - this._lastRefreshedTimeMs = nowMs; + this._lastRefreshedTimeMs = new Date(nowMs); } /** diff --git a/src/repositories/ParkingRepositoryConstants.ts b/src/repositories/ParkingRepositoryConstants.ts deleted file mode 100644 index 4d8b30e..0000000 --- a/src/repositories/ParkingRepositoryConstants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const PARKING_LOGGING_INTERVAL_MS = process.env.PARKING_LOGGING_INTERVAL_MS - ? parseInt(process.env.PARKING_LOGGING_INTERVAL_MS) - : 600000; // Every 10 minutes diff --git a/src/repositories/InMemoryNotificationRepository.ts b/src/repositories/notifications/InMemoryNotificationRepository.ts similarity index 98% rename from src/repositories/InMemoryNotificationRepository.ts rename to src/repositories/notifications/InMemoryNotificationRepository.ts index 513f7b7..7e26a73 100644 --- a/src/repositories/InMemoryNotificationRepository.ts +++ b/src/repositories/notifications/InMemoryNotificationRepository.ts @@ -5,7 +5,7 @@ import { NotificationRepository, ScheduledNotification } from "./NotificationRepository"; -import { TupleKey } from "../types/TupleKey"; +import { TupleKey } from "../../types/TupleKey"; type DeviceIdSecondsThresholdAssociation = { [key: string]: number }; diff --git a/src/repositories/NotificationRepository.ts b/src/repositories/notifications/NotificationRepository.ts similarity index 100% rename from src/repositories/NotificationRepository.ts rename to src/repositories/notifications/NotificationRepository.ts diff --git a/src/repositories/RedisNotificationRepository.ts b/src/repositories/notifications/RedisNotificationRepository.ts similarity index 97% rename from src/repositories/RedisNotificationRepository.ts rename to src/repositories/notifications/RedisNotificationRepository.ts index 6648642..2c7609e 100644 --- a/src/repositories/RedisNotificationRepository.ts +++ b/src/repositories/notifications/RedisNotificationRepository.ts @@ -1,4 +1,4 @@ -import { TupleKey } from '../types/TupleKey'; +import { TupleKey } from '../../types/TupleKey'; import { Listener, NotificationEvent, @@ -6,7 +6,7 @@ import { NotificationRepository, ScheduledNotification } from "./NotificationRepository"; -import { BaseRedisRepository } from "./BaseRedisRepository"; +import { BaseRedisRepository } from "../BaseRedisRepository"; export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository { private listeners: Listener[] = []; diff --git a/src/repositories/InMemoryParkingRepository.ts b/src/repositories/parking/InMemoryParkingRepository.ts similarity index 85% rename from src/repositories/InMemoryParkingRepository.ts rename to src/repositories/parking/InMemoryParkingRepository.ts index 741cfd9..84cea32 100644 --- a/src/repositories/InMemoryParkingRepository.ts +++ b/src/repositories/parking/InMemoryParkingRepository.ts @@ -2,10 +2,10 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure, IParkingStructureTimestampRecord -} from "../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; -import { CircularQueue } from "../types/CircularQueue"; -import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; +} from "../../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { CircularQueue } from "../../types/CircularQueue"; +import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; // If every 10 minutes, two weeks of data (6x per hour * 24x per day * 7x per week * 2) export const MAX_NUM_ENTRIES = 2016; @@ -63,7 +63,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { const queue = this.historicalData.get(id); if (!queue || queue.size() === 0) { return []; @@ -80,7 +80,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository private createTimestampRecord = (structure: IParkingStructure, timestampMs: number): IParkingStructureTimestampRecord => ({ id: structure.id, spotsAvailable: structure.spotsAvailable, - timestampMs, + timestampMs: new Date(timestampMs), }); private ensureHistoricalDataExists = (structureId: string): void => { @@ -90,7 +90,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository }; private addRecordToHistoricalData = (structureId: string, record: IParkingStructureTimestampRecord): void => { - const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs; + const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs.getTime() - b.timestampMs.getTime(); this.historicalData.get(structureId)?.appendWithSorting(record, sortingCallback); }; @@ -107,15 +107,16 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository private calculateAveragesFromRecords = ( records: IParkingStructureTimestampRecord[], - options: ParkingStructureCountOptions + options: HistoricalParkingAverageQueryArguments ): HistoricalParkingAverageQueryResult[] => { const results: HistoricalParkingAverageQueryResult[] = []; - const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options; + const { from, to, intervalMs } = options; - let currentIntervalStart = startUnixEpochMs; + let currentIntervalStart = from.getTime(); + const endTime = to.getTime(); - while (currentIntervalStart < endUnixEpochMs) { - const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs); + while (currentIntervalStart < endTime) { + const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endTime); const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd); if (recordsInInterval.length > 0) { @@ -135,7 +136,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository endMs: number ): IParkingStructureTimestampRecord[] => { return records.filter(record => - record.timestampMs >= startMs && record.timestampMs < endMs + record.timestampMs.getTime() >= startMs && record.timestampMs.getTime() < endMs ); }; @@ -148,8 +149,8 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository const averageSpotsAvailable = totalSpotsAvailable / records.length; return { - fromUnixEpochMs: fromMs, - toUnixEpochMs: toMs, + from: new Date(fromMs), + to: new Date(toMs), averageSpotsAvailable }; }; diff --git a/src/repositories/ParkingGetterRepository.ts b/src/repositories/parking/ParkingGetterRepository.ts similarity index 60% rename from src/repositories/ParkingGetterRepository.ts rename to src/repositories/parking/ParkingGetterRepository.ts index 8ef13c9..e1346a4 100644 --- a/src/repositories/ParkingGetterRepository.ts +++ b/src/repositories/parking/ParkingGetterRepository.ts @@ -1,14 +1,14 @@ -import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; -export interface ParkingStructureCountOptions { - startUnixEpochMs: number; - endUnixEpochMs: number; +export interface HistoricalParkingAverageQueryArguments { + from: Date; + to: Date; intervalMs: number; } export interface HistoricalParkingAverageQueryResult { - fromUnixEpochMs: number; - toUnixEpochMs: number; + from: Date; + to: Date; averageSpotsAvailable: number; } @@ -22,5 +22,5 @@ export interface ParkingGetterRepository { * @param id * @param options */ - getHistoricalAveragesOfParkingStructureCounts(id: string, options: ParkingStructureCountOptions): Promise; + getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageQueryArguments): Promise; } diff --git a/src/repositories/ParkingGetterSetterRepository.ts b/src/repositories/parking/ParkingGetterSetterRepository.ts similarity index 83% rename from src/repositories/ParkingGetterSetterRepository.ts rename to src/repositories/parking/ParkingGetterSetterRepository.ts index 63b60e5..91d70c2 100644 --- a/src/repositories/ParkingGetterSetterRepository.ts +++ b/src/repositories/parking/ParkingGetterSetterRepository.ts @@ -1,4 +1,4 @@ -import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; +import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; import { ParkingGetterRepository } from "./ParkingGetterRepository"; export interface ParkingGetterSetterRepository extends ParkingGetterRepository { diff --git a/src/repositories/RedisParkingRepository.ts b/src/repositories/parking/RedisParkingRepository.ts similarity index 89% rename from src/repositories/RedisParkingRepository.ts rename to src/repositories/parking/RedisParkingRepository.ts index fae968c..5704226 100644 --- a/src/repositories/RedisParkingRepository.ts +++ b/src/repositories/parking/RedisParkingRepository.ts @@ -1,8 +1,8 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; -import { IParkingStructure } from "../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository"; -import { BaseRedisRepository } from "./BaseRedisRepository"; -import { PARKING_LOGGING_INTERVAL_MS } from "./ParkingRepositoryConstants"; +import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { BaseRedisRepository } from "../BaseRedisRepository"; +import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; export type ParkingStructureID = string; @@ -75,7 +75,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { return this.calculateAveragesFromRecords(id, options); }; @@ -157,17 +157,18 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki private calculateAveragesFromRecords = async ( id: string, - options: ParkingStructureCountOptions + options: HistoricalParkingAverageQueryArguments ): Promise => { const keys = this.createRedisKeys(id); - const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options; + const { from, to, intervalMs } = options; const results: HistoricalParkingAverageQueryResult[] = []; - let currentIntervalStart = startUnixEpochMs; + let currentIntervalStart = from.getTime(); + const endTime = to.getTime(); + + while (currentIntervalStart < endTime) { + const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endTime); - while (currentIntervalStart < endUnixEpochMs) { - const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs); - try { const aggregationResult = await this.redisClient.sendCommand([ 'TS.RANGE', @@ -182,8 +183,8 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki if (aggregationResult && aggregationResult.length > 0) { const [, averageValue] = aggregationResult[0]; results.push({ - fromUnixEpochMs: currentIntervalStart, - toUnixEpochMs: currentIntervalEnd, + from: new Date(currentIntervalStart), + to: new Date(currentIntervalEnd), averageSpotsAvailable: parseFloat(averageValue) }); } diff --git a/src/repositories/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts similarity index 97% rename from src/repositories/ShuttleGetterRepository.ts rename to src/repositories/shuttle/ShuttleGetterRepository.ts index f705e15..20d0fdc 100644 --- a/src/repositories/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -1,4 +1,4 @@ -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../entities/ShuttleRepositoryEntities"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; /** * Shuttle getter repository to be linked to a system. diff --git a/src/repositories/ShuttleGetterSetterRepository.ts b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts similarity index 97% rename from src/repositories/ShuttleGetterSetterRepository.ts rename to src/repositories/shuttle/ShuttleGetterSetterRepository.ts index 6f23bc1..24da9e1 100644 --- a/src/repositories/ShuttleGetterSetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts @@ -2,7 +2,7 @@ // to convert from data repo to GraphQL schema import { ShuttleGetterRepository } from "./ShuttleGetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../entities/ShuttleRepositoryEntities"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; /** * ShuttleGetterRepository interface for data derived from Passio API. diff --git a/src/repositories/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts similarity index 98% rename from src/repositories/UnoptimizedInMemoryShuttleRepository.ts rename to src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index fd6c36f..52553cc 100644 --- a/src/repositories/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -1,6 +1,6 @@ import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../entities/ShuttleRepositoryEntities"; -import { IEntityWithId } from "../entities/SharedEntities"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IEntityWithId } from "../../entities/SharedEntities"; /** * An unoptimized in memory repository. diff --git a/src/resolvers/MutationResolvers.ts b/src/resolvers/MutationResolvers.ts index 8d266c8..2543051 100644 --- a/src/resolvers/MutationResolvers.ts +++ b/src/resolvers/MutationResolvers.ts @@ -3,7 +3,7 @@ import { ServerContext } from "../ServerContext"; import { ETANotificationScheduler, } from "../notifications/schedulers/ETANotificationScheduler"; -import { ScheduledNotification } from "../repositories/NotificationRepository"; +import { ScheduledNotification } from "../repositories/notifications/NotificationRepository"; import { InterchangeSystem } from "../entities/InterchangeSystem"; async function temp_findMatchingSystemBasedOnShuttleId(context: ServerContext, args: Omit & { diff --git a/src/resolvers/ParkingStructureResolvers.ts b/src/resolvers/ParkingStructureResolvers.ts new file mode 100644 index 0000000..a108daf --- /dev/null +++ b/src/resolvers/ParkingStructureResolvers.ts @@ -0,0 +1,59 @@ +import { Resolvers } from "../generated/graphql"; +import { ServerContext } from "../ServerContext"; +import { HistoricalParkingAverageQueryArguments } from "../repositories/parking/ParkingGetterRepository"; +import { GraphQLError } from "graphql/error"; +import { + PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN, + PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL +} from "../environment"; + +export const ParkingStructureResolvers: Resolvers = { + ParkingStructure: { + historicalAverages: async (parent, args, contextValue, _info) => { + /** + * @param errorMessage + */ + function throwBadUserInputError(errorMessage: string) { + throw new GraphQLError(errorMessage, { + extensions: { + code: 'BAD_USER_INPUT', + } + }); + } + + const system = contextValue.findSystemById(parent.systemId); + + if (!args.input?.intervalMs) { + throwBadUserInputError('No interval provided'); + return null; + } + const queryArguments: HistoricalParkingAverageQueryArguments = { + from: new Date(args.input.from), + intervalMs: args.input.intervalMs, + to: new Date(args.input.to), + } + if (Number.isNaN(queryArguments.from.getTime() + || Number.isNaN(queryArguments.to.getTime()))) { + throwBadUserInputError('One or more incorrect dates provided'); + } + if (queryArguments.from.getTime() > queryArguments.to.getTime()) { + throwBadUserInputError("`from` date can't be greater than the `to` date"); + } + + // Limit queries for improved performance + if (queryArguments.to.getTime() - queryArguments.from.getTime() > PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN) { + throwBadUserInputError('Maximum timespan exceeded'); + } + if (queryArguments.intervalMs < PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL) { + throwBadUserInputError('Provided interval is less than minimum interval'); + } + + const parkingAverages = await system?.parkingRepository?.getHistoricalAveragesOfParkingStructureCounts(parent.id, queryArguments); + if (!parkingAverages) { + return null; + } + + return parkingAverages; + } + } +} diff --git a/src/resolvers/ParkingSystemResolvers.ts b/src/resolvers/ParkingSystemResolvers.ts index 73a5303..076f18e 100644 --- a/src/resolvers/ParkingSystemResolvers.ts +++ b/src/resolvers/ParkingSystemResolvers.ts @@ -11,9 +11,15 @@ export const ParkingSystemResolvers: Resolvers = { const parkingRepository = system.parkingRepository; if (!parkingRepository) return []; - return await parkingRepository.getParkingStructures(); + const parkingStructures = await parkingRepository.getParkingStructures(); + return parkingStructures.map((structure) => { + return { + ...structure, + systemId: parent.systemId + }; + }); }, - parkingStructure: async (parent, args, contextValue, info) => { + parkingStructure: async (parent, args, contextValue, _info) => { if (!args.id) return null; const system = contextValue.findSystemById(parent.systemId); @@ -23,7 +29,11 @@ export const ParkingSystemResolvers: Resolvers = { const parkingRepository = system.parkingRepository; if (!parkingRepository) return null; - return await parkingRepository.getParkingStructureById(args.id); + const parkingStructure = await parkingRepository.getParkingStructureById(args.id); + return parkingStructure ? { + ...parkingStructure, + systemId: parent.systemId, + } : null; }, } } diff --git a/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts b/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts index a57557d..e87556f 100644 --- a/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts +++ b/test/loaders/TimedApiBasedRepositoryLoaderTests.test.ts @@ -1,19 +1,16 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { TimedApiBasedRepositoryLoader } from "../../src/loaders/TimedApiBasedRepositoryLoader"; import { resetGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers"; -import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; import { ApiBasedShuttleRepositoryLoader } from "../../src/loaders/shuttle/ApiBasedShuttleRepositoryLoader"; describe("TimedApiBasedRepositoryLoader", () => { let timedLoader: TimedApiBasedRepositoryLoader; let spies: any; - beforeAll(() => { + beforeEach(() => { jest.useFakeTimers(); jest.spyOn(global, "setTimeout"); - }); - - beforeEach(() => { resetGlobalFetchMockJson(); const mockLoader = new ApiBasedShuttleRepositoryLoader( diff --git a/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts b/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts index ecd6a12..18f22d5 100644 --- a/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts +++ b/test/loaders/parking/ChapmanApiBasedParkingRepositoryLoaderTests.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { ChapmanApiBasedParkingRepositoryLoader } from "../../../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader"; -import { InMemoryParkingRepository } from "../../../src/repositories/InMemoryParkingRepository"; +import { InMemoryParkingRepository } from "../../../src/repositories/parking/InMemoryParkingRepository"; import { resetGlobalFetchMockJson, updateGlobalFetchMockJson, diff --git a/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts b/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts index d829732..6b55712 100644 --- a/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/test/loaders/shuttle/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { ApiBasedShuttleRepositoryLoader } from "../../../src/loaders/shuttle/ApiBasedShuttleRepositoryLoader"; -import { UnoptimizedInMemoryShuttleRepository } from "../../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../../src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; import { fetchRouteDataSuccessfulResponse } from "../../jsonSnapshots/fetchRouteData/fetchRouteDataSuccessfulResponse"; import { fetchStopAndPolylineDataSuccessfulResponse diff --git a/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts index 1c5b819..3fd9192 100644 --- a/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts +++ b/test/notifications/schedulers/ETANotificationSchedulerTests.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { ETANotificationScheduler } from "../../../src/notifications/schedulers/ETANotificationScheduler"; -import { UnoptimizedInMemoryShuttleRepository } from "../../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../../src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; import { IEta, IShuttle, IStop } from "../../../src/entities/ShuttleRepositoryEntities"; import { addMockShuttleToRepository, addMockStopToRepository } from "../../testHelpers/repositorySetupHelpers"; import { AppleNotificationSender } from "../../../src/notifications/senders/AppleNotificationSender"; -import { InMemoryNotificationRepository } from "../../../src/repositories/InMemoryNotificationRepository"; -import { NotificationRepository } from "../../../src/repositories/NotificationRepository"; +import { InMemoryNotificationRepository } from "../../../src/repositories/notifications/InMemoryNotificationRepository"; +import { NotificationRepository } from "../../../src/repositories/notifications/NotificationRepository"; jest.mock("http2"); jest.mock("../../../src/notifications/senders/AppleNotificationSender"); diff --git a/test/repositories/NotificationRepositorySharedTests.test.ts b/test/repositories/NotificationRepositorySharedTests.test.ts index d8df44e..fc96674 100644 --- a/test/repositories/NotificationRepositorySharedTests.test.ts +++ b/test/repositories/NotificationRepositorySharedTests.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { InMemoryNotificationRepository } from "../../src/repositories/InMemoryNotificationRepository"; -import { NotificationEvent, NotificationRepository } from "../../src/repositories/NotificationRepository"; -import { RedisNotificationRepository } from "../../src/repositories/RedisNotificationRepository"; +import { InMemoryNotificationRepository } from "../../src/repositories/notifications/InMemoryNotificationRepository"; +import { NotificationEvent, NotificationRepository } from "../../src/repositories/notifications/NotificationRepository"; +import { RedisNotificationRepository } from "../../src/repositories/notifications/RedisNotificationRepository"; interface RepositoryHolder { name: string; diff --git a/test/repositories/ParkingRepositorySharedTests.test.ts b/test/repositories/ParkingRepositorySharedTests.test.ts index c43cf07..04bfe6f 100644 --- a/test/repositories/ParkingRepositorySharedTests.test.ts +++ b/test/repositories/ParkingRepositorySharedTests.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { InMemoryParkingRepository, } from "../../src/repositories/InMemoryParkingRepository"; +import { InMemoryParkingRepository, } from "../../src/repositories/parking/InMemoryParkingRepository"; import { IParkingStructure } from "../../src/entities/ParkingRepositoryEntities"; -import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository"; -import { ParkingGetterSetterRepository } from "../../src/repositories/ParkingGetterSetterRepository"; -import { RedisParkingRepository } from "../../src/repositories/RedisParkingRepository"; +import { HistoricalParkingAverageQueryArguments } from "../../src/repositories/parking/ParkingGetterRepository"; +import { ParkingGetterSetterRepository } from "../../src/repositories/parking/ParkingGetterSetterRepository"; +import { RedisParkingRepository } from "../../src/repositories/parking/RedisParkingRepository"; interface RepositoryHolder { name: string; @@ -151,9 +151,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { describe("getHistoricalAveragesOfParkingStructureCounts", () => { it("should return empty array for non-existent structure or no data", async () => { - const options: ParkingStructureCountOptions = { - startUnixEpochMs: 1000, - endUnixEpochMs: 2000, + const options: HistoricalParkingAverageQueryArguments = { + from: new Date(1000), + to: new Date(2000), intervalMs: 500 }; @@ -163,7 +163,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]); }); - it("should calculate averages for intervals with manual historical data", async () => { + it("should calculate average for one single large interval", async () => { // Set logging interval to 0 so every update creates historical data repository.setLoggingInterval(0); @@ -182,9 +182,9 @@ describe.each(repositoryImplementations)('$name', (holder) => { } const now = Date.now(); - const options: ParkingStructureCountOptions = { - startUnixEpochMs: now - 10000, // Look back 10 seconds - endUnixEpochMs: now + 10000, // Look forward 10 seconds + const options: HistoricalParkingAverageQueryArguments = { + from: new Date(now - 10000), // Look back 10 seconds + to: new Date(now + 10000), // Look forward 10 seconds intervalMs: 20000 // Single large interval }; @@ -193,8 +193,10 @@ describe.each(repositoryImplementations)('$name', (holder) => { // Should have at least some historical data expect(result.length).toEqual(1); if (result.length > 0) { - expect(result[0]).toHaveProperty('fromUnixEpochMs'); - expect(result[0]).toHaveProperty('toUnixEpochMs'); + expect(result[0]).toHaveProperty('from'); + expect(result[0]).toHaveProperty('to'); + expect(result[0].from).toBeInstanceOf(Date); + expect(result[0].to).toBeInstanceOf(Date); expect(result[0]).toHaveProperty('averageSpotsAvailable'); expect(result[0].averageSpotsAvailable).toBeCloseTo(52.5); } diff --git a/test/repositories/UnoptimizedInMemoryShuttleRepositoryTests.test.ts b/test/repositories/UnoptimizedInMemoryShuttleRepositoryTests.test.ts index e1d9ba7..c5a840d 100644 --- a/test/repositories/UnoptimizedInMemoryShuttleRepositoryTests.test.ts +++ b/test/repositories/UnoptimizedInMemoryShuttleRepositoryTests.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, jest, test } from "@jest/globals"; -import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/UnoptimizedInMemoryShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; import { generateMockEtas, generateMockOrderedStops, diff --git a/test/resolvers/ParkingStructureResolverTests.test.ts b/test/resolvers/ParkingStructureResolverTests.test.ts new file mode 100644 index 0000000..7133c57 --- /dev/null +++ b/test/resolvers/ParkingStructureResolverTests.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers"; +import { InterchangeSystem } from "../../src/entities/InterchangeSystem"; +import { generateParkingStructures } from "../testHelpers/mockDataGenerators"; +import { HistoricalParkingAverageQueryInput } from "../../src/generated/graphql"; +import assert = require("node:assert"); + +jest.mock("../../src/environment"); + +describe("ParkingStructureResolver", () => { + const holder = setupTestServerHolder(); + const context = setupTestServerContext(); + + let mockSystem: InterchangeSystem; + + beforeEach(async () => { + mockSystem = context.systems[0]; + jest.useRealTimers(); + }); + + describe("historicalAverages", () => { + const query = ` + query GetParkingStructureHistoricalAverages( + $systemId: ID!, + $parkingStructureId: ID!, + $historicalAverageInput: HistoricalParkingAverageQueryInput! + ) { + system(id: $systemId) { + parkingSystem { + parkingStructure(id: $parkingStructureId) { + historicalAverages(input: $historicalAverageInput) { + from + to + averageSpotsAvailable + } + } + } + } + } + `; + + it("gets data for historical averages", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + + const parkingStructure = generateParkingStructures()[0]; + parkingStructure.spotsAvailable = parkingStructure.capacity; + mockSystem.parkingRepository?.setLoggingInterval(100); + + // Simulate repeated updates + for (let i = 0; i < 6; i += 1) { + jest.setSystemTime(new Date(Date.now() + 1000)); + parkingStructure.spotsAvailable = parkingStructure.spotsAvailable - 100; + await mockSystem.parkingRepository?.addOrUpdateParkingStructure(parkingStructure); + } + + const historicalAverageInput: HistoricalParkingAverageQueryInput = { + from: new Date(Date.now() - 5000).getTime(), + intervalMs: 2000, + to: new Date().getTime(), + }; + const response = await holder.testServer.executeOperation({ + query, + variables: { + systemId: mockSystem.id, + parkingStructureId: parkingStructure.id, + historicalAverageInput, + }, + }, { + contextValue: context, + }); + + assert(response.body.kind === 'single'); + expect(response.body.singleResult.errors).toBeUndefined(); + const historicalAverages = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure.historicalAverages; + expect(historicalAverages).toHaveLength(3); + }); + }); +}); diff --git a/test/resolvers/QueryResolverTests.test.ts b/test/resolvers/QueryResolverTests.test.ts index 3bc3dae..c24cc61 100644 --- a/test/resolvers/QueryResolverTests.test.ts +++ b/test/resolvers/QueryResolverTests.test.ts @@ -6,7 +6,7 @@ import { } from "../testHelpers/apolloTestServerHelpers"; import assert = require("node:assert"); import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; -import { ScheduledNotification } from "../../src/repositories/NotificationRepository"; +import { ScheduledNotification } from "../../src/repositories/notifications/NotificationRepository"; // See Apollo documentation for integration test guide // https://www.apollographql.com/docs/apollo-server/testing/testing diff --git a/test/testHelpers/repositorySetupHelpers.ts b/test/testHelpers/repositorySetupHelpers.ts index 85e5e1e..c262ab1 100644 --- a/test/testHelpers/repositorySetupHelpers.ts +++ b/test/testHelpers/repositorySetupHelpers.ts @@ -4,7 +4,7 @@ import { generateMockShuttles, generateMockStops, } from "./mockDataGenerators"; -import { ShuttleGetterSetterRepository } from "../../src/repositories/ShuttleGetterSetterRepository"; +import { ShuttleGetterSetterRepository } from "../../src/repositories/shuttle/ShuttleGetterSetterRepository"; export async function addMockRouteToRepository(repository: ShuttleGetterSetterRepository, systemId: string) { const mockRoutes = generateMockRoutes();