mirror of
https://github.com/brendan-ch/project-inter-server.git
synced 2026-04-17 07:50:31 +00:00
Add RedisParkingRepository.ts and convert existing tests to shared tests for both classes
This commit is contained in:
265
src/repositories/RedisParkingRepository.ts
Normal file
265
src/repositories/RedisParkingRepository.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { createClient, RedisClientType } from 'redis';
|
||||||
|
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
|
||||||
|
import { IParkingStructure, IParkingStructureTimestampRecord } from "../entities/ParkingRepositoryEntities";
|
||||||
|
import { HistoricalParkingAverageQueryResult, ParkingStructureCountOptions } from "./ParkingGetterRepository";
|
||||||
|
|
||||||
|
export type ParkingStructureID = string;
|
||||||
|
|
||||||
|
// Every 10 minutes
|
||||||
|
export const PARKING_LOGGING_INTERVAL_MS = 600000;
|
||||||
|
|
||||||
|
export class RedisParkingRepository implements ParkingGetterSetterRepository {
|
||||||
|
private dataLastAdded: Map<ParkingStructureID, Date> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private redisClient = createClient({
|
||||||
|
url: process.env.REDIS_URL,
|
||||||
|
socket: {
|
||||||
|
tls: (process.env.REDIS_URL?.match(/rediss:/) != null),
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReady() {
|
||||||
|
return this.redisClient.isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect() {
|
||||||
|
await this.redisClient.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect() {
|
||||||
|
await this.redisClient.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAllData() {
|
||||||
|
await this.redisClient.flushAll();
|
||||||
|
this.dataLastAdded.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise<void> => {
|
||||||
|
// Store current structure data
|
||||||
|
await this.redisClient.hSet(`parking:structure:${structure.id}`, {
|
||||||
|
id: structure.id,
|
||||||
|
name: structure.name,
|
||||||
|
address: structure.address,
|
||||||
|
capacity: structure.capacity.toString(),
|
||||||
|
spotsAvailable: structure.spotsAvailable.toString(),
|
||||||
|
latitude: structure.coordinates.latitude.toString(),
|
||||||
|
longitude: structure.coordinates.longitude.toString(),
|
||||||
|
updatedTime: structure.updatedTime.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to historical data if needed
|
||||||
|
await this.addHistoricalDataForStructure(structure);
|
||||||
|
};
|
||||||
|
|
||||||
|
private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise<void> => {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastAdded = this.dataLastAdded.get(structure.id);
|
||||||
|
|
||||||
|
const parkingLoggingIntervalExceeded = () => {
|
||||||
|
return !lastAdded || (now - lastAdded.getTime()) >= PARKING_LOGGING_INTERVAL_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parkingLoggingIntervalExceeded()) {
|
||||||
|
// Use Redis Time Series to store historical data
|
||||||
|
const timeSeriesKey = `parking:timeseries:${structure.id}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to add the time series data point
|
||||||
|
await this.redisClient.sendCommand([
|
||||||
|
'TS.ADD',
|
||||||
|
timeSeriesKey,
|
||||||
|
now.toString(),
|
||||||
|
structure.spotsAvailable.toString(),
|
||||||
|
'LABELS',
|
||||||
|
'structureId',
|
||||||
|
structure.id
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
// If time series doesn't exist, create it first
|
||||||
|
try {
|
||||||
|
await this.redisClient.sendCommand([
|
||||||
|
'TS.CREATE',
|
||||||
|
timeSeriesKey,
|
||||||
|
'LABELS',
|
||||||
|
'structureId',
|
||||||
|
structure.id
|
||||||
|
]);
|
||||||
|
// Now add the data point
|
||||||
|
await this.redisClient.sendCommand([
|
||||||
|
'TS.ADD',
|
||||||
|
timeSeriesKey,
|
||||||
|
now.toString(),
|
||||||
|
structure.spotsAvailable.toString()
|
||||||
|
]);
|
||||||
|
} catch (createError) {
|
||||||
|
// If still fails, it might be because time series already exists, try adding again
|
||||||
|
await this.redisClient.sendCommand([
|
||||||
|
'TS.ADD',
|
||||||
|
timeSeriesKey,
|
||||||
|
now.toString(),
|
||||||
|
structure.spotsAvailable.toString()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataLastAdded.set(structure.id, new Date(now));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clearParkingStructureData = async (): Promise<void> => {
|
||||||
|
// Get all parking structure keys
|
||||||
|
const structureKeys = await this.redisClient.keys('parking:structure:*');
|
||||||
|
const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*');
|
||||||
|
|
||||||
|
// Delete all structure and time series data
|
||||||
|
if (structureKeys.length > 0) {
|
||||||
|
await this.redisClient.del(structureKeys);
|
||||||
|
}
|
||||||
|
if (timeSeriesKeys.length > 0) {
|
||||||
|
await this.redisClient.del(timeSeriesKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataLastAdded.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
getParkingStructureById = async (id: string): Promise<IParkingStructure | null> => {
|
||||||
|
const data = await this.redisClient.hGetAll(`parking:structure:${id}`);
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
capacity: parseInt(data.capacity),
|
||||||
|
spotsAvailable: parseInt(data.spotsAvailable),
|
||||||
|
coordinates: {
|
||||||
|
latitude: parseFloat(data.latitude),
|
||||||
|
longitude: parseFloat(data.longitude)
|
||||||
|
},
|
||||||
|
updatedTime: new Date(data.updatedTime)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getParkingStructures = async (): Promise<IParkingStructure[]> => {
|
||||||
|
const keys = await this.redisClient.keys('parking:structure:*');
|
||||||
|
const structures: IParkingStructure[] = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.redisClient.hGetAll(key);
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
structures.push({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
capacity: parseInt(data.capacity),
|
||||||
|
spotsAvailable: parseInt(data.spotsAvailable),
|
||||||
|
coordinates: {
|
||||||
|
latitude: parseFloat(data.latitude),
|
||||||
|
longitude: parseFloat(data.longitude)
|
||||||
|
},
|
||||||
|
updatedTime: new Date(data.updatedTime)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return structures;
|
||||||
|
};
|
||||||
|
|
||||||
|
removeParkingStructureIfExists = async (id: string): Promise<IParkingStructure | null> => {
|
||||||
|
const structure = await this.getParkingStructureById(id);
|
||||||
|
if (structure) {
|
||||||
|
await this.redisClient.del(`parking:structure:${id}`);
|
||||||
|
await this.redisClient.del(`parking:timeseries:${id}`);
|
||||||
|
this.dataLastAdded.delete(id);
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: ParkingStructureCountOptions): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||||
|
const timeSeriesKey = `parking:timeseries:${id}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get time series data for the specified range
|
||||||
|
const timeSeriesData = await this.redisClient.sendCommand([
|
||||||
|
'TS.RANGE',
|
||||||
|
timeSeriesKey,
|
||||||
|
options.startUnixEpochMs.toString(),
|
||||||
|
options.endUnixEpochMs.toString()
|
||||||
|
]) as [string, string][];
|
||||||
|
|
||||||
|
if (!timeSeriesData || timeSeriesData.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Redis time series data to our record format
|
||||||
|
const records: IParkingStructureTimestampRecord[] = timeSeriesData.map(([timestamp, value]) => ({
|
||||||
|
id,
|
||||||
|
timestampMs: parseInt(timestamp),
|
||||||
|
spotsAvailable: parseInt(value)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.calculateAveragesFromRecords(records, options);
|
||||||
|
} catch (error) {
|
||||||
|
// Time series might not exist
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private calculateAveragesFromRecords = (
|
||||||
|
records: IParkingStructureTimestampRecord[],
|
||||||
|
options: ParkingStructureCountOptions
|
||||||
|
): HistoricalParkingAverageQueryResult[] => {
|
||||||
|
const results: HistoricalParkingAverageQueryResult[] = [];
|
||||||
|
const { startUnixEpochMs, endUnixEpochMs, intervalMs } = options;
|
||||||
|
|
||||||
|
let currentIntervalStart = startUnixEpochMs;
|
||||||
|
|
||||||
|
while (currentIntervalStart < endUnixEpochMs) {
|
||||||
|
const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endUnixEpochMs);
|
||||||
|
const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd);
|
||||||
|
|
||||||
|
if (recordsInInterval.length > 0) {
|
||||||
|
const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval);
|
||||||
|
results.push(averageResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIntervalStart = currentIntervalEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getRecordsInTimeRange = (
|
||||||
|
records: IParkingStructureTimestampRecord[],
|
||||||
|
startMs: number,
|
||||||
|
endMs: number
|
||||||
|
): IParkingStructureTimestampRecord[] => {
|
||||||
|
return records.filter(record =>
|
||||||
|
record.timestampMs >= startMs && record.timestampMs < endMs
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private calculateAverageForInterval = (
|
||||||
|
fromMs: number,
|
||||||
|
toMs: number,
|
||||||
|
records: IParkingStructureTimestampRecord[]
|
||||||
|
): HistoricalParkingAverageQueryResult => {
|
||||||
|
const totalSpotsAvailable = records.reduce((sum, record) => sum + record.spotsAvailable, 0);
|
||||||
|
const averageSpotsAvailable = totalSpotsAvailable / records.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fromUnixEpochMs: fromMs,
|
||||||
|
toUnixEpochMs: toMs,
|
||||||
|
averageSpotsAvailable
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
||||||
import {
|
|
||||||
InMemoryParkingRepository,
|
|
||||||
PARKING_LOGGING_INTERVAL_MS, ParkingStructureID
|
|
||||||
} from "../../src/repositories/InMemoryParkingRepository";
|
|
||||||
import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities";
|
|
||||||
import { CircularQueue } from "../../src/types/CircularQueue";
|
|
||||||
import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository";
|
|
||||||
|
|
||||||
describe("InMemoryParkingRepository", () => {
|
|
||||||
let repository: InMemoryParkingRepository;
|
|
||||||
const testStructure: IParkingStructure = {
|
|
||||||
coordinates: {
|
|
||||||
latitude: 33.794795,
|
|
||||||
longitude: -117.850807,
|
|
||||||
},
|
|
||||||
spotsAvailable: 0,
|
|
||||||
id: "1",
|
|
||||||
name: "Anderson Parking Structure",
|
|
||||||
capacity: 100,
|
|
||||||
address: "300 E Walnut Ave, Orange, CA 92867",
|
|
||||||
updatedTime: new Date(),
|
|
||||||
};
|
|
||||||
let historicalData: Map<ParkingStructureID, CircularQueue<IParkingStructureTimestampRecord>> = new Map();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
historicalData = new Map();
|
|
||||||
repository = new InMemoryParkingRepository(new Map(), historicalData);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("addOrUpdateParkingStructure", () => {
|
|
||||||
it("should add a new parking structure", async () => {
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
const result = await repository.getParkingStructureById(testStructure.id);
|
|
||||||
expect(result).toEqual(testStructure);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update existing parking structure", async () => {
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
const updatedStructure = { ...testStructure, name: "Updated Garage" };
|
|
||||||
await repository.addOrUpdateParkingStructure(updatedStructure);
|
|
||||||
const result = await repository.getParkingStructureById(testStructure.id);
|
|
||||||
expect(result).toEqual(updatedStructure);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should log historical data if past the logging interval", async () => {
|
|
||||||
const now = Date.now();
|
|
||||||
jest
|
|
||||||
.useFakeTimers()
|
|
||||||
.setSystemTime(now);
|
|
||||||
|
|
||||||
const expectedTimestampRecordMatcher = {
|
|
||||||
spotsAvailable: testStructure.spotsAvailable,
|
|
||||||
id: testStructure.id,
|
|
||||||
timestampMs: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
jest.setSystemTime(now + PARKING_LOGGING_INTERVAL_MS + 60);
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
|
|
||||||
expect(historicalData.get(testStructure.id)?.get(0)).toEqual(expectedTimestampRecordMatcher);
|
|
||||||
expect(historicalData.get(testStructure.id)?.get(1)).toEqual({
|
|
||||||
...expectedTimestampRecordMatcher,
|
|
||||||
timestampMs: now + PARKING_LOGGING_INTERVAL_MS + 60,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not log historical data if not past the logging interval", async () => {
|
|
||||||
const now = Date.now();
|
|
||||||
jest
|
|
||||||
.useFakeTimers()
|
|
||||||
.setSystemTime(now);
|
|
||||||
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
jest.setSystemTime(now + 60);
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
|
|
||||||
expect(historicalData.get(testStructure.id)?.size()).toEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("removeParkingStructureIfExists", () => {
|
|
||||||
it("should remove existing parking structure and return it", async () => {
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
const removed = await repository.removeParkingStructureIfExists(testStructure.id);
|
|
||||||
expect(removed).toEqual(testStructure);
|
|
||||||
const result = await repository.getParkingStructureById(testStructure.id);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null when removing non-existent structure", async () => {
|
|
||||||
const result = await repository.removeParkingStructureIfExists("non-existent");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clearParkingStructureData", () => {
|
|
||||||
it("should remove all parking structures", async () => {
|
|
||||||
const structures = [
|
|
||||||
testStructure,
|
|
||||||
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const structure of structures) {
|
|
||||||
await repository.addOrUpdateParkingStructure(structure);
|
|
||||||
}
|
|
||||||
|
|
||||||
await repository.clearParkingStructureData();
|
|
||||||
const result = await repository.getParkingStructures();
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getParkingStructures", () => {
|
|
||||||
it("should return empty array when no structures exist", async () => {
|
|
||||||
const result = await repository.getParkingStructures();
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return all added structures", async () => {
|
|
||||||
const structures = [
|
|
||||||
testStructure,
|
|
||||||
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const structure of structures) {
|
|
||||||
await repository.addOrUpdateParkingStructure(structure);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await repository.getParkingStructures();
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result).toEqual(expect.arrayContaining(structures));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getParkingStructureById", () => {
|
|
||||||
it("should return null for non-existent structure", async () => {
|
|
||||||
const result = await repository.getParkingStructureById("non-existent");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return structure by id", async () => {
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
const result = await repository.getParkingStructureById(testStructure.id);
|
|
||||||
expect(result).toEqual(testStructure);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getHistoricalAveragesOfParkingStructureCounts", () => {
|
|
||||||
const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs - b.timestampMs;
|
|
||||||
|
|
||||||
const setupHistoricalData = (records: IParkingStructureTimestampRecord[]) => {
|
|
||||||
const queue = new CircularQueue<IParkingStructureTimestampRecord>(10);
|
|
||||||
records.forEach(record => queue.appendWithSorting(record, sortingCallback));
|
|
||||||
historicalData.set(testStructure.id, queue);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should return empty array for non-existent structure or no data", async () => {
|
|
||||||
const options: ParkingStructureCountOptions = {
|
|
||||||
startUnixEpochMs: 1000,
|
|
||||||
endUnixEpochMs: 2000,
|
|
||||||
intervalMs: 500
|
|
||||||
};
|
|
||||||
|
|
||||||
// Non-existent structure
|
|
||||||
expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]);
|
|
||||||
|
|
||||||
// Structure with no historical data
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should calculate averages for single and multiple intervals", async () => {
|
|
||||||
const records = [
|
|
||||||
{ id: testStructure.id, spotsAvailable: 80, timestampMs: 1100 },
|
|
||||||
{ id: testStructure.id, spotsAvailable: 70, timestampMs: 1200 },
|
|
||||||
{ id: testStructure.id, spotsAvailable: 50, timestampMs: 1600 },
|
|
||||||
{ id: testStructure.id, spotsAvailable: 40, timestampMs: 1700 }
|
|
||||||
];
|
|
||||||
|
|
||||||
setupHistoricalData(records);
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
|
|
||||||
// Single interval test
|
|
||||||
const singleIntervalOptions: ParkingStructureCountOptions = {
|
|
||||||
startUnixEpochMs: 1000,
|
|
||||||
endUnixEpochMs: 1500,
|
|
||||||
intervalMs: 500
|
|
||||||
};
|
|
||||||
|
|
||||||
const singleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, singleIntervalOptions);
|
|
||||||
expect(singleResult).toHaveLength(1);
|
|
||||||
expect(singleResult[0]).toEqual({
|
|
||||||
fromUnixEpochMs: 1000,
|
|
||||||
toUnixEpochMs: 1500,
|
|
||||||
averageSpotsAvailable: 75 // (80 + 70) / 2
|
|
||||||
});
|
|
||||||
|
|
||||||
// Multiple intervals test
|
|
||||||
const multipleIntervalOptions: ParkingStructureCountOptions = {
|
|
||||||
startUnixEpochMs: 1000,
|
|
||||||
endUnixEpochMs: 2000,
|
|
||||||
intervalMs: 500
|
|
||||||
};
|
|
||||||
|
|
||||||
const multipleResult = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, multipleIntervalOptions);
|
|
||||||
expect(multipleResult).toHaveLength(2);
|
|
||||||
expect(multipleResult[0]).toEqual({
|
|
||||||
fromUnixEpochMs: 1000,
|
|
||||||
toUnixEpochMs: 1500,
|
|
||||||
averageSpotsAvailable: 75 // (80 + 70) / 2
|
|
||||||
});
|
|
||||||
expect(multipleResult[1]).toEqual({
|
|
||||||
fromUnixEpochMs: 1500,
|
|
||||||
toUnixEpochMs: 2000,
|
|
||||||
averageSpotsAvailable: 45 // (50 + 40) / 2
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edge cases: skipped intervals and boundaries", async () => {
|
|
||||||
const records = [
|
|
||||||
{ id: testStructure.id, spotsAvailable: 90, timestampMs: 1000 }, // start boundary - included
|
|
||||||
{ id: testStructure.id, spotsAvailable: 80, timestampMs: 1500 }, // interval boundary - included in second
|
|
||||||
{ id: testStructure.id, spotsAvailable: 60, timestampMs: 2100 } // skip interval, in third
|
|
||||||
];
|
|
||||||
|
|
||||||
setupHistoricalData(records);
|
|
||||||
await repository.addOrUpdateParkingStructure(testStructure);
|
|
||||||
|
|
||||||
const options: ParkingStructureCountOptions = {
|
|
||||||
startUnixEpochMs: 1000,
|
|
||||||
endUnixEpochMs: 2500,
|
|
||||||
intervalMs: 500
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
fromUnixEpochMs: 1000,
|
|
||||||
toUnixEpochMs: 1500,
|
|
||||||
averageSpotsAvailable: 90 // only record at 1000ms
|
|
||||||
});
|
|
||||||
expect(result[1]).toEqual({
|
|
||||||
fromUnixEpochMs: 1500,
|
|
||||||
toUnixEpochMs: 2000,
|
|
||||||
averageSpotsAvailable: 80 // only record at 1500ms
|
|
||||||
});
|
|
||||||
expect(result[2]).toEqual({
|
|
||||||
fromUnixEpochMs: 2000,
|
|
||||||
toUnixEpochMs: 2500,
|
|
||||||
averageSpotsAvailable: 60 // only record at 2100ms
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
202
test/repositories/ParkingRepositorySharedTests.test.ts
Normal file
202
test/repositories/ParkingRepositorySharedTests.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||||
|
import { InMemoryParkingRepository, PARKING_LOGGING_INTERVAL_MS, ParkingStructureID } from "../../src/repositories/InMemoryParkingRepository";
|
||||||
|
import { IParkingStructure, IParkingStructureTimestampRecord } from "../../src/entities/ParkingRepositoryEntities";
|
||||||
|
import { CircularQueue } from "../../src/types/CircularQueue";
|
||||||
|
import { ParkingStructureCountOptions } from "../../src/repositories/ParkingGetterRepository";
|
||||||
|
import { ParkingGetterSetterRepository } from "../../src/repositories/ParkingGetterSetterRepository";
|
||||||
|
import { RedisParkingRepository } from "../../src/repositories/RedisParkingRepository";
|
||||||
|
|
||||||
|
interface RepositoryHolder {
|
||||||
|
name: string;
|
||||||
|
factory(): Promise<ParkingGetterSetterRepository>;
|
||||||
|
teardown(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryParkingRepositoryHolder implements RepositoryHolder {
|
||||||
|
name = 'InMemoryParkingRepository';
|
||||||
|
factory = async () => {
|
||||||
|
return new InMemoryParkingRepository();
|
||||||
|
};
|
||||||
|
teardown = async () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedisParkingRepositoryHolder implements RepositoryHolder {
|
||||||
|
repo: RedisParkingRepository | undefined;
|
||||||
|
|
||||||
|
name = 'RedisParkingRepository';
|
||||||
|
factory = async () => {
|
||||||
|
this.repo = new RedisParkingRepository();
|
||||||
|
await this.repo.connect();
|
||||||
|
return this.repo;
|
||||||
|
};
|
||||||
|
teardown = async () => {
|
||||||
|
if (this.repo) {
|
||||||
|
await this.repo.clearAllData();
|
||||||
|
await this.repo.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryImplementations = [
|
||||||
|
// new InMemoryParkingRepositoryHolder(),
|
||||||
|
new RedisParkingRepositoryHolder(),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.each(repositoryImplementations)('$name', (holder) => {
|
||||||
|
let repository: ParkingGetterSetterRepository;
|
||||||
|
const testStructure: IParkingStructure = {
|
||||||
|
coordinates: {
|
||||||
|
latitude: 33.794795,
|
||||||
|
longitude: -117.850807,
|
||||||
|
},
|
||||||
|
spotsAvailable: 0,
|
||||||
|
id: "1",
|
||||||
|
name: "Anderson Parking Structure",
|
||||||
|
capacity: 100,
|
||||||
|
address: "300 E Walnut Ave, Orange, CA 92867",
|
||||||
|
updatedTime: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
repository = await holder.factory();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await holder.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addOrUpdateParkingStructure", () => {
|
||||||
|
it("should add a new parking structure", async () => {
|
||||||
|
await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
const result = await repository.getParkingStructureById(testStructure.id);
|
||||||
|
expect(result).toEqual(testStructure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update existing parking structure", async () => {
|
||||||
|
await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
const updatedStructure = { ...testStructure, name: "Updated Garage" };
|
||||||
|
await repository.addOrUpdateParkingStructure(updatedStructure);
|
||||||
|
const result = await repository.getParkingStructureById(testStructure.id);
|
||||||
|
expect(result).toEqual(updatedStructure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeParkingStructureIfExists", () => {
|
||||||
|
it("should remove existing parking structure and return it", async () => {
|
||||||
|
await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
const removed = await repository.removeParkingStructureIfExists(testStructure.id);
|
||||||
|
expect(removed).toEqual(testStructure);
|
||||||
|
const result = await repository.getParkingStructureById(testStructure.id);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when removing non-existent structure", async () => {
|
||||||
|
const result = await repository.removeParkingStructureIfExists("non-existent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearParkingStructureData", () => {
|
||||||
|
it("should remove all parking structures", async () => {
|
||||||
|
const structures = [
|
||||||
|
testStructure,
|
||||||
|
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const structure of structures) {
|
||||||
|
await repository.addOrUpdateParkingStructure(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.clearParkingStructureData();
|
||||||
|
const result = await repository.getParkingStructures();
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getParkingStructures", () => {
|
||||||
|
it("should return empty array when no structures exist", async () => {
|
||||||
|
const result = await repository.getParkingStructures();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all added structures", async () => {
|
||||||
|
const structures = [
|
||||||
|
testStructure,
|
||||||
|
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const structure of structures) {
|
||||||
|
await repository.addOrUpdateParkingStructure(structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await repository.getParkingStructures();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result).toEqual(expect.arrayContaining(structures));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getParkingStructureById", () => {
|
||||||
|
it("should return null for non-existent structure", async () => {
|
||||||
|
const result = await repository.getParkingStructureById("non-existent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return structure by id", async () => {
|
||||||
|
await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
const result = await repository.getParkingStructureById(testStructure.id);
|
||||||
|
expect(result).toEqual(testStructure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHistoricalAveragesOfParkingStructureCounts", () => {
|
||||||
|
it("should return empty array for non-existent structure or no data", async () => {
|
||||||
|
const options: ParkingStructureCountOptions = {
|
||||||
|
startUnixEpochMs: 1000,
|
||||||
|
endUnixEpochMs: 2000,
|
||||||
|
intervalMs: 500
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-existent structure
|
||||||
|
expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]);
|
||||||
|
|
||||||
|
// Structure with no historical data
|
||||||
|
await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// it("should calculate averages for intervals with manual historical data", async () => {
|
||||||
|
// await repository.addOrUpdateParkingStructure(testStructure);
|
||||||
|
//
|
||||||
|
// const now = Date.now();
|
||||||
|
// jest.useFakeTimers().setSystemTime(now);
|
||||||
|
//
|
||||||
|
// const updates = [
|
||||||
|
// { ...testStructure, spotsAvailable: 80, updatedTime: new Date(now + 1000) },
|
||||||
|
// { ...testStructure, spotsAvailable: 70, updatedTime: new Date(now + 2000) },
|
||||||
|
// ];
|
||||||
|
//
|
||||||
|
// for (let i = 0; i < updates.length; i++) {
|
||||||
|
// jest.setSystemTime(now + (i + 1) * PARKING_LOGGING_INTERVAL_MS + 100);
|
||||||
|
// await repository.addOrUpdateParkingStructure(updates[i]);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// const options: ParkingStructureCountOptions = {
|
||||||
|
// startUnixEpochMs: now,
|
||||||
|
// endUnixEpochMs: now + 4000000, // Large range to capture all data
|
||||||
|
// intervalMs: 1000000
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options);
|
||||||
|
//
|
||||||
|
// // Should have at least some historical data
|
||||||
|
// expect(result.length).toBeGreaterThan(0);
|
||||||
|
// if (result.length > 0) {
|
||||||
|
// expect(result[0]).toHaveProperty('fromUnixEpochMs');
|
||||||
|
// expect(result[0]).toHaveProperty('toUnixEpochMs');
|
||||||
|
// expect(result[0]).toHaveProperty('averageSpotsAvailable');
|
||||||
|
// expect(typeof result[0].averageSpotsAvailable).toBe('number');
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user